@isentinel/jest-roblox 0.3.8 → 0.3.10
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 +1 -1
- package/bin/jest-roblox.js +4 -7
- package/dist/cli.mjs +3 -2
- package/dist/index.d.mts +92 -4
- package/dist/index.mjs +52 -2
- package/dist/{run-Cirdb_CB.mjs → run-BKP7mem2.mjs} +442 -239
- package/package.json +8 -8
|
@@ -36,7 +36,7 @@ import { once } from "node:events";
|
|
|
36
36
|
import { parseJSONC, parseYAML } from "confbox";
|
|
37
37
|
import { Visitor, parseSync } from "oxc-parser";
|
|
38
38
|
//#region package.json
|
|
39
|
-
var version = "0.3.
|
|
39
|
+
var version = "0.3.10";
|
|
40
40
|
//#endregion
|
|
41
41
|
//#region src/config/errors.ts
|
|
42
42
|
var ConfigError = class extends Error {
|
|
@@ -438,7 +438,11 @@ function resolveConfig(config) {
|
|
|
438
438
|
const { test, ...rest } = config;
|
|
439
439
|
const definedRest = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== void 0));
|
|
440
440
|
const definedTest = test === void 0 ? {} : Object.fromEntries(Object.entries(test).filter(([, value]) => value !== void 0));
|
|
441
|
-
const resolved =
|
|
441
|
+
const resolved = {
|
|
442
|
+
...DEFAULT_CONFIG,
|
|
443
|
+
...definedTest,
|
|
444
|
+
...definedRest
|
|
445
|
+
};
|
|
442
446
|
if (config.gameOutput === true) resolved.gameOutput = path$1.join(resolved.rootDir, "game-output.log");
|
|
443
447
|
if (config.outputFile === true) resolved.outputFile = path$1.join(resolved.rootDir, "jest-output.log");
|
|
444
448
|
return resolved;
|
|
@@ -820,7 +824,7 @@ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
|
|
|
820
824
|
});
|
|
821
825
|
if (locations.length === 0) continue;
|
|
822
826
|
const firstLocation = locations[0];
|
|
823
|
-
const lastLocation = locations
|
|
827
|
+
const lastLocation = locations.at(-1);
|
|
824
828
|
assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
|
|
825
829
|
fileBranches.push({
|
|
826
830
|
armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
|
|
@@ -1023,7 +1027,7 @@ function mapFileBranches(resources, fileCoverage, pendingBranches) {
|
|
|
1023
1027
|
pendingBranches.set(result.tsPath, fileBranches);
|
|
1024
1028
|
}
|
|
1025
1029
|
const firstLocation = result.locations[0];
|
|
1026
|
-
const lastLocation = result.locations
|
|
1030
|
+
const lastLocation = result.locations.at(-1);
|
|
1027
1031
|
assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after successful mapping");
|
|
1028
1032
|
fileBranches.push({
|
|
1029
1033
|
armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
|
|
@@ -1530,19 +1534,22 @@ function loadRojoProject(projectPath) {
|
|
|
1530
1534
|
tree
|
|
1531
1535
|
};
|
|
1532
1536
|
}
|
|
1537
|
+
const TrailingSlashPattern = /\/$/;
|
|
1533
1538
|
function collectMounts(node, currentDataModelPath, classify) {
|
|
1534
1539
|
const result = [];
|
|
1535
1540
|
walk(node, currentDataModelPath, classify, result);
|
|
1536
1541
|
return result;
|
|
1537
1542
|
}
|
|
1538
1543
|
function pruneAncestors(paths) {
|
|
1539
|
-
return paths.filter((candidate) =>
|
|
1544
|
+
return paths.filter((candidate) => {
|
|
1545
|
+
return paths.every((other) => other === candidate || !candidate.startsWith(`${other}/`));
|
|
1546
|
+
});
|
|
1540
1547
|
}
|
|
1541
1548
|
function addDirectoryMount(node, dataModelPath, classify, result) {
|
|
1542
1549
|
const rawPath = node.$path;
|
|
1543
1550
|
if (typeof rawPath !== "string") return;
|
|
1544
1551
|
if (rawPath.endsWith(".project.json")) return;
|
|
1545
|
-
const fsPath = rawPath.replace(
|
|
1552
|
+
const fsPath = rawPath.replace(TrailingSlashPattern, "");
|
|
1546
1553
|
if (classify(fsPath) === "directory") result.push({
|
|
1547
1554
|
dataModelPath,
|
|
1548
1555
|
fsPath
|
|
@@ -1559,10 +1566,11 @@ function walk(node, currentDataModelPath, classify, result) {
|
|
|
1559
1566
|
walk(value, childDataModelPath, classify, result);
|
|
1560
1567
|
}
|
|
1561
1568
|
}
|
|
1569
|
+
const TRAILING_SLASH$1$1 = /\/$/;
|
|
1562
1570
|
function matchNodePath(childNode, targetPath, childDataModelPath) {
|
|
1563
1571
|
const nodePath = childNode.$path;
|
|
1564
1572
|
if (typeof nodePath !== "string") return;
|
|
1565
|
-
const normalizedNodePath = nodePath.replace(
|
|
1573
|
+
const normalizedNodePath = nodePath.replace(TRAILING_SLASH$1$1, "");
|
|
1566
1574
|
if (normalizedNodePath === targetPath) return childDataModelPath;
|
|
1567
1575
|
if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
|
|
1568
1576
|
}
|
|
@@ -1750,7 +1758,7 @@ var RojoResolver = class RojoResolver {
|
|
|
1750
1758
|
const relativePath = path.relative(partition.fsPath, stripped);
|
|
1751
1759
|
const relativeParts = relativePath === "" ? [] : relativePath.split(path.sep);
|
|
1752
1760
|
if (ROJO_SCRIPT_EXTS.has(extension) && relativeParts.at(-1) === INIT_NAME) relativeParts.pop();
|
|
1753
|
-
return partition.rbxPath
|
|
1761
|
+
return [...partition.rbxPath, ...relativeParts];
|
|
1754
1762
|
}
|
|
1755
1763
|
}
|
|
1756
1764
|
getRbxTypeFromFilePath(filePath) {
|
|
@@ -1777,8 +1785,8 @@ var RojoResolver = class RojoResolver {
|
|
|
1777
1785
|
rbxPath: partition.rbxPath.slice()
|
|
1778
1786
|
};
|
|
1779
1787
|
}),
|
|
1780
|
-
walkedConfigFiles:
|
|
1781
|
-
walkedDirs:
|
|
1788
|
+
walkedConfigFiles: [...this.walkedConfigFilesInternal],
|
|
1789
|
+
walkedDirs: [...this.walkedDirectoriesInternal],
|
|
1782
1790
|
warnings: this.warnings.slice()
|
|
1783
1791
|
};
|
|
1784
1792
|
}
|
|
@@ -1870,7 +1878,8 @@ var RojoResolver = class RojoResolver {
|
|
|
1870
1878
|
if (!doNotPush) this.rbxPath.push(name);
|
|
1871
1879
|
if (tree.$path !== void 0) this.parsePath(path.resolve(basePath, typeof tree.$path === "string" ? tree.$path : tree.$path.optional));
|
|
1872
1880
|
if (tree.$className === "DataModel") this.isGame = true;
|
|
1873
|
-
|
|
1881
|
+
const childNames = Object.keys(tree).filter((value) => !value.startsWith("$"));
|
|
1882
|
+
for (const childName of childNames) this.parseTree(basePath, childName, tree[childName]);
|
|
1874
1883
|
if (!doNotPush) this.rbxPath.pop();
|
|
1875
1884
|
}
|
|
1876
1885
|
searchChildren(directory, directoryEntries) {
|
|
@@ -1914,6 +1923,91 @@ var RojoResolver = class RojoResolver {
|
|
|
1914
1923
|
}
|
|
1915
1924
|
};
|
|
1916
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
|
|
1917
2011
|
//#region src/reporter/parser.ts
|
|
1918
2012
|
const TASK_SCRIPT_PREFIX = /^TaskScript:\d+:\s*/;
|
|
1919
2013
|
var LuauScriptError = class extends Error {
|
|
@@ -1976,10 +2070,8 @@ function parseJestOutput(output) {
|
|
|
1976
2070
|
}
|
|
1977
2071
|
function countBraces(line) {
|
|
1978
2072
|
let count = 0;
|
|
1979
|
-
for (const character of line) {
|
|
1980
|
-
|
|
1981
|
-
if (character === "}") count--;
|
|
1982
|
-
}
|
|
2073
|
+
for (const character of line) if (character === "{") count++;
|
|
2074
|
+
else if (character === "}") count--;
|
|
1983
2075
|
return count;
|
|
1984
2076
|
}
|
|
1985
2077
|
function isValidJson(text) {
|
|
@@ -2030,56 +2122,8 @@ function extractLuauTiming(parsed) {
|
|
|
2030
2122
|
for (const [key, value] of Object.entries(timing)) if (typeof value === "number") record[key] = value;
|
|
2031
2123
|
return Object.keys(record).length > 0 ? record : void 0;
|
|
2032
2124
|
}
|
|
2033
|
-
/**
|
|
2034
|
-
* Luau 1-based integer-keyed tables serialize as JSON arrays.
|
|
2035
|
-
* Convert arrays to string-keyed Records with 1-based keys to match cov-map format.
|
|
2036
|
-
*/
|
|
2037
|
-
function normalizeHitCounts(data) {
|
|
2038
|
-
if (Array.isArray(data)) {
|
|
2039
|
-
const result = {};
|
|
2040
|
-
let index = 0;
|
|
2041
|
-
for (const element of data) {
|
|
2042
|
-
result[String(index + 1)] = typeof element === "number" ? element : 0;
|
|
2043
|
-
index++;
|
|
2044
|
-
}
|
|
2045
|
-
return result;
|
|
2046
|
-
}
|
|
2047
|
-
if (typeof data === "object" && data !== null) return data;
|
|
2048
|
-
return {};
|
|
2049
|
-
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Normalize branch hit counts from Luau's nested array format.
|
|
2052
|
-
* Luau serializes `__cov_b` as an array of arrays: `[[0,0,0], [0,0]]`.
|
|
2053
|
-
* Convert outer array to string-keyed Record with 1-based keys,
|
|
2054
|
-
* keeping inner arrays as-is.
|
|
2055
|
-
*/
|
|
2056
|
-
function normalizeBranchCounts(data) {
|
|
2057
|
-
if (Array.isArray(data)) {
|
|
2058
|
-
const result = {};
|
|
2059
|
-
let index = 0;
|
|
2060
|
-
for (const inner of data) {
|
|
2061
|
-
if (Array.isArray(inner)) result[String(index + 1)] = inner.map((value) => typeof value === "number" ? value : 0);
|
|
2062
|
-
else result[String(index + 1)] = [];
|
|
2063
|
-
index++;
|
|
2064
|
-
}
|
|
2065
|
-
return result;
|
|
2066
|
-
}
|
|
2067
|
-
if (typeof data === "object" && data !== null) return data;
|
|
2068
|
-
return {};
|
|
2069
|
-
}
|
|
2070
2125
|
function extractCoverageData(parsed) {
|
|
2071
|
-
|
|
2072
|
-
if (coverage === void 0 || coverage === null || typeof coverage !== "object") return;
|
|
2073
|
-
const record = {};
|
|
2074
|
-
for (const [key, value] of Object.entries(coverage)) if (typeof value === "object" && value !== null && "s" in value) {
|
|
2075
|
-
const raw = value;
|
|
2076
|
-
record[key] = {
|
|
2077
|
-
b: raw.b !== void 0 ? normalizeBranchCounts(raw.b) : void 0,
|
|
2078
|
-
f: raw.f !== void 0 ? normalizeHitCounts(raw.f) : void 0,
|
|
2079
|
-
s: normalizeHitCounts(raw.s)
|
|
2080
|
-
};
|
|
2081
|
-
}
|
|
2082
|
-
return Object.keys(record).length > 0 ? record : void 0;
|
|
2126
|
+
return normalizeRawCoverage(parsed["_coverage"]);
|
|
2083
2127
|
}
|
|
2084
2128
|
function extractPerTestCoverage(parsed) {
|
|
2085
2129
|
const raw = parsed["_perTestCoverage"];
|
|
@@ -2507,13 +2551,14 @@ function replacePrefix(filePath, from, to) {
|
|
|
2507
2551
|
}
|
|
2508
2552
|
//#endregion
|
|
2509
2553
|
//#region src/source-mapper/column-finder.ts
|
|
2554
|
+
const EXPECT_CALL = /\bexpect\s*[.(]/;
|
|
2510
2555
|
/**
|
|
2511
2556
|
* Finds the column position of the failing matcher in an expect() call. Returns
|
|
2512
2557
|
* 1-indexed column position, or undefined if no expect is found.
|
|
2513
2558
|
*/
|
|
2514
2559
|
function findExpectationColumn(lineText) {
|
|
2515
2560
|
if (!lineText) return;
|
|
2516
|
-
const expectIndex = lineText.search(
|
|
2561
|
+
const expectIndex = lineText.search(EXPECT_CALL);
|
|
2517
2562
|
if (expectIndex === -1) return;
|
|
2518
2563
|
const afterExpect = lineText.slice(expectIndex);
|
|
2519
2564
|
const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
|
|
@@ -2525,9 +2570,11 @@ function findExpectationColumn(lineText) {
|
|
|
2525
2570
|
}
|
|
2526
2571
|
//#endregion
|
|
2527
2572
|
//#region src/source-mapper/path-resolver.ts
|
|
2573
|
+
const INIT_SEGMENT = /(^|\/)(init)(\.|\/)/;
|
|
2574
|
+
const LEADING_DOT_SLASH = /^\.\//;
|
|
2528
2575
|
/** roblox-ts compiles index.ts → init.luau; reverse the rename for TS paths. */
|
|
2529
2576
|
function luauInitToIndex(filePath) {
|
|
2530
|
-
return filePath.replace(
|
|
2577
|
+
return filePath.replace(INIT_SEGMENT, "$1index$3");
|
|
2531
2578
|
}
|
|
2532
2579
|
function createPathResolver(rojoProject, config) {
|
|
2533
2580
|
const rojoMappings = /* @__PURE__ */ new Map();
|
|
@@ -2542,14 +2589,14 @@ function createPathResolver(rojoProject, config) {
|
|
|
2542
2589
|
}
|
|
2543
2590
|
walkTree(rojoProject.tree, "");
|
|
2544
2591
|
const tsconfigMappings = config?.mappings ?? [];
|
|
2545
|
-
const sortedRojoMappings = [...rojoMappings
|
|
2592
|
+
const sortedRojoMappings = [...rojoMappings].sort(([a], [b]) => b.length - a.length);
|
|
2546
2593
|
return { resolve(dataModelPath) {
|
|
2547
2594
|
for (const [prefix, basePath] of sortedRojoMappings) {
|
|
2548
2595
|
if (dataModelPath !== prefix && !dataModelPath.startsWith(`${prefix}.`)) continue;
|
|
2549
2596
|
const result = `${basePath}/${convertToFilePath(dataModelPath.slice(prefix.length + 1))}`;
|
|
2550
2597
|
const mapping = findMapping(result, tsconfigMappings);
|
|
2551
2598
|
if (mapping !== void 0) return {
|
|
2552
|
-
filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(
|
|
2599
|
+
filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(LEADING_DOT_SLASH, ""))}.ts`,
|
|
2553
2600
|
mapping
|
|
2554
2601
|
};
|
|
2555
2602
|
return { filePath: findLuaFile(result) };
|
|
@@ -2624,6 +2671,8 @@ function getTraceMap(luauPath) {
|
|
|
2624
2671
|
}
|
|
2625
2672
|
//#endregion
|
|
2626
2673
|
//#region src/source-mapper/index.ts
|
|
2674
|
+
const TS_EXTENSION = /\.ts$/;
|
|
2675
|
+
const LEADING_SLASH = /^\//;
|
|
2627
2676
|
function createSourceMapper(config) {
|
|
2628
2677
|
const pathResolver = createPathResolver(config.rojoProject, { mappings: config.mappings });
|
|
2629
2678
|
function mapFrame(frame) {
|
|
@@ -2634,7 +2683,7 @@ function createSourceMapper(config) {
|
|
|
2634
2683
|
mapped: void 0
|
|
2635
2684
|
};
|
|
2636
2685
|
const { filePath, mapping } = resolved;
|
|
2637
|
-
const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(
|
|
2686
|
+
const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(TS_EXTENSION, ".luau");
|
|
2638
2687
|
const v3Result = mapFromSourceMap(luauPath, frame.line, frame.column);
|
|
2639
2688
|
if (v3Result !== void 0 && v3Result.source !== null && v3Result.line !== null) {
|
|
2640
2689
|
const mapDirectory = path$1.dirname(luauPath);
|
|
@@ -2715,7 +2764,7 @@ function createSourceMapper(config) {
|
|
|
2715
2764
|
resolveTestFilePath
|
|
2716
2765
|
};
|
|
2717
2766
|
function resolveTestFilePath(testFilePath) {
|
|
2718
|
-
const dataModelPath = testFilePath.replace(
|
|
2767
|
+
const dataModelPath = testFilePath.replace(LEADING_SLASH, "").replaceAll("/", ".");
|
|
2719
2768
|
return pathResolver.resolve(dataModelPath)?.filePath;
|
|
2720
2769
|
}
|
|
2721
2770
|
}
|
|
@@ -2960,6 +3009,11 @@ function highlightTypeScript(source) {
|
|
|
2960
3009
|
//#endregion
|
|
2961
3010
|
//#region src/formatters/formatter.ts
|
|
2962
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+))?/;
|
|
2963
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 }"]];
|
|
2964
3018
|
function getExecErrorHint(message) {
|
|
2965
3019
|
for (const [pattern, hint] of EXEC_ERROR_HINTS) if (pattern.test(message)) return hint;
|
|
@@ -2975,7 +3029,7 @@ function parseErrorMessage(message) {
|
|
|
2975
3029
|
const lines = message.split("\n");
|
|
2976
3030
|
const firstLine = lines[0];
|
|
2977
3031
|
assert(firstLine !== void 0, "split always returns ≥1 element");
|
|
2978
|
-
const snapshotHeaderIndex = lines.findIndex((line) =>
|
|
3032
|
+
const snapshotHeaderIndex = lines.findIndex((line) => SNAPSHOT_HEADER.test(line));
|
|
2979
3033
|
if (snapshotHeaderIndex !== -1) {
|
|
2980
3034
|
const diffLines = [];
|
|
2981
3035
|
for (let index = snapshotHeaderIndex; index < lines.length; index++) {
|
|
@@ -2988,8 +3042,8 @@ function parseErrorMessage(message) {
|
|
|
2988
3042
|
snapshotDiff: diffLines.join("\n").trimEnd()
|
|
2989
3043
|
};
|
|
2990
3044
|
}
|
|
2991
|
-
const expectedMatch = message.match(
|
|
2992
|
-
const receivedMatch = message.match(
|
|
3045
|
+
const expectedMatch = message.match(EXPECTED_VALUE);
|
|
3046
|
+
const receivedMatch = message.match(RECEIVED_VALUE);
|
|
2993
3047
|
return {
|
|
2994
3048
|
expected: expectedMatch?.[1],
|
|
2995
3049
|
message: firstLine,
|
|
@@ -3018,7 +3072,7 @@ function cleanExecErrorMessage(raw) {
|
|
|
3018
3072
|
}
|
|
3019
3073
|
}
|
|
3020
3074
|
if (contentLine === void 0) return raw.trim();
|
|
3021
|
-
return contentLine.replace(
|
|
3075
|
+
return contentLine.replace(ROBLOX_PATH_CHAIN, "");
|
|
3022
3076
|
}
|
|
3023
3077
|
function formatSourceSnippet(snippet, filePath, options) {
|
|
3024
3078
|
const useColor = options?.useColor ?? true;
|
|
@@ -3047,7 +3101,7 @@ function formatSourceSnippet(snippet, filePath, options) {
|
|
|
3047
3101
|
return lines.join("\n");
|
|
3048
3102
|
}
|
|
3049
3103
|
function parseSourceLocation(message) {
|
|
3050
|
-
const match = message.match(
|
|
3104
|
+
const match = message.match(SOURCE_LOCATION);
|
|
3051
3105
|
if (match === null) return;
|
|
3052
3106
|
const [, filePath, lineStr, columnStr] = match;
|
|
3053
3107
|
assert(filePath !== void 0, "regex group 1 matched");
|
|
@@ -3181,15 +3235,20 @@ function formatTypecheckSummary(result, useColor = true) {
|
|
|
3181
3235
|
parts.push(`\n${label} ${failedPart}${passedPart}, ${String(total)} total\n`);
|
|
3182
3236
|
return parts.join("\n");
|
|
3183
3237
|
}
|
|
3184
|
-
function
|
|
3185
|
-
const styles = createStyles(useColor);
|
|
3238
|
+
function formatTypecheckFileFailures(file, styles) {
|
|
3186
3239
|
const lines = [];
|
|
3187
|
-
for (const
|
|
3240
|
+
for (const test of file.testResults) {
|
|
3188
3241
|
if (test.status !== "failed") continue;
|
|
3189
3242
|
const badge = styles.failBadge(" FAIL ");
|
|
3190
3243
|
lines.push(` ${badge} ${styles.status.fail(test.fullName)}`);
|
|
3191
3244
|
for (const message of test.failureMessages) lines.push(` ${styles.dim(message)}`);
|
|
3192
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));
|
|
3193
3252
|
return lines.join("\n");
|
|
3194
3253
|
}
|
|
3195
3254
|
const PROJECT_BADGE_COLORS = [
|
|
@@ -3433,7 +3492,8 @@ function computeProjectStats(result) {
|
|
|
3433
3492
|
function formatFileFailures(file, options, styles, failureCtx) {
|
|
3434
3493
|
const lines = [];
|
|
3435
3494
|
const displayPath = resolveDisplayPath(file.testFilePath, options.sourceMapper);
|
|
3436
|
-
for (const testCase of file.testResults)
|
|
3495
|
+
for (const testCase of file.testResults) {
|
|
3496
|
+
if (testCase.status !== "failed") continue;
|
|
3437
3497
|
const index = failureCtx.currentIndex;
|
|
3438
3498
|
failureCtx.currentIndex++;
|
|
3439
3499
|
lines.push(formatFailure({
|
|
@@ -3844,7 +3904,8 @@ function formatFileHeaderFailures(file, options) {
|
|
|
3844
3904
|
const totalTests = file.numFailingTests + file.numPassingTests + file.numPendingTests;
|
|
3845
3905
|
const testWord = totalTests === 1 ? "test" : "tests";
|
|
3846
3906
|
lines.push(` ❯ ${relativePath} (${totalTests} ${testWord} | ${file.numFailingTests} failed)`);
|
|
3847
|
-
for (const test of file.testResults)
|
|
3907
|
+
for (const test of file.testResults) {
|
|
3908
|
+
if (test.status !== "failed") continue;
|
|
3848
3909
|
const duration = test.duration !== void 0 ? ` ${String(test.duration)}ms` : "";
|
|
3849
3910
|
lines.push(` × ${test.title}${duration}`);
|
|
3850
3911
|
}
|
|
@@ -4211,7 +4272,8 @@ function createTimingCollector(options = {}) {
|
|
|
4211
4272
|
node.elapsedMs += elapsedMs;
|
|
4212
4273
|
}
|
|
4213
4274
|
function emit(node, depth) {
|
|
4214
|
-
|
|
4275
|
+
const indent = " ".repeat(depth);
|
|
4276
|
+
sink(`[TIMING] ${indent}${node.name}: ${String(Math.round(node.elapsedMs))}ms`);
|
|
4215
4277
|
for (const child of node.children.values()) emit(child, depth + 1);
|
|
4216
4278
|
}
|
|
4217
4279
|
function flushTimingReport() {
|
|
@@ -4310,16 +4372,18 @@ function writeJsonFile(filePath, value) {
|
|
|
4310
4372
|
}
|
|
4311
4373
|
//#endregion
|
|
4312
4374
|
//#region src/executor.ts
|
|
4375
|
+
const TS_SOURCE_EXTENSION$1 = /\.tsx?$/;
|
|
4376
|
+
const TSCONFIG_FILENAME = /^tsconfig.*\.json$/i;
|
|
4313
4377
|
function isLuauProject(testFiles, tsconfigMappings) {
|
|
4314
4378
|
if (tsconfigMappings.length > 0) return false;
|
|
4315
|
-
if (testFiles.some((file) =>
|
|
4379
|
+
if (testFiles.some((file) => TS_SOURCE_EXTENSION$1.test(file))) return false;
|
|
4316
4380
|
return true;
|
|
4317
4381
|
}
|
|
4318
4382
|
function resolveAllTsconfigMappings(projectRoot) {
|
|
4319
4383
|
const resolvedRoot = path$1.resolve(projectRoot);
|
|
4320
4384
|
let files;
|
|
4321
4385
|
try {
|
|
4322
|
-
files = fs$1.readdirSync(resolvedRoot).filter((file) =>
|
|
4386
|
+
files = fs$1.readdirSync(resolvedRoot).filter((file) => TSCONFIG_FILENAME.test(file));
|
|
4323
4387
|
} catch {
|
|
4324
4388
|
return [];
|
|
4325
4389
|
}
|
|
@@ -4785,6 +4849,7 @@ function buildProjectJob(parameters, timing) {
|
|
|
4785
4849
|
//#endregion
|
|
4786
4850
|
//#region src/formatters/github-actions.ts
|
|
4787
4851
|
const SEPARATOR = " · ";
|
|
4852
|
+
const TRAILING_SLASH$4 = /\/$/;
|
|
4788
4853
|
function escapeData(value) {
|
|
4789
4854
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
4790
4855
|
}
|
|
@@ -4826,13 +4891,7 @@ function formatJobSummary(result, options) {
|
|
|
4826
4891
|
});
|
|
4827
4892
|
continue;
|
|
4828
4893
|
}
|
|
4829
|
-
|
|
4830
|
-
if (test.status !== "failed") continue;
|
|
4831
|
-
failures.push({
|
|
4832
|
-
file: makeRelative(file.testFilePath, options.workspace),
|
|
4833
|
-
title: test.fullName
|
|
4834
|
-
});
|
|
4835
|
-
}
|
|
4894
|
+
failures.push(...collectFileFailures(file, options));
|
|
4836
4895
|
}
|
|
4837
4896
|
if (failures.length > 0) {
|
|
4838
4897
|
lines.push("### Failures\n");
|
|
@@ -4857,7 +4916,7 @@ function resolveGitHubActionsOptions(userOptions, sourceMapper, environment = pr
|
|
|
4857
4916
|
function makeRelative(filePath, workspace) {
|
|
4858
4917
|
if (workspace === void 0) return filePath;
|
|
4859
4918
|
const normalized = normalizeWindowsPath(filePath);
|
|
4860
|
-
const normalizedWorkspace = normalizeWindowsPath(workspace).replace(
|
|
4919
|
+
const normalizedWorkspace = normalizeWindowsPath(workspace).replace(TRAILING_SLASH$4, "");
|
|
4861
4920
|
if (normalized.startsWith(`${normalizedWorkspace}/`)) return normalized.slice(normalizedWorkspace.length + 1);
|
|
4862
4921
|
return filePath;
|
|
4863
4922
|
}
|
|
@@ -4895,6 +4954,17 @@ function collectTestFailureAnnotations(annotations, file, options) {
|
|
|
4895
4954
|
});
|
|
4896
4955
|
}
|
|
4897
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
|
+
}
|
|
4898
4968
|
function noun(count, singular, plural) {
|
|
4899
4969
|
return count === 1 ? singular : plural;
|
|
4900
4970
|
}
|
|
@@ -4961,11 +5031,12 @@ async function outputSingleResult(config, result) {
|
|
|
4961
5031
|
coverageEnabled: config.collectCoverage,
|
|
4962
5032
|
printResults: () => {
|
|
4963
5033
|
if (config.silent) return;
|
|
5034
|
+
const timing = runtimeResult !== void 0 ? addCoverageTiming(runtimeResult.timing, preCoverageMs) : void 0;
|
|
4964
5035
|
printFormattedOutput({
|
|
4965
5036
|
config,
|
|
4966
5037
|
mergedResult,
|
|
4967
5038
|
runtimeResult,
|
|
4968
|
-
timing
|
|
5039
|
+
timing,
|
|
4969
5040
|
typecheckResult
|
|
4970
5041
|
});
|
|
4971
5042
|
},
|
|
@@ -5488,6 +5559,7 @@ function redirectPathToShadow(target, coverageRoots) {
|
|
|
5488
5559
|
}
|
|
5489
5560
|
//#endregion
|
|
5490
5561
|
//#region src/staging/synthesizer.ts
|
|
5562
|
+
const TRAILING_SLASH$3 = /\/$/;
|
|
5491
5563
|
const STUB_INJECTION_KEY = "jest.config";
|
|
5492
5564
|
const COLLIDING_SOURCE_FILES = ["jest.config.lua", "jest.config.luau"];
|
|
5493
5565
|
const SERVICE_CLASSES = /* @__PURE__ */ new Set([
|
|
@@ -5565,7 +5637,7 @@ function resolveDollarPath(value, treeBase, coverageBase, coverageRoots) {
|
|
|
5565
5637
|
if (coverageRoots === void 0) return absoluteTarget;
|
|
5566
5638
|
return redirectPathToShadow(absoluteTarget, coverageRoots.map((root) => {
|
|
5567
5639
|
return {
|
|
5568
|
-
luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(
|
|
5640
|
+
luauRoot: normalizeWindowsPath(path$1.resolve(coverageBase, root.luauRoot)).replace(TRAILING_SLASH$3, ""),
|
|
5569
5641
|
shadowDir: normalizeWindowsPath(root.shadowDir)
|
|
5570
5642
|
};
|
|
5571
5643
|
})) ?? absoluteTarget;
|
|
@@ -6124,7 +6196,8 @@ function collectCoverage(root) {
|
|
|
6124
6196
|
return true;
|
|
6125
6197
|
},
|
|
6126
6198
|
visitStatBlock(block) {
|
|
6127
|
-
for (const stmt of block.statements)
|
|
6199
|
+
for (const stmt of block.statements) {
|
|
6200
|
+
if (!INSTRUMENTABLE_STATEMENT_TAGS.has(stmt.tag)) continue;
|
|
6128
6201
|
statements.push({
|
|
6129
6202
|
index: statementIndex,
|
|
6130
6203
|
location: { ...stmt.location }
|
|
@@ -6168,16 +6241,14 @@ function collectCoverage(root) {
|
|
|
6168
6241
|
location: { ...elseif.thenBlock.location }
|
|
6169
6242
|
});
|
|
6170
6243
|
}
|
|
6171
|
-
|
|
6172
|
-
if (hasExplicitElse) {
|
|
6244
|
+
if (elseBlock !== void 0 && elseBlock.statements.length > 0) {
|
|
6173
6245
|
const elseFirst = getBodyFirstStatement(elseBlock);
|
|
6174
6246
|
branch.arms.push({
|
|
6175
6247
|
bodyFirstColumn: elseFirst.column,
|
|
6176
6248
|
bodyFirstLine: elseFirst.line,
|
|
6177
6249
|
location: { ...elseBlock.location }
|
|
6178
6250
|
});
|
|
6179
|
-
}
|
|
6180
|
-
if (!hasExplicitElse) {
|
|
6251
|
+
} else {
|
|
6181
6252
|
branch.arms.push({
|
|
6182
6253
|
bodyFirstColumn: 0,
|
|
6183
6254
|
bodyFirstLine: 0,
|
|
@@ -6300,6 +6371,9 @@ const KIND_RANK = {
|
|
|
6300
6371
|
open: 0,
|
|
6301
6372
|
point: 1
|
|
6302
6373
|
};
|
|
6374
|
+
const TRAILING_WHITESPACE = /\s$/;
|
|
6375
|
+
const IDENTIFIER_START = /^[a-zA-Z_]/;
|
|
6376
|
+
const MODE_DIRECTIVE = /^--![a-z]+/;
|
|
6303
6377
|
function insertProbes(source, result, fileKey) {
|
|
6304
6378
|
const lines = splitLines(source);
|
|
6305
6379
|
applyProbes(lines, collectProbes(result));
|
|
@@ -6370,11 +6444,11 @@ function applyProbes(mutableLines, probes) {
|
|
|
6370
6444
|
assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
|
|
6371
6445
|
const before = line.slice(0, column - 1);
|
|
6372
6446
|
const after = line.slice(column - 1);
|
|
6373
|
-
mutableLines[lineIndex] = before + (before.length > 0 &&
|
|
6447
|
+
mutableLines[lineIndex] = before + (before.length > 0 && !TRAILING_WHITESPACE.test(before) && IDENTIFIER_START.test(text) ? " " : "") + text + after;
|
|
6374
6448
|
}
|
|
6375
6449
|
}
|
|
6376
6450
|
function extractModeDirective(lines) {
|
|
6377
|
-
if (lines.length > 0 && lines[0] !== void 0 &&
|
|
6451
|
+
if (lines.length > 0 && lines[0] !== void 0 && MODE_DIRECTIVE.test(lines[0])) {
|
|
6378
6452
|
const directive = `${lines[0]}\n`;
|
|
6379
6453
|
lines.splice(0, 1);
|
|
6380
6454
|
return directive;
|
|
@@ -6426,6 +6500,7 @@ function buildPreamble(modeDirective, fileKey, result) {
|
|
|
6426
6500
|
}
|
|
6427
6501
|
//#endregion
|
|
6428
6502
|
//#region src/coverage-pipeline/instrumenter.ts
|
|
6503
|
+
const LUAU_EXTENSION = /\.luau?$/;
|
|
6429
6504
|
let cachedTemporaryDirectory$1;
|
|
6430
6505
|
/**
|
|
6431
6506
|
* Instrument a single luauRoot directory. Returns the files map without
|
|
@@ -6470,7 +6545,7 @@ function instrumentRoot(options) {
|
|
|
6470
6545
|
} catch (err) {
|
|
6471
6546
|
throw new Error("Failed to parse file list from lute", { cause: err });
|
|
6472
6547
|
}
|
|
6473
|
-
if (!
|
|
6548
|
+
if (!isStringArray(parsed)) throw new Error("Expected file list array from lute");
|
|
6474
6549
|
const fileList = parsed;
|
|
6475
6550
|
const files = {};
|
|
6476
6551
|
const posixLuauRoot = normalizeWindowsPath(luauRoot);
|
|
@@ -6487,7 +6562,7 @@ function instrumentRoot(options) {
|
|
|
6487
6562
|
const fileKey = normalizeWindowsPath(path$1.join(posixLuauRoot, relativePath));
|
|
6488
6563
|
const originalLuauPath = fileKey;
|
|
6489
6564
|
const instrumentedLuauPath = normalizeWindowsPath(path$1.join(shadowDir, relativePath));
|
|
6490
|
-
const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(
|
|
6565
|
+
const coverageMapOutputPath = path$1.join(shadowDir, relativePath.replace(LUAU_EXTENSION, ".cov-map.json"));
|
|
6491
6566
|
const sourceMapPath = `${originalLuauPath}.map`;
|
|
6492
6567
|
const outputDirectory = path$1.dirname(path$1.join(shadowDir, relativePath));
|
|
6493
6568
|
fs$1.mkdirSync(outputDirectory, { recursive: true });
|
|
@@ -6514,6 +6589,9 @@ function instrumentRoot(options) {
|
|
|
6514
6589
|
}
|
|
6515
6590
|
return files;
|
|
6516
6591
|
}
|
|
6592
|
+
function isStringArray(value) {
|
|
6593
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
6594
|
+
}
|
|
6517
6595
|
function shouldSkipFile(relativePath, skipFiles) {
|
|
6518
6596
|
if (relativePath.endsWith(".snap.luau") || relativePath.endsWith(".snap.lua")) return true;
|
|
6519
6597
|
return skipFiles?.has(relativePath) === true;
|
|
@@ -6587,7 +6665,8 @@ function walkDirectory$1(directory, visitedDirectories, files) {
|
|
|
6587
6665
|
const real = toKey(fs$1.realpathSync(directory));
|
|
6588
6666
|
if (visitedDirectories.has(real)) return;
|
|
6589
6667
|
visitedDirectories.add(real);
|
|
6590
|
-
|
|
6668
|
+
const entries = fs$1.readdirSync(directory, { withFileTypes: true });
|
|
6669
|
+
for (const entry of entries) {
|
|
6591
6670
|
if (entry.name.startsWith(".")) continue;
|
|
6592
6671
|
collectInputFiles(path$1.join(directory, entry.name), visitedDirectories, files);
|
|
6593
6672
|
}
|
|
@@ -7256,18 +7335,18 @@ const DEFAULT_BACKOFF_MS = 5e3;
|
|
|
7256
7335
|
*/
|
|
7257
7336
|
async function runTaskPool(options) {
|
|
7258
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");
|
|
7259
7340
|
const now = options.now ?? Date.now;
|
|
7260
7341
|
const sleep = options.sleep ?? setTimeout$1;
|
|
7261
|
-
const
|
|
7342
|
+
const allocations = distributeSlots(places, concurrency, options.warn ?? ((message) => {
|
|
7262
7343
|
console.warn(message);
|
|
7263
|
-
});
|
|
7264
|
-
if (concurrency < 1) throw new Error(`runTaskPool concurrency must be >= 1, got ${String(concurrency)}`);
|
|
7265
|
-
if (places.length === 0) throw new Error("runTaskPool requires at least one place");
|
|
7266
|
-
const allocations = distributeSlots(places, concurrency, warn);
|
|
7344
|
+
}));
|
|
7267
7345
|
async function backoff(state, retryAfterMs) {
|
|
7268
7346
|
const genuineMs = retryAfterMs !== void 0 && retryAfterMs > 0 ? retryAfterMs : DEFAULT_BACKOFF_MS;
|
|
7269
7347
|
const sinceCompletion = now() - state.lastCompletionMs;
|
|
7270
|
-
|
|
7348
|
+
const waitMs = sinceCompletion < RECYCLE_LAG_MS ? RECYCLE_LAG_MS - sinceCompletion : genuineMs;
|
|
7349
|
+
await sleep(waitMs);
|
|
7271
7350
|
}
|
|
7272
7351
|
async function worker(state) {
|
|
7273
7352
|
while (!isDone()) {
|
|
@@ -7426,6 +7505,7 @@ function isShardedParallel(parallel) {
|
|
|
7426
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";
|
|
7427
7506
|
//#endregion
|
|
7428
7507
|
//#region src/test-script.ts
|
|
7508
|
+
const TS_OR_LUAU_EXTENSION$2 = /\.(tsx?|luau?)$/;
|
|
7429
7509
|
function buildJestArgv(options) {
|
|
7430
7510
|
const argv = {};
|
|
7431
7511
|
for (const [key, value] of Object.entries(options.config)) if (!JEST_ARGV_EXCLUDED_KEYS.has(key) && value !== void 0) argv[key] = value;
|
|
@@ -7436,7 +7516,7 @@ function buildJestArgv(options) {
|
|
|
7436
7516
|
return {
|
|
7437
7517
|
...argv,
|
|
7438
7518
|
reporters: argv["reporters"] ?? [],
|
|
7439
|
-
testMatch: options.config.testMatch.map((pattern) => pattern.replace(
|
|
7519
|
+
testMatch: options.config.testMatch.map((pattern) => pattern.replace(TS_OR_LUAU_EXTENSION$2, ""))
|
|
7440
7520
|
};
|
|
7441
7521
|
}
|
|
7442
7522
|
function generateTestScript(options) {
|
|
@@ -7474,17 +7554,30 @@ function readStringProperty(err, key) {
|
|
|
7474
7554
|
}
|
|
7475
7555
|
//#endregion
|
|
7476
7556
|
//#region src/backends/open-cloud.ts
|
|
7557
|
+
/**
|
|
7558
|
+
* The value the version guard returns when the booted server is not running
|
|
7559
|
+
* the version this run uploaded — i.e. a concurrent upload won the boot race.
|
|
7560
|
+
* The backend retries that task once, pinned to the uploaded version.
|
|
7561
|
+
*
|
|
7562
|
+
* Embedded verbatim in a Luau double-quoted string literal, so it must not
|
|
7563
|
+
* contain backslashes, double quotes, or newlines.
|
|
7564
|
+
*/
|
|
7565
|
+
const PLACE_VERSION_RACE_SENTINEL = "__JEST_ROBLOX_PLACE_VERSION_RACE__";
|
|
7477
7566
|
const PARALLEL_AUTO_CAP = 3;
|
|
7478
7567
|
const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
|
|
7479
7568
|
const MAX_RETRIES_ENV = "JEST_ROBLOX_OCALE_MAX_RETRIES";
|
|
7480
7569
|
const DEFAULT_STREAM_POLL_MS = 250;
|
|
7570
|
+
const TrailingSlashesPattern = /\/+$/;
|
|
7481
7571
|
var OpenCloudBackend = class {
|
|
7482
7572
|
runner;
|
|
7573
|
+
/** One-shot per run so parallel raced tasks don't repeat the warning. */
|
|
7574
|
+
raceWarned = false;
|
|
7483
7575
|
kind = "open-cloud";
|
|
7484
7576
|
constructor(credentials, options) {
|
|
7485
7577
|
this.runner = options?.runner ?? new OcaleRunner(credentials, resolveRunnerOptions());
|
|
7486
7578
|
}
|
|
7487
7579
|
async runTests(options) {
|
|
7580
|
+
this.raceWarned = false;
|
|
7488
7581
|
const { jobs, parallel, scriptOverride, streaming, workStealing } = options;
|
|
7489
7582
|
if (jobs.length === 0) throw new Error("OpenCloudBackend requires at least one job");
|
|
7490
7583
|
if (workStealing === true && scriptOverride === void 0) throw new Error("OpenCloudBackend work-stealing mode requires scriptOverride");
|
|
@@ -7507,6 +7600,37 @@ var OpenCloudBackend = class {
|
|
|
7507
7600
|
}
|
|
7508
7601
|
};
|
|
7509
7602
|
}
|
|
7603
|
+
/**
|
|
7604
|
+
* Optimistic version pinning. Pinned tasks
|
|
7605
|
+
* (`/versions/{v}/luau-execution-session-tasks`) miss the warm-server pool
|
|
7606
|
+
* whenever no server holds the freshly-uploaded version yet, costing a cold
|
|
7607
|
+
* place boot per task (~10-45s, scaling with place size). Unpinned tasks
|
|
7608
|
+
* boot the latest saved version from the warm pool, so the first attempt
|
|
7609
|
+
* runs unpinned with a guard prepended: if the booted server is not on this
|
|
7610
|
+
* run's version (a concurrent upload won the boot race), the task returns
|
|
7611
|
+
* {@link PLACE_VERSION_RACE_SENTINEL} instead of running. On the sentinel,
|
|
7612
|
+
* the task is retried once, pinned — correct by construction, no re-upload
|
|
7613
|
+
* (the version exists even when it is no longer head), and no unpinned
|
|
7614
|
+
* retry loop for a concurrent uploader to keep winning against.
|
|
7615
|
+
*/
|
|
7616
|
+
async executeGuarded(options) {
|
|
7617
|
+
const { placeVersion, script, timeout } = options;
|
|
7618
|
+
const guarded = injectVersionGuard(script, placeVersion);
|
|
7619
|
+
const first = await this.runner.executeScript({
|
|
7620
|
+
script: guarded,
|
|
7621
|
+
timeout
|
|
7622
|
+
});
|
|
7623
|
+
if (first.outputs[0] !== "__JEST_ROBLOX_PLACE_VERSION_RACE__") return first;
|
|
7624
|
+
if (!this.raceWarned) {
|
|
7625
|
+
this.raceWarned = true;
|
|
7626
|
+
process.stderr.write("Warning: place version raced by a concurrent upload — raced tasks retried pinned (slower, cold place boot).\n");
|
|
7627
|
+
}
|
|
7628
|
+
return this.runner.executeScript({
|
|
7629
|
+
placeVersion,
|
|
7630
|
+
script,
|
|
7631
|
+
timeout
|
|
7632
|
+
});
|
|
7633
|
+
}
|
|
7510
7634
|
async runBucket(bucket, placeVersion, scriptOverride) {
|
|
7511
7635
|
const { indices, jobs } = bucket;
|
|
7512
7636
|
const primary = jobs[0];
|
|
@@ -7517,7 +7641,7 @@ var OpenCloudBackend = class {
|
|
|
7517
7641
|
};
|
|
7518
7642
|
});
|
|
7519
7643
|
const script = scriptOverride ?? generateTestScript(inputs);
|
|
7520
|
-
const scriptResult = await this.
|
|
7644
|
+
const scriptResult = await this.executeGuarded({
|
|
7521
7645
|
placeVersion,
|
|
7522
7646
|
script,
|
|
7523
7647
|
timeout: primary.config.timeout
|
|
@@ -7562,7 +7686,7 @@ var OpenCloudBackend = class {
|
|
|
7562
7686
|
},
|
|
7563
7687
|
places: [{ runTask: async () => {
|
|
7564
7688
|
launched += 1;
|
|
7565
|
-
return this.
|
|
7689
|
+
return this.executeGuarded({
|
|
7566
7690
|
placeVersion,
|
|
7567
7691
|
script: scriptOverride,
|
|
7568
7692
|
timeout: primaryConfig.timeout
|
|
@@ -7611,7 +7735,7 @@ async function pollStreamingResults(hooks, isDone) {
|
|
|
7611
7735
|
function resolveOpenCloudBaseUrl() {
|
|
7612
7736
|
const override = process.env[BASE_URL_ENV]?.trim();
|
|
7613
7737
|
if (override === void 0 || override === "") return;
|
|
7614
|
-
return override.replace(
|
|
7738
|
+
return override.replace(TrailingSlashesPattern, "");
|
|
7615
7739
|
}
|
|
7616
7740
|
/**
|
|
7617
7741
|
* Reads {@link MAX_RETRIES_ENV} for an Open Cloud retry-budget override. Lets
|
|
@@ -7662,6 +7786,22 @@ async function sleep(ms) {
|
|
|
7662
7786
|
setTimeout(resolve, ms);
|
|
7663
7787
|
});
|
|
7664
7788
|
}
|
|
7789
|
+
/**
|
|
7790
|
+
* Insert the version guard after any leading `--!` directive lines — Luau
|
|
7791
|
+
* honors directives only in the leading comment block, so a plain line-1
|
|
7792
|
+
* prepend would silently disable a caller's `--!strict`/`--!native`/etc.
|
|
7793
|
+
*/
|
|
7794
|
+
function injectVersionGuard(script, placeVersion) {
|
|
7795
|
+
const guard = `if game.PlaceVersion ~= ${String(placeVersion)} then return "${PLACE_VERSION_RACE_SENTINEL}" end`;
|
|
7796
|
+
const lines = script.split("\n");
|
|
7797
|
+
let insertAt = 0;
|
|
7798
|
+
for (const line of lines) {
|
|
7799
|
+
if (!line.startsWith("--!")) break;
|
|
7800
|
+
insertAt += 1;
|
|
7801
|
+
}
|
|
7802
|
+
lines.splice(insertAt, 0, guard);
|
|
7803
|
+
return lines.join("\n");
|
|
7804
|
+
}
|
|
7665
7805
|
function resolveRunnerOptions() {
|
|
7666
7806
|
const baseUrl = resolveOpenCloudBaseUrl();
|
|
7667
7807
|
const maxRetries = resolveOcaleMaxRetries();
|
|
@@ -7706,15 +7846,19 @@ function parseStealingEnvelope(result) {
|
|
|
7706
7846
|
gameOutput: result.outputs[1]
|
|
7707
7847
|
};
|
|
7708
7848
|
}
|
|
7709
|
-
function
|
|
7710
|
-
const
|
|
7711
|
-
|
|
7849
|
+
function addEntriesToMap(entryByKey, entries, gameOutput) {
|
|
7850
|
+
for (const entry of entries) {
|
|
7851
|
+
if (entry.pkg === void 0) continue;
|
|
7712
7852
|
const key = entryLookupKey(entry.pkg, entry.project);
|
|
7713
7853
|
if (!entryByKey.has(key)) entryByKey.set(key, {
|
|
7714
7854
|
entry,
|
|
7715
7855
|
gameOutput
|
|
7716
7856
|
});
|
|
7717
7857
|
}
|
|
7858
|
+
}
|
|
7859
|
+
function aggregateEntriesByKey(taskEnvelopes) {
|
|
7860
|
+
const entryByKey = /* @__PURE__ */ new Map();
|
|
7861
|
+
for (const { entries, gameOutput } of taskEnvelopes) addEntriesToMap(entryByKey, entries, gameOutput);
|
|
7718
7862
|
return entryByKey;
|
|
7719
7863
|
}
|
|
7720
7864
|
//#endregion
|
|
@@ -8239,8 +8383,8 @@ var StudioBackend = class {
|
|
|
8239
8383
|
const requestMessage = buildRunTestsMessage(jobs, requestId);
|
|
8240
8384
|
const executionStart = Date.now();
|
|
8241
8385
|
const message = await this.waitForResult(wss, requestMessage, requestId, existingSocket);
|
|
8242
|
-
const executionMs = Date.now() - executionStart;
|
|
8243
8386
|
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.`);
|
|
8387
|
+
const executionMs = Date.now() - executionStart;
|
|
8244
8388
|
const entries = parseEnvelope(message.jestOutput);
|
|
8245
8389
|
if (entries.length !== jobs.length) throw new Error(`Studio backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
|
|
8246
8390
|
return {
|
|
@@ -8261,7 +8405,8 @@ var StudioBackend = class {
|
|
|
8261
8405
|
function attachSocket(ws) {
|
|
8262
8406
|
ws.send(String(JSON.stringify(requestMessage)));
|
|
8263
8407
|
ws.on("message", (data) => {
|
|
8264
|
-
const
|
|
8408
|
+
const raw = JSON.parse(data.toString());
|
|
8409
|
+
const message = pluginMessageSchema(raw);
|
|
8265
8410
|
if (message instanceof type.errors) {
|
|
8266
8411
|
clearTimeout(timer);
|
|
8267
8412
|
reject(/* @__PURE__ */ new Error(`Invalid plugin message: ${message.summary}`));
|
|
@@ -8323,6 +8468,7 @@ function buildRunTestsMessage(jobs, requestId) {
|
|
|
8323
8468
|
//#endregion
|
|
8324
8469
|
//#region src/backends/auto.ts
|
|
8325
8470
|
const ENV_PREFIX = "JEST_";
|
|
8471
|
+
const StudioBusyPattern = /previous call to start play session/i;
|
|
8326
8472
|
var StudioWithFallback = class {
|
|
8327
8473
|
credentials;
|
|
8328
8474
|
studio;
|
|
@@ -8347,7 +8493,7 @@ var StudioWithFallback = class {
|
|
|
8347
8493
|
}
|
|
8348
8494
|
};
|
|
8349
8495
|
function isStudioBusyError(error) {
|
|
8350
|
-
if (error instanceof LuauScriptError) return
|
|
8496
|
+
if (error instanceof LuauScriptError) return StudioBusyPattern.test(error.message);
|
|
8351
8497
|
return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
|
|
8352
8498
|
}
|
|
8353
8499
|
async function probeStudioPlugin(port, timeoutMs, createServer = (wsPort) => {
|
|
@@ -8472,7 +8618,7 @@ function walkDirectory(directoryPath, baseDirectory) {
|
|
|
8472
8618
|
*/
|
|
8473
8619
|
function applyExcludes(files, excludeGlobs) {
|
|
8474
8620
|
if (excludeGlobs === void 0 || excludeGlobs.length === 0) return files;
|
|
8475
|
-
return files.filter((file) =>
|
|
8621
|
+
return files.filter((file) => excludeGlobs.every((pattern) => !matchesGlobPattern(file, pattern)));
|
|
8476
8622
|
}
|
|
8477
8623
|
//#endregion
|
|
8478
8624
|
//#region src/config/derive-typecheck-include.ts
|
|
@@ -8485,16 +8631,19 @@ function applyExcludes(files, excludeGlobs) {
|
|
|
8485
8631
|
* `/\.(test-d|spec-d)\.ts$/`), so patterns ending in any other extension, lacking
|
|
8486
8632
|
* a trailing `.spec.`/`.test.` marker, or already carrying `-d` are dropped.
|
|
8487
8633
|
*/
|
|
8634
|
+
const SpecTsSuffixPattern = /\.spec\.ts$/;
|
|
8635
|
+
const TestTsSuffixPattern = /\.test\.ts$/;
|
|
8488
8636
|
function deriveTypecheckInclude(runtimeInclude) {
|
|
8489
8637
|
const derived = [];
|
|
8490
|
-
for (const pattern of runtimeInclude) if (
|
|
8491
|
-
else if (
|
|
8638
|
+
for (const pattern of runtimeInclude) if (SpecTsSuffixPattern.test(pattern)) derived.push(pattern.replace(SpecTsSuffixPattern, ".spec-d.ts"));
|
|
8639
|
+
else if (TestTsSuffixPattern.test(pattern)) derived.push(pattern.replace(TestTsSuffixPattern, ".test-d.ts"));
|
|
8492
8640
|
return derived;
|
|
8493
8641
|
}
|
|
8494
8642
|
//#endregion
|
|
8495
8643
|
//#region src/utils/extensions.ts
|
|
8644
|
+
const TS_OR_LUAU_EXTENSION$1 = /\.(tsx?|luau?)$/;
|
|
8496
8645
|
function stripTsExtension(pattern) {
|
|
8497
|
-
return pattern.replace(
|
|
8646
|
+
return pattern.replace(TS_OR_LUAU_EXTENSION$1, "");
|
|
8498
8647
|
}
|
|
8499
8648
|
//#endregion
|
|
8500
8649
|
//#region src/luau/eval-literals.ts
|
|
@@ -8603,6 +8752,8 @@ function getTemporaryDirectory() {
|
|
|
8603
8752
|
}
|
|
8604
8753
|
//#endregion
|
|
8605
8754
|
//#region src/config/projects.ts
|
|
8755
|
+
const TRAILING_SLASH$2 = /\/$/;
|
|
8756
|
+
const TS_OR_LUAU_EXTENSION = /\.(tsx?|luau?)$/;
|
|
8606
8757
|
function extractStaticRoot(pattern) {
|
|
8607
8758
|
const globChars = /* @__PURE__ */ new Set([
|
|
8608
8759
|
"*",
|
|
@@ -8630,7 +8781,7 @@ function extractStaticRoot(pattern) {
|
|
|
8630
8781
|
};
|
|
8631
8782
|
}
|
|
8632
8783
|
function mapFsRootToDataModel(outDirectory, rojoTree) {
|
|
8633
|
-
const normalized = outDirectory.replace(
|
|
8784
|
+
const normalized = outDirectory.replace(TRAILING_SLASH$2, "");
|
|
8634
8785
|
const result = findInTree(rojoTree, normalized, "");
|
|
8635
8786
|
if (result === void 0) {
|
|
8636
8787
|
const available = [];
|
|
@@ -8655,7 +8806,7 @@ function extractProjectRoots(include) {
|
|
|
8655
8806
|
}
|
|
8656
8807
|
patterns.push(qualified);
|
|
8657
8808
|
}
|
|
8658
|
-
return
|
|
8809
|
+
return Array.from(rootMap, ([root, testMatch]) => ({
|
|
8659
8810
|
root,
|
|
8660
8811
|
testMatch
|
|
8661
8812
|
}));
|
|
@@ -8692,7 +8843,8 @@ const PROJECT_ONLY_KEYS = /* @__PURE__ */ new Set([
|
|
|
8692
8843
|
function dedupeMounts(mounts) {
|
|
8693
8844
|
const seen = /* @__PURE__ */ new Set();
|
|
8694
8845
|
const result = [];
|
|
8695
|
-
for (const mount of mounts)
|
|
8846
|
+
for (const mount of mounts) {
|
|
8847
|
+
if (seen.has(mount.dataModelPath)) continue;
|
|
8696
8848
|
seen.add(mount.dataModelPath);
|
|
8697
8849
|
result.push(mount);
|
|
8698
8850
|
}
|
|
@@ -8848,7 +9000,7 @@ function deriveIncludeFromTestMatch(config, configDirectory, tsconfig) {
|
|
|
8848
9000
|
if (raw["include"] !== void 0) return;
|
|
8849
9001
|
if (!Array.isArray(raw["testMatch"])) return;
|
|
8850
9002
|
config.include = raw["testMatch"].flatMap((pattern) => {
|
|
8851
|
-
return (
|
|
9003
|
+
return (TS_OR_LUAU_EXTENSION.test(pattern) ? [pattern] : [`${pattern}.ts`, `${pattern}.tsx`]).map((extension) => path$1.posix.join(configDirectory, extension));
|
|
8852
9004
|
});
|
|
8853
9005
|
const { outDir, rootDir } = tsconfig;
|
|
8854
9006
|
if (raw["outDir"] === void 0 && rootDir !== void 0 && outDir !== void 0) {
|
|
@@ -8942,6 +9094,7 @@ function buildNoMatchMessage(files, roots) {
|
|
|
8942
9094
|
const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
|
|
8943
9095
|
const TEST_FILE_EXTENSION = /\.(tsx?|luau?)$/;
|
|
8944
9096
|
const TS_SOURCE_EXTENSION = /\.tsx?$/;
|
|
9097
|
+
const INDEX_STEM = /^index(\.|$)/;
|
|
8945
9098
|
/**
|
|
8946
9099
|
* Translate a list of explicit test files (typically from CLI positional args)
|
|
8947
9100
|
* into a `testPathPattern` regex that constrains Jest on the Luau side. Each
|
|
@@ -8999,7 +9152,7 @@ function narrowForLuauRun(config, runtimeFiles, filterActive) {
|
|
|
8999
9152
|
* rewritten (else a pure-Luau project's positional arg matches zero tests).
|
|
9000
9153
|
*/
|
|
9001
9154
|
function indexStemToInit(basename) {
|
|
9002
|
-
return basename.replace(
|
|
9155
|
+
return basename.replace(INDEX_STEM, "init$1");
|
|
9003
9156
|
}
|
|
9004
9157
|
function toBasenamePattern(file) {
|
|
9005
9158
|
const posix = file.replaceAll("\\", "/");
|
|
@@ -9023,8 +9176,8 @@ function isGeneratedStub(filePath) {
|
|
|
9023
9176
|
try {
|
|
9024
9177
|
const fd = fs.openSync(filePath, "r");
|
|
9025
9178
|
try {
|
|
9026
|
-
const
|
|
9027
|
-
return fs.readSync(fd,
|
|
9179
|
+
const buffer = Buffer.alloc(47);
|
|
9180
|
+
return fs.readSync(fd, buffer, 0, 47, 0) === 47 && buffer.toString("utf8") === HEADER;
|
|
9028
9181
|
} finally {
|
|
9029
9182
|
fs.closeSync(fd);
|
|
9030
9183
|
}
|
|
@@ -9131,17 +9284,7 @@ function cleanLeftoverStubs(projects, rootDirectory) {
|
|
|
9131
9284
|
realRoot ??= fs.realpathSync(path.resolve(rootDirectory));
|
|
9132
9285
|
return realRoot;
|
|
9133
9286
|
}
|
|
9134
|
-
for (const project of projects)
|
|
9135
|
-
assertMountContained(project, mount.fsPath, rootDirectory);
|
|
9136
|
-
const stubPath = path.resolve(rootDirectory, mount.fsPath, STUB_FILENAME);
|
|
9137
|
-
if (!isGeneratedStub(stubPath)) continue;
|
|
9138
|
-
const realStubPath = fs.realpathSync(stubPath);
|
|
9139
|
-
const root = realRootResolved();
|
|
9140
|
-
const relativePath = path.relative(root, realStubPath);
|
|
9141
|
-
if (!(relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath))) throw new Error(`Project "${project.displayName}" mount fsPath resolves outside root via symlink: ${mount.fsPath} → ${realStubPath}`);
|
|
9142
|
-
fs.unlinkSync(stubPath);
|
|
9143
|
-
cleaned.push(stubPath);
|
|
9144
|
-
}
|
|
9287
|
+
for (const project of projects) cleaned.push(...cleanLeftoverStubsForProject(project, rootDirectory, realRootResolved));
|
|
9145
9288
|
return cleaned;
|
|
9146
9289
|
}
|
|
9147
9290
|
function generateProjectStubs(projects, rootDirectory, outputRoot) {
|
|
@@ -9155,15 +9298,7 @@ function generateProjectStubs(projects, rootDirectory, outputRoot) {
|
|
|
9155
9298
|
include: [],
|
|
9156
9299
|
testMatch: project.testMatch
|
|
9157
9300
|
};
|
|
9158
|
-
|
|
9159
|
-
assertMountContained(project, mount.fsPath, writeRoot);
|
|
9160
|
-
if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) continue;
|
|
9161
|
-
const outputPath = path.resolve(writeRoot, mount.fsPath, STUB_FILENAME);
|
|
9162
|
-
entries.push({
|
|
9163
|
-
config: stubConfig,
|
|
9164
|
-
outputPath
|
|
9165
|
-
});
|
|
9166
|
-
}
|
|
9301
|
+
entries.push(...buildStubEntriesForProject(project, stubConfig, rootDirectory, writeRoot));
|
|
9167
9302
|
}
|
|
9168
9303
|
generateProjectConfigs(entries);
|
|
9169
9304
|
}
|
|
@@ -9184,6 +9319,34 @@ function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
|
|
|
9184
9319
|
}
|
|
9185
9320
|
return changed;
|
|
9186
9321
|
}
|
|
9322
|
+
function cleanLeftoverStubsForProject(project, rootDirectory, realRootResolved) {
|
|
9323
|
+
const cleaned = [];
|
|
9324
|
+
for (const mount of project.rojoMounts) {
|
|
9325
|
+
assertMountContained(project, mount.fsPath, rootDirectory);
|
|
9326
|
+
const stubPath = path.resolve(rootDirectory, mount.fsPath, STUB_FILENAME);
|
|
9327
|
+
if (!isGeneratedStub(stubPath)) continue;
|
|
9328
|
+
const realStubPath = fs.realpathSync(stubPath);
|
|
9329
|
+
const root = realRootResolved();
|
|
9330
|
+
const relativePath = path.relative(root, realStubPath);
|
|
9331
|
+
if (!(relativePath === "" || !relativePath.startsWith("..") && !path.isAbsolute(relativePath))) throw new Error(`Project "${project.displayName}" mount fsPath resolves outside root via symlink: ${mount.fsPath} → ${realStubPath}`);
|
|
9332
|
+
fs.unlinkSync(stubPath);
|
|
9333
|
+
cleaned.push(stubPath);
|
|
9334
|
+
}
|
|
9335
|
+
return cleaned;
|
|
9336
|
+
}
|
|
9337
|
+
function buildStubEntriesForProject(project, stubConfig, rootDirectory, writeRoot) {
|
|
9338
|
+
const entries = [];
|
|
9339
|
+
for (const mount of project.rojoMounts) {
|
|
9340
|
+
assertMountContained(project, mount.fsPath, writeRoot);
|
|
9341
|
+
if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) continue;
|
|
9342
|
+
const outputPath = path.resolve(writeRoot, mount.fsPath, STUB_FILENAME);
|
|
9343
|
+
entries.push({
|
|
9344
|
+
config: stubConfig,
|
|
9345
|
+
outputPath
|
|
9346
|
+
});
|
|
9347
|
+
}
|
|
9348
|
+
return entries;
|
|
9349
|
+
}
|
|
9187
9350
|
function buildStubConfig(config) {
|
|
9188
9351
|
const result = {};
|
|
9189
9352
|
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;
|
|
@@ -9254,6 +9417,7 @@ function serializeLuauValue(value, indent) {
|
|
|
9254
9417
|
}
|
|
9255
9418
|
//#endregion
|
|
9256
9419
|
//#region src/coverage-pipeline/derive-coverage-from.ts
|
|
9420
|
+
const SPEC_OR_TEST_EXTENSION = /\.(?:spec|test)(\.\w+)$/;
|
|
9257
9421
|
/**
|
|
9258
9422
|
* Derives `collectCoverageFrom` glob patterns from project `include` patterns.
|
|
9259
9423
|
*
|
|
@@ -9286,7 +9450,7 @@ function deriveCoverageFromIncludes(projects) {
|
|
|
9286
9450
|
* extension so that misconfigured globs fail loudly.
|
|
9287
9451
|
*/
|
|
9288
9452
|
function inferSourceExtension(pattern) {
|
|
9289
|
-
const match = pattern.match(
|
|
9453
|
+
const match = pattern.match(SPEC_OR_TEST_EXTENSION);
|
|
9290
9454
|
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).`);
|
|
9291
9455
|
const [, extension] = match;
|
|
9292
9456
|
return extension;
|
|
@@ -9428,9 +9592,10 @@ function extractDefinition(node, source) {
|
|
|
9428
9592
|
//#endregion
|
|
9429
9593
|
//#region src/typecheck/parse.ts
|
|
9430
9594
|
const errorCodeRegExp = /error TS(?<errorCode>\d+)/;
|
|
9595
|
+
const LINE_SPLIT = /\r?\n/;
|
|
9431
9596
|
function parseTscOutput(stdout) {
|
|
9432
9597
|
const map = /* @__PURE__ */ new Map();
|
|
9433
|
-
const merged = stdout.split(
|
|
9598
|
+
const merged = stdout.split(LINE_SPLIT).reduce((lines, next) => {
|
|
9434
9599
|
if (!next) return lines;
|
|
9435
9600
|
if (next[0] !== " ") lines.push(next);
|
|
9436
9601
|
else if (lines.length > 0) lines[lines.length - 1] += `\n${next}`;
|
|
@@ -9561,7 +9726,7 @@ async function runTypecheck(options) {
|
|
|
9561
9726
|
function buildFileResult(filePath, fileInfo, errors) {
|
|
9562
9727
|
const indexMap = createLocationsIndexMap(fileInfo.source);
|
|
9563
9728
|
const testDefinitions = fileInfo.definitions.filter((definition) => definition.type === "test");
|
|
9564
|
-
const sortedDefinitions =
|
|
9729
|
+
const sortedDefinitions = testDefinitions.toSorted((a, b) => b.start - a.start);
|
|
9565
9730
|
const errorsByTest = /* @__PURE__ */ new Map();
|
|
9566
9731
|
const fileErrors = [];
|
|
9567
9732
|
for (const error of errors) {
|
|
@@ -9738,7 +9903,7 @@ function discoverTestFiles(config, cliFiles) {
|
|
|
9738
9903
|
}
|
|
9739
9904
|
const ignoredPatterns = config.testPathIgnorePatterns.map((pat) => new RegExp(pat));
|
|
9740
9905
|
const baseFiles = allFiles.filter((file) => {
|
|
9741
|
-
return
|
|
9906
|
+
return ignoredPatterns.every((pattern) => !pattern.test(file));
|
|
9742
9907
|
});
|
|
9743
9908
|
const totalFiles = new Set(baseFiles).size;
|
|
9744
9909
|
let filtered = baseFiles;
|
|
@@ -9854,13 +10019,7 @@ const VERSION$2 = version;
|
|
|
9854
10019
|
*/
|
|
9855
10020
|
function collectStubMounts(projects, rootDirectory, cacheRoot) {
|
|
9856
10021
|
const stubMounts = [];
|
|
9857
|
-
for (const project of projects)
|
|
9858
|
-
if (hasUserAuthoredConfig(path$1.resolve(rootDirectory, mount.fsPath))) continue;
|
|
9859
|
-
stubMounts.push({
|
|
9860
|
-
absStubPath: path$1.resolve(cacheRoot, mount.fsPath, STUB_FILENAME),
|
|
9861
|
-
dataModelPath: mount.dataModelPath
|
|
9862
|
-
});
|
|
9863
|
-
}
|
|
10022
|
+
for (const project of projects) stubMounts.push(...collectStubMountsForProject(project, rootDirectory, cacheRoot));
|
|
9864
10023
|
return stubMounts;
|
|
9865
10024
|
}
|
|
9866
10025
|
function loadRojoTree(config) {
|
|
@@ -9871,6 +10030,26 @@ function loadRojoTree(config) {
|
|
|
9871
10030
|
return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
|
|
9872
10031
|
}
|
|
9873
10032
|
/**
|
|
10033
|
+
* Instrument + rojo-build the coverage place and project it to a
|
|
10034
|
+
* `CoverageArtifacts`, optionally baking each project's `jest.config` stub into
|
|
10035
|
+
* the place. The single seam the run path (`prepareMultiProjectCoverage`) and
|
|
10036
|
+
* the offline build path (`buildCoveragePlace`) both drive, so the
|
|
10037
|
+
* prepare-and-bake mechanism can't drift between them. The caller owns the
|
|
10038
|
+
* `bakeStubs` decision (the run path skips baking for studio-cli, which injects
|
|
10039
|
+
* `jest.config` at runtime; the build path always bakes so a place opened by a
|
|
10040
|
+
* foreign runner is self-contained). Stubs must already be generated into
|
|
10041
|
+
* `cacheRoot`. Baking mirrors those cache stubs into the shadow tree — the
|
|
10042
|
+
* source tree is clean (stubs land in `cacheRoot`, not `rootDir`), so without it
|
|
10043
|
+
* the coverage place would build with no `jest.config` ModuleScripts.
|
|
10044
|
+
*/
|
|
10045
|
+
function prepareBakedCoverage(config, projects, cacheRoot, bakeStubs) {
|
|
10046
|
+
const coverage = prepareCoverage(config, bakeStubs ? (shadowDirectory) => syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory) : void 0);
|
|
10047
|
+
return {
|
|
10048
|
+
artifacts: toCoverageArtifacts(coverage, toBuildManifestProjects(projects)),
|
|
10049
|
+
coverage
|
|
10050
|
+
};
|
|
10051
|
+
}
|
|
10052
|
+
/**
|
|
9874
10053
|
* Multi-project execution core: stub generation, place build, discovery, and
|
|
9875
10054
|
* job dispatch over a set of already-resolved projects. Shared by the
|
|
9876
10055
|
* `projects:`-configured path (`runMultiProject`) and the no-`projects` collapse
|
|
@@ -9999,6 +10178,17 @@ async function runMultiProject(options) {
|
|
|
9999
10178
|
return resolveAllProjects(rawProjects, rootConfig, rojoTree, rootConfig.rootDir);
|
|
10000
10179
|
}), rootConfig, cli, timing);
|
|
10001
10180
|
}
|
|
10181
|
+
function collectStubMountsForProject(project, rootDirectory, cacheRoot) {
|
|
10182
|
+
const stubMounts = [];
|
|
10183
|
+
for (const mount of project.rojoMounts) {
|
|
10184
|
+
if (hasUserAuthoredConfig(path$1.resolve(rootDirectory, mount.fsPath))) continue;
|
|
10185
|
+
stubMounts.push({
|
|
10186
|
+
absStubPath: path$1.resolve(cacheRoot, mount.fsPath, STUB_FILENAME),
|
|
10187
|
+
dataModelPath: mount.dataModelPath
|
|
10188
|
+
});
|
|
10189
|
+
}
|
|
10190
|
+
return stubMounts;
|
|
10191
|
+
}
|
|
10002
10192
|
function buildMultiDisplayFilter(options) {
|
|
10003
10193
|
const { cliFiles, matchedRuntimeFiles, projectNames, projects, rootDir, testPathPattern } = options;
|
|
10004
10194
|
if (cliFiles !== void 0 && cliFiles.length > 0) return sourceTwinFilter(cliFiles, rootDir);
|
|
@@ -10173,11 +10363,9 @@ function prepareMultiProjectCoverage(rootConfig, projects, cacheRoot) {
|
|
|
10173
10363
|
};
|
|
10174
10364
|
const bakeStubs = rootConfig.backend !== "studio-cli";
|
|
10175
10365
|
const start = Date.now();
|
|
10176
|
-
const coverage =
|
|
10177
|
-
return syncStubsToShadowDirectory(projects, cacheRoot, shadowDirectory);
|
|
10178
|
-
} : void 0);
|
|
10366
|
+
const { artifacts, coverage } = prepareBakedCoverage(rootConfig, projects, cacheRoot, bakeStubs);
|
|
10179
10367
|
return {
|
|
10180
|
-
coverageArtifacts:
|
|
10368
|
+
coverageArtifacts: artifacts,
|
|
10181
10369
|
effectiveConfig: {
|
|
10182
10370
|
...rootConfig,
|
|
10183
10371
|
placeFile: coverage.placeFile
|
|
@@ -10224,6 +10412,7 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
|
|
|
10224
10412
|
}
|
|
10225
10413
|
//#endregion
|
|
10226
10414
|
//#region src/run/single-projects.ts
|
|
10415
|
+
const TRAILING_SLASH$1 = /\/$/;
|
|
10227
10416
|
/**
|
|
10228
10417
|
* Map each compiled-Luau root to its Rojo mount (FS path ↔ DataModel path) via
|
|
10229
10418
|
* the Rojo tree. Roots that don't map (a compiled-output dir the Rojo project
|
|
@@ -10232,7 +10421,7 @@ function selectProjects(allProjects, projectNames, cliFiles, rootDirectory) {
|
|
|
10232
10421
|
*/
|
|
10233
10422
|
function deriveProjectMounts(luauRoots, rojoTree) {
|
|
10234
10423
|
return dedupeMounts(luauRoots.flatMap((luauRoot) => {
|
|
10235
|
-
const fsPath = luauRoot.replace(
|
|
10424
|
+
const fsPath = luauRoot.replace(TRAILING_SLASH$1, "");
|
|
10236
10425
|
const dataModelPath = findInTree(rojoTree, fsPath, "");
|
|
10237
10426
|
return dataModelPath !== void 0 ? [{
|
|
10238
10427
|
dataModelPath,
|
|
@@ -10650,10 +10839,11 @@ function prepareWorkspaceCoverage(options) {
|
|
|
10650
10839
|
const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
|
|
10651
10840
|
const defaultMatcher = createIgnoreMatcher(DEFAULT_CONFIG.coveragePathIgnorePatterns);
|
|
10652
10841
|
return packages.map((descriptor) => {
|
|
10653
|
-
|
|
10842
|
+
const ignore = {
|
|
10654
10843
|
matcher: descriptor.coveragePathIgnorePatterns !== void 0 ? createIgnoreMatcher(descriptor.coveragePathIgnorePatterns) : defaultMatcher,
|
|
10655
10844
|
patterns: descriptor.coveragePathIgnorePatterns ?? DEFAULT_CONFIG.coveragePathIgnorePatterns
|
|
10656
|
-
}
|
|
10845
|
+
};
|
|
10846
|
+
return prepareForPackage(descriptor, workspaceRoot, ignore, timing);
|
|
10657
10847
|
});
|
|
10658
10848
|
}
|
|
10659
10849
|
/**
|
|
@@ -10840,10 +11030,11 @@ function prepareForPackage(descriptor, workspaceRoot, ignore, timing) {
|
|
|
10840
11030
|
shadowDir: shadowDirectory
|
|
10841
11031
|
});
|
|
10842
11032
|
}
|
|
11033
|
+
const generatedAtDate = /* @__PURE__ */ new Date();
|
|
10843
11034
|
const manifest = {
|
|
10844
11035
|
buildId: crypto.randomUUID(),
|
|
10845
11036
|
files: allFiles,
|
|
10846
|
-
generatedAt:
|
|
11037
|
+
generatedAt: generatedAtDate.toISOString(),
|
|
10847
11038
|
instrumenterVersion: 2,
|
|
10848
11039
|
luauRoots: coverageRoots.map((entry) => entry.shadowDir),
|
|
10849
11040
|
nonInstrumentedFiles: allNonInstrumented,
|
|
@@ -11204,7 +11395,7 @@ async function runWorkspaceProfiled(options, timing) {
|
|
|
11204
11395
|
const { emptyPackageErrors, filteredContexts, pending, typecheckByDirectory, typeTestEntries, typeTestProjects } = timing.profile("discoverTests", () => {
|
|
11205
11396
|
const discoveredContexts = applyProjectFilter(contexts, cli.project);
|
|
11206
11397
|
const discovered = collectPendingEntries(discoveredContexts, cli);
|
|
11207
|
-
const typeTestPackages = new Set(
|
|
11398
|
+
const typeTestPackages = new Set(Array.from(discovered.typecheckByDirectory.values(), (entry) => entry.pkg));
|
|
11208
11399
|
const policy = applyEmptyPackagePolicy(discovered.pending, discoveredContexts, typeTestPackages);
|
|
11209
11400
|
return {
|
|
11210
11401
|
emptyPackageErrors: policy.emptyPackageErrors,
|
|
@@ -11311,7 +11502,7 @@ async function runWorkspaceProfiled(options, timing) {
|
|
|
11311
11502
|
pending,
|
|
11312
11503
|
results,
|
|
11313
11504
|
runOptions,
|
|
11314
|
-
verbose:
|
|
11505
|
+
verbose: cli.verbose,
|
|
11315
11506
|
workspaceRoot
|
|
11316
11507
|
});
|
|
11317
11508
|
return attachTypecheck(attachCoverageManifests(results, pending, coverageByPackage), typecheckPass.outcome, timing);
|
|
@@ -11541,6 +11732,43 @@ function applyEmptyPackagePolicy(allEntries, contexts, typeTestPackages) {
|
|
|
11541
11732
|
pending
|
|
11542
11733
|
};
|
|
11543
11734
|
}
|
|
11735
|
+
function collectPackageProjectEntries(ctx, cliTypecheck, packageTypecheck, accumulators) {
|
|
11736
|
+
const { pending, typecheckByDirectory, typeTestEntries, typeTestProjects } = accumulators;
|
|
11737
|
+
const { packageDirectory } = ctx.info;
|
|
11738
|
+
for (const project of ctx.projects) {
|
|
11739
|
+
const typecheck = resolveTypecheckConfig({
|
|
11740
|
+
cli: cliTypecheck,
|
|
11741
|
+
project: project.typecheck,
|
|
11742
|
+
root: ctx.pkgConfig.typecheck
|
|
11743
|
+
});
|
|
11744
|
+
const projectConfig = buildProjectExecutionConfig(ctx.pkgConfig, project);
|
|
11745
|
+
const testFiles = typecheck.only ? [] : discoverProjectTestFiles(project, packageDirectory);
|
|
11746
|
+
pending.push({
|
|
11747
|
+
pkg: ctx.info.name,
|
|
11748
|
+
project,
|
|
11749
|
+
projectConfig,
|
|
11750
|
+
testFiles
|
|
11751
|
+
});
|
|
11752
|
+
if (!typecheck.enabled) continue;
|
|
11753
|
+
const typeTestFiles = discoverProjectTypeTests(project, typecheck, packageDirectory);
|
|
11754
|
+
if (typeTestFiles.length === 0) continue;
|
|
11755
|
+
typeTestProjects.push({
|
|
11756
|
+
pkg: ctx.info.name,
|
|
11757
|
+
project: project.displayName
|
|
11758
|
+
});
|
|
11759
|
+
typeTestEntries.push({
|
|
11760
|
+
cwd: packageDirectory,
|
|
11761
|
+
files: typeTestFiles,
|
|
11762
|
+
...typecheck.tsconfig !== void 0 ? { tsconfig: typecheck.tsconfig } : {}
|
|
11763
|
+
});
|
|
11764
|
+
typecheckByDirectory.set(packageDirectory, {
|
|
11765
|
+
ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
|
|
11766
|
+
pkg: ctx.info.name,
|
|
11767
|
+
spawnTimeout: packageTypecheck.spawnTimeout,
|
|
11768
|
+
timeout: ctx.pkgConfig.timeout
|
|
11769
|
+
});
|
|
11770
|
+
}
|
|
11771
|
+
}
|
|
11544
11772
|
function collectPendingEntries(contexts, cli) {
|
|
11545
11773
|
const cliTypecheck = {
|
|
11546
11774
|
enabled: cli.typecheck,
|
|
@@ -11551,46 +11779,15 @@ function collectPendingEntries(contexts, cli) {
|
|
|
11551
11779
|
const typeTestEntries = [];
|
|
11552
11780
|
const typeTestProjects = [];
|
|
11553
11781
|
const typecheckByDirectory = /* @__PURE__ */ new Map();
|
|
11554
|
-
for (const ctx of contexts) {
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
11558
|
-
|
|
11559
|
-
|
|
11560
|
-
|
|
11561
|
-
|
|
11562
|
-
|
|
11563
|
-
project: project.typecheck,
|
|
11564
|
-
root: ctx.pkgConfig.typecheck
|
|
11565
|
-
});
|
|
11566
|
-
const projectConfig = buildProjectExecutionConfig(ctx.pkgConfig, project);
|
|
11567
|
-
const testFiles = typecheck.only ? [] : discoverProjectTestFiles(project, packageDirectory);
|
|
11568
|
-
pending.push({
|
|
11569
|
-
pkg: ctx.info.name,
|
|
11570
|
-
project,
|
|
11571
|
-
projectConfig,
|
|
11572
|
-
testFiles
|
|
11573
|
-
});
|
|
11574
|
-
if (!typecheck.enabled) continue;
|
|
11575
|
-
const typeTestFiles = discoverProjectTypeTests(project, typecheck, packageDirectory);
|
|
11576
|
-
if (typeTestFiles.length === 0) continue;
|
|
11577
|
-
typeTestProjects.push({
|
|
11578
|
-
pkg: ctx.info.name,
|
|
11579
|
-
project: project.displayName
|
|
11580
|
-
});
|
|
11581
|
-
typeTestEntries.push({
|
|
11582
|
-
cwd: packageDirectory,
|
|
11583
|
-
files: typeTestFiles,
|
|
11584
|
-
...typecheck.tsconfig !== void 0 ? { tsconfig: typecheck.tsconfig } : {}
|
|
11585
|
-
});
|
|
11586
|
-
typecheckByDirectory.set(packageDirectory, {
|
|
11587
|
-
ignoreSourceErrors: packageTypecheck.ignoreSourceErrors,
|
|
11588
|
-
pkg: ctx.info.name,
|
|
11589
|
-
spawnTimeout: packageTypecheck.spawnTimeout,
|
|
11590
|
-
timeout: ctx.pkgConfig.timeout
|
|
11591
|
-
});
|
|
11592
|
-
}
|
|
11593
|
-
}
|
|
11782
|
+
for (const ctx of contexts) collectPackageProjectEntries(ctx, cliTypecheck, resolveTypecheckConfig({
|
|
11783
|
+
cli: cliTypecheck,
|
|
11784
|
+
root: ctx.pkgConfig.typecheck
|
|
11785
|
+
}), {
|
|
11786
|
+
pending,
|
|
11787
|
+
typecheckByDirectory,
|
|
11788
|
+
typeTestEntries,
|
|
11789
|
+
typeTestProjects
|
|
11790
|
+
});
|
|
11594
11791
|
return {
|
|
11595
11792
|
pending,
|
|
11596
11793
|
typecheckByDirectory,
|
|
@@ -11746,19 +11943,24 @@ function liveProjectsByPackage(pending) {
|
|
|
11746
11943
|
}
|
|
11747
11944
|
return live;
|
|
11748
11945
|
}
|
|
11946
|
+
function collectLiveProjectStubMounts(project, ctx) {
|
|
11947
|
+
const stubMounts = [];
|
|
11948
|
+
for (const mount of project.rojoMounts) {
|
|
11949
|
+
if (hasUserAuthoredConfig(path$1.resolve(ctx.info.packageDirectory, mount.fsPath))) continue;
|
|
11950
|
+
stubMounts.push({
|
|
11951
|
+
absStubPath: path$1.resolve(ctx.cacheRoot, mount.fsPath, STUB_FILENAME),
|
|
11952
|
+
dataModelPath: mount.dataModelPath
|
|
11953
|
+
});
|
|
11954
|
+
}
|
|
11955
|
+
return stubMounts;
|
|
11956
|
+
}
|
|
11749
11957
|
function writeStubsAndBuildDescriptors(contexts, liveProjects) {
|
|
11750
11958
|
return contexts.map((ctx) => {
|
|
11751
11959
|
const live = liveProjects.get(ctx.info.name) ?? /* @__PURE__ */ new Set();
|
|
11752
11960
|
const liveProjectsForPackage = ctx.projects.filter((project) => live.has(project.displayName));
|
|
11753
11961
|
generateProjectStubs(liveProjectsForPackage, ctx.info.packageDirectory, ctx.cacheRoot);
|
|
11754
11962
|
const stubMounts = [];
|
|
11755
|
-
for (const project of liveProjectsForPackage)
|
|
11756
|
-
if (hasUserAuthoredConfig(path$1.resolve(ctx.info.packageDirectory, mount.fsPath))) continue;
|
|
11757
|
-
stubMounts.push({
|
|
11758
|
-
absStubPath: path$1.resolve(ctx.cacheRoot, mount.fsPath, STUB_FILENAME),
|
|
11759
|
-
dataModelPath: mount.dataModelPath
|
|
11760
|
-
});
|
|
11761
|
-
}
|
|
11963
|
+
for (const project of liveProjectsForPackage) stubMounts.push(...collectLiveProjectStubMounts(project, ctx));
|
|
11762
11964
|
return {
|
|
11763
11965
|
...ctx.descriptor,
|
|
11764
11966
|
stubMounts
|
|
@@ -11782,6 +11984,7 @@ function discoverWorkspaceRoot(cwd) {
|
|
|
11782
11984
|
//#endregion
|
|
11783
11985
|
//#region src/workspace/package-resolver.ts
|
|
11784
11986
|
const JEST_CONFIG_MARKER$1 = /^jest\.config\.[^.]+$/;
|
|
11987
|
+
const TRAILING_SLASH = /\/$/;
|
|
11785
11988
|
function readPackageJsonName(packageJsonPath) {
|
|
11786
11989
|
if (!fs$1.existsSync(packageJsonPath)) return;
|
|
11787
11990
|
const raw = parsePackageJson(packageJsonPath);
|
|
@@ -11819,7 +12022,7 @@ function listPnpmPackages(workspaceRoot) {
|
|
|
11819
12022
|
const patterns = parseYAML(fs$1.readFileSync(yamlPath, "utf-8")).packages ?? [];
|
|
11820
12023
|
const packages = [];
|
|
11821
12024
|
for (const pattern of patterns) {
|
|
11822
|
-
const matches = globSync(`${pattern.replace(
|
|
12025
|
+
const matches = globSync(`${pattern.replace(TRAILING_SLASH, "")}/package.json`, { cwd: workspaceRoot });
|
|
11823
12026
|
for (const match of matches) {
|
|
11824
12027
|
const packageJsonPath = path$1.join(workspaceRoot, match);
|
|
11825
12028
|
const name = readPackageJsonName(packageJsonPath);
|
|
@@ -11831,9 +12034,6 @@ function listPnpmPackages(workspaceRoot) {
|
|
|
11831
12034
|
}
|
|
11832
12035
|
return packages;
|
|
11833
12036
|
}
|
|
11834
|
-
function inferPackageName(packageDirectory) {
|
|
11835
|
-
return readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? path$1.basename(packageDirectory);
|
|
11836
|
-
}
|
|
11837
12037
|
function assertNoDuplicateNames(packages, workspaceRoot) {
|
|
11838
12038
|
const byName = /* @__PURE__ */ new Map();
|
|
11839
12039
|
for (const package_ of packages) {
|
|
@@ -11843,26 +12043,29 @@ function assertNoDuplicateNames(packages, workspaceRoot) {
|
|
|
11843
12043
|
byName.set(package_.name, list);
|
|
11844
12044
|
}
|
|
11845
12045
|
for (const [name, paths] of byName) if (paths.length > 1) {
|
|
11846
|
-
const sorted =
|
|
12046
|
+
const sorted = paths.toSorted();
|
|
11847
12047
|
throw new Error(`Duplicate package name "${name}" from ${sorted.join(" and ")}. Add a package.json with a unique \`name\`, or rename a directory.`);
|
|
11848
12048
|
}
|
|
11849
12049
|
}
|
|
12050
|
+
function inferPackageName(packageDirectory) {
|
|
12051
|
+
return readPackageJsonName(path$1.join(packageDirectory, "package.json")) ?? path$1.basename(packageDirectory);
|
|
12052
|
+
}
|
|
12053
|
+
function collectPackagesFromMatches(matches, workspaceRoot, seenDirectories, packages) {
|
|
12054
|
+
for (const match of matches) {
|
|
12055
|
+
if (!JEST_CONFIG_MARKER$1.test(path$1.basename(match))) continue;
|
|
12056
|
+
const packageDirectory = path$1.dirname(path$1.join(workspaceRoot, match));
|
|
12057
|
+
if (seenDirectories.has(packageDirectory)) continue;
|
|
12058
|
+
seenDirectories.add(packageDirectory);
|
|
12059
|
+
packages.push({
|
|
12060
|
+
name: inferPackageName(packageDirectory),
|
|
12061
|
+
packageDirectory
|
|
12062
|
+
});
|
|
12063
|
+
}
|
|
12064
|
+
}
|
|
11850
12065
|
function enumerateFromGlobs(workspaceRoot, patterns) {
|
|
11851
12066
|
const seenDirectories = /* @__PURE__ */ new Set();
|
|
11852
12067
|
const packages = [];
|
|
11853
|
-
for (const pattern of patterns) {
|
|
11854
|
-
const matches = globSync(`${pattern.replace(/\/$/, "")}/jest.config.*`, { cwd: workspaceRoot });
|
|
11855
|
-
for (const match of matches) {
|
|
11856
|
-
if (!JEST_CONFIG_MARKER$1.test(path$1.basename(match))) continue;
|
|
11857
|
-
const packageDirectory = path$1.dirname(path$1.join(workspaceRoot, match));
|
|
11858
|
-
if (seenDirectories.has(packageDirectory)) continue;
|
|
11859
|
-
seenDirectories.add(packageDirectory);
|
|
11860
|
-
packages.push({
|
|
11861
|
-
name: inferPackageName(packageDirectory),
|
|
11862
|
-
packageDirectory
|
|
11863
|
-
});
|
|
11864
|
-
}
|
|
11865
|
-
}
|
|
12068
|
+
for (const pattern of patterns) collectPackagesFromMatches(globSync(`${pattern.replace(TRAILING_SLASH, "")}/jest.config.*`, { cwd: workspaceRoot }), workspaceRoot, seenDirectories, packages);
|
|
11866
12069
|
assertNoDuplicateNames(packages, workspaceRoot);
|
|
11867
12070
|
return packages;
|
|
11868
12071
|
}
|
|
@@ -11873,7 +12076,7 @@ function resolvePosixShim(binDirectory, command) {
|
|
|
11873
12076
|
const candidate = path$1.join(binDirectory, command);
|
|
11874
12077
|
return fs$1.existsSync(candidate) ? candidate : command;
|
|
11875
12078
|
}
|
|
11876
|
-
function
|
|
12079
|
+
function buildCommandExeArgs(command, args) {
|
|
11877
12080
|
return [
|
|
11878
12081
|
"/d",
|
|
11879
12082
|
"/s",
|
|
@@ -11935,7 +12138,7 @@ function runTool(command, args, cwd) {
|
|
|
11935
12138
|
PATH: `${binDirectory}${path$1.delimiter}${process.env["PATH"]}`
|
|
11936
12139
|
} : process.env;
|
|
11937
12140
|
const file = isWindows ? "cmd.exe" : resolvePosixShim(binDirectory, command);
|
|
11938
|
-
const spawnArgs = isWindows ?
|
|
12141
|
+
const spawnArgs = isWindows ? buildCommandExeArgs(command, args) : args;
|
|
11939
12142
|
try {
|
|
11940
12143
|
return cp.execFileSync(file, spawnArgs, {
|
|
11941
12144
|
cwd,
|
|
@@ -12329,4 +12532,4 @@ async function runJestRoblox(cli, config) {
|
|
|
12329
12532
|
}
|
|
12330
12533
|
}
|
|
12331
12534
|
//#endregion
|
|
12332
|
-
export {
|
|
12535
|
+
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 };
|