@isentinel/jest-roblox 0.1.1 → 0.1.3
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/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +278 -41
- package/dist/{executor-DqZE3wME.d.mts → executor-D6BzDfQ_.d.mts} +3 -0
- package/dist/{game-output-C0_-YIAY.mjs → game-output-BU-9pJ93.mjs} +73 -6
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/sea/jest-roblox +0 -0
- package/dist/sea-entry.cjs +362 -58
- package/package.json +2 -2
package/dist/cli.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-
|
|
1
|
+
import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-D6BzDfQ_.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/cli.d.ts
|
|
4
4
|
declare function parseArgs(args: Array<string>): CliOptions;
|
package/dist/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as hashBuffer, C as resolveNestedProjects, D as createStudioBackend, F as VALID_BACKENDS, P as ROOT_ONLY_KEYS, R as isValidBackend, S as collectPaths, _ as formatResult, a as formatAnnotations, b as formatBanner, c as execute, d as findFormatterOptions, g as formatMultiProjectResult, i as runTypecheck, k as createOpenCloudBackend, l as formatExecuteOutput, m as formatCompactMultiProject, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, w as loadConfig$1, x as rojoProjectSchema, y as formatTypecheckSummary, z as LuauScriptError } from "./game-output-BU-9pJ93.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { type } from "arktype";
|
|
4
4
|
import assert from "node:assert";
|
|
@@ -14,16 +14,16 @@ import { WebSocketServer } from "ws";
|
|
|
14
14
|
import { loadConfig } from "c12";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import { Buffer } from "node:buffer";
|
|
17
|
+
import * as cp from "node:child_process";
|
|
17
18
|
import { RojoResolver } from "@roblox-ts/rojo-resolver";
|
|
18
19
|
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
19
20
|
import { getTsconfig } from "get-tsconfig";
|
|
20
21
|
import picomatch from "picomatch";
|
|
21
|
-
import * as cp from "node:child_process";
|
|
22
22
|
import istanbulCoverage from "istanbul-lib-coverage";
|
|
23
23
|
import istanbulReport from "istanbul-lib-report";
|
|
24
24
|
import istanbulReports from "istanbul-reports";
|
|
25
25
|
//#region package.json
|
|
26
|
-
var version = "0.1.
|
|
26
|
+
var version = "0.1.3";
|
|
27
27
|
//#endregion
|
|
28
28
|
//#region src/backends/auto.ts
|
|
29
29
|
var StudioWithFallback = class {
|
|
@@ -115,10 +115,95 @@ function stripTsExtension(pattern) {
|
|
|
115
115
|
return pattern.replace(/\.(tsx?|luau?)$/, "");
|
|
116
116
|
}
|
|
117
117
|
//#endregion
|
|
118
|
-
//#region src/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
//#region src/luau/eval-literals.ts
|
|
119
|
+
/**
|
|
120
|
+
* Evaluate the first return expression in a Lute-stripped AST root block,
|
|
121
|
+
* supporting only literal values (string, boolean, number, nil, table, cast).
|
|
122
|
+
*
|
|
123
|
+
* Accepts `unknown` and narrows safely — no type casts on JSON.parse needed.
|
|
124
|
+
*/
|
|
125
|
+
function evalLuauReturnLiterals(root) {
|
|
126
|
+
if (!isObject(root) || !Array.isArray(root["statements"])) throw new Error("Config file has no return statement");
|
|
127
|
+
const returnStat = root["statements"].find((stat) => isObject(stat) && stat["tag"] === "return");
|
|
128
|
+
if (!isObject(returnStat) || !Array.isArray(returnStat["expressions"])) throw new Error("Config file has no return statement");
|
|
129
|
+
const first = returnStat["expressions"][0];
|
|
130
|
+
if (!isObject(first) || !("node" in first)) throw new Error("Return statement has no expressions");
|
|
131
|
+
return evalExpr(first["node"]);
|
|
132
|
+
}
|
|
133
|
+
function isObject(value) {
|
|
134
|
+
return typeof value === "object" && value !== null;
|
|
135
|
+
}
|
|
136
|
+
function evalExpr(node) {
|
|
137
|
+
if (!isObject(node)) return;
|
|
138
|
+
let current = node;
|
|
139
|
+
while (current["tag"] === "cast" && isObject(current["operand"])) current = current["operand"];
|
|
140
|
+
const { tag } = current;
|
|
141
|
+
if (tag === "boolean" || tag === "number") return current["value"];
|
|
142
|
+
if (tag === "string") return current["text"];
|
|
143
|
+
if (tag === "table" && Array.isArray(current["entries"])) return evalTable(current["entries"]);
|
|
144
|
+
}
|
|
145
|
+
function evalTable(entries) {
|
|
146
|
+
if (entries.length === 0) return {};
|
|
147
|
+
const first = entries[0];
|
|
148
|
+
if (isObject(first) && first["kind"] === "list") return entries.map((entry) => isObject(entry) ? evalExpr(entry["value"]) : void 0);
|
|
149
|
+
const result = {};
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (!isObject(entry) || entry["kind"] !== "record") continue;
|
|
152
|
+
const { key, value } = entry;
|
|
153
|
+
if (isObject(key) && typeof key["text"] === "string") result[key["text"]] = evalExpr(value);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/luau/parse-ast.luau
|
|
159
|
+
var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nif not luauRoot then\n error(\"Usage: lute run parse-ast.luau -- <file.luau | luau-root> [output-dir]\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsoperand\", \"rhsoperand\" },\n block = { \"statements\" },\n boolean = { \"value\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenblock\", \"elseifs\", \"elseblock\", \"thenexpr\", \"elseexpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n number = { \"value\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n string = { \"text\" },\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginline, no tag\n if value.beginline ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Single-file mode: parse one file, print stripped AST to stdout\nif string.sub(luauRoot, -5) == \".luau\" or string.sub(luauRoot, -4) == \".lua\" then\n local source = fs.readfiletostring(luauRoot)\n local parseResult = syntax.parse(source)\n print(json.serialize(strip(parseResult.root)))\n return\nend\n\nlocal outputDir = userArgs[2]\nif not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listdirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox-coverage\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readfiletostring(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createdirectory(dir, { makeparents = true })\n end\n\n fs.writestringtofile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.array))\n";
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/config/luau-config-loader.ts
|
|
162
|
+
let cachedTemporaryDirectory$1;
|
|
163
|
+
/**
|
|
164
|
+
* Parse a .luau config file via Lute and evaluate its return expression.
|
|
165
|
+
*/
|
|
166
|
+
function loadLuauConfig(filePath) {
|
|
167
|
+
const temporaryDirectory = getTemporaryDirectory$1();
|
|
168
|
+
const scriptPath = path$1.join(temporaryDirectory, "parse-ast.luau");
|
|
169
|
+
fs$1.writeFileSync(scriptPath, parse_ast_default);
|
|
170
|
+
let stdout;
|
|
171
|
+
try {
|
|
172
|
+
stdout = cp.execFileSync("lute", [
|
|
173
|
+
"run",
|
|
174
|
+
scriptPath,
|
|
175
|
+
"--",
|
|
176
|
+
path$1.resolve(filePath)
|
|
177
|
+
], {
|
|
178
|
+
encoding: "utf-8",
|
|
179
|
+
maxBuffer: 1024 * 1024
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error("lute is required to load .luau config files but was not found on PATH");
|
|
183
|
+
throw new Error(`Failed to evaluate Luau config ${filePath}`, { cause: err });
|
|
184
|
+
}
|
|
185
|
+
let ast;
|
|
186
|
+
try {
|
|
187
|
+
ast = JSON.parse(stdout);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new Error(`Failed to parse AST JSON from Luau config ${filePath}`, { cause: err });
|
|
190
|
+
}
|
|
191
|
+
const result = evalLuauReturnLiterals(ast);
|
|
192
|
+
if (typeof result !== "object" || result === null) throw new Error(`Luau config ${filePath} must return a table`);
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check if `<cwd>/<directoryOrFile>/jest.config.luau` exists. Returns the
|
|
197
|
+
* resolved path if found, undefined otherwise.
|
|
198
|
+
*/
|
|
199
|
+
function findLuauConfigFile(directoryOrFile, cwd) {
|
|
200
|
+
const resolved = path$1.resolve(cwd, directoryOrFile, "jest.config.luau");
|
|
201
|
+
if (fs$1.existsSync(resolved)) return resolved;
|
|
202
|
+
}
|
|
203
|
+
function getTemporaryDirectory$1() {
|
|
204
|
+
if (cachedTemporaryDirectory$1 !== void 0 && fs$1.existsSync(cachedTemporaryDirectory$1)) return cachedTemporaryDirectory$1;
|
|
205
|
+
cachedTemporaryDirectory$1 = fs$1.mkdtempSync(path$1.join(os.tmpdir(), "jest-roblox-luau-config-"));
|
|
206
|
+
return cachedTemporaryDirectory$1;
|
|
122
207
|
}
|
|
123
208
|
//#endregion
|
|
124
209
|
//#region src/config/projects.ts
|
|
@@ -220,6 +305,8 @@ function resolveProjectConfig(project, rootConfig, rojoTree) {
|
|
|
220
305
|
};
|
|
221
306
|
}
|
|
222
307
|
async function loadProjectConfigFile(filePath, cwd) {
|
|
308
|
+
const luauConfigPath = findLuauConfigFile(filePath, cwd);
|
|
309
|
+
if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
|
|
223
310
|
let result;
|
|
224
311
|
try {
|
|
225
312
|
result = await loadConfig({
|
|
@@ -261,6 +348,38 @@ function mergeProjectConfig(rootConfig, project) {
|
|
|
261
348
|
for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
|
|
262
349
|
return merged;
|
|
263
350
|
}
|
|
351
|
+
const LUAU_BOOLEAN_KEYS = [
|
|
352
|
+
"automock",
|
|
353
|
+
"clearMocks",
|
|
354
|
+
"injectGlobals",
|
|
355
|
+
"mockDataModel",
|
|
356
|
+
"resetMocks",
|
|
357
|
+
"resetModules",
|
|
358
|
+
"restoreMocks"
|
|
359
|
+
];
|
|
360
|
+
const LUAU_NUMBER_KEYS = ["slowTestThreshold", "testTimeout"];
|
|
361
|
+
const LUAU_STRING_KEYS = ["testEnvironment"];
|
|
362
|
+
const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
|
|
363
|
+
function copyLuauOptionalFields(raw, config) {
|
|
364
|
+
const record = config;
|
|
365
|
+
for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
|
|
366
|
+
for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
|
|
367
|
+
for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
|
|
368
|
+
for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
|
|
369
|
+
}
|
|
370
|
+
function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
|
|
371
|
+
const raw = loadLuauConfig(luauConfigPath);
|
|
372
|
+
const { displayName } = raw;
|
|
373
|
+
if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
|
|
374
|
+
const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
|
|
375
|
+
const config = {
|
|
376
|
+
displayName,
|
|
377
|
+
include: testMatch !== void 0 ? testMatch.map((pattern) => path$1.posix.join(directoryPath, `${pattern}.luau`)) : [path$1.posix.join(directoryPath, "**/*.spec.luau")]
|
|
378
|
+
};
|
|
379
|
+
if (testMatch !== void 0) config.testMatch = testMatch;
|
|
380
|
+
copyLuauOptionalFields(raw, config);
|
|
381
|
+
return config;
|
|
382
|
+
}
|
|
264
383
|
function matchNodePath(childNode, targetPath, childDataModelPath) {
|
|
265
384
|
const nodePath = childNode.$path;
|
|
266
385
|
if (typeof nodePath !== "string") return;
|
|
@@ -336,6 +455,8 @@ function serializeToLuau(config) {
|
|
|
336
455
|
}
|
|
337
456
|
function generateProjectConfigs(projects) {
|
|
338
457
|
for (const project of projects) {
|
|
458
|
+
const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
|
|
459
|
+
if (fs.existsSync(luauConfigPath)) continue;
|
|
339
460
|
const content = serializeToLuau(project.config);
|
|
340
461
|
const directory = path.dirname(project.outputPath);
|
|
341
462
|
fs.mkdirSync(directory, { recursive: true });
|
|
@@ -441,27 +562,133 @@ function mapCoverageToTypeScript(coverageData, manifest) {
|
|
|
441
562
|
if (record === void 0) continue;
|
|
442
563
|
const resources = loadFileResources(record);
|
|
443
564
|
if (resources === void 0) continue;
|
|
444
|
-
|
|
445
|
-
|
|
565
|
+
if (resources.traceMap === void 0) {
|
|
566
|
+
passthroughFileStatements(resources, fileCoverage, pendingStatements);
|
|
567
|
+
passthroughFileFunctions(resources, fileCoverage, pendingFunctions);
|
|
568
|
+
passthroughFileBranches(resources, fileCoverage, pendingBranches);
|
|
569
|
+
} else {
|
|
570
|
+
const mapped = {
|
|
571
|
+
coverageMap: resources.coverageMap,
|
|
572
|
+
traceMap: resources.traceMap
|
|
573
|
+
};
|
|
574
|
+
mapFileFunctions(mapped, fileCoverage, pendingFunctions, mapFileStatements(mapped, fileCoverage, pendingStatements));
|
|
575
|
+
mapFileBranches(mapped, fileCoverage, pendingBranches);
|
|
576
|
+
}
|
|
446
577
|
}
|
|
447
578
|
return buildResult(pendingStatements, pendingFunctions, pendingBranches);
|
|
448
579
|
}
|
|
449
580
|
function loadFileResources(record) {
|
|
450
581
|
let coverageMapRaw;
|
|
451
|
-
let sourceMapRaw;
|
|
452
582
|
try {
|
|
453
583
|
coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
|
|
454
|
-
sourceMapRaw = fs$1.readFileSync(record.sourceMapPath, "utf-8");
|
|
455
584
|
} catch {
|
|
456
585
|
return;
|
|
457
586
|
}
|
|
458
587
|
const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
|
|
459
588
|
if (parsed instanceof type.errors) return;
|
|
589
|
+
let traceMap;
|
|
590
|
+
try {
|
|
591
|
+
traceMap = new TraceMap(fs$1.readFileSync(record.sourceMapPath, "utf-8"));
|
|
592
|
+
} catch {}
|
|
460
593
|
return {
|
|
461
594
|
coverageMap: parsed,
|
|
462
|
-
|
|
595
|
+
sourceKey: record.key,
|
|
596
|
+
traceMap
|
|
463
597
|
};
|
|
464
598
|
}
|
|
599
|
+
function toIstanbulColumn(luauColumn) {
|
|
600
|
+
return Math.max(0, luauColumn - 1);
|
|
601
|
+
}
|
|
602
|
+
function passthroughFileStatements(resources, fileCoverage, pending) {
|
|
603
|
+
let fileStatements = pending.get(resources.sourceKey);
|
|
604
|
+
if (fileStatements === void 0) {
|
|
605
|
+
fileStatements = /* @__PURE__ */ new Map();
|
|
606
|
+
pending.set(resources.sourceKey, fileStatements);
|
|
607
|
+
}
|
|
608
|
+
for (const [statementId, rawSpan] of Object.entries(resources.coverageMap.statementMap)) {
|
|
609
|
+
const span = spanSchema(rawSpan);
|
|
610
|
+
if (span instanceof type.errors) continue;
|
|
611
|
+
const hitCount = fileCoverage.s[statementId] ?? 0;
|
|
612
|
+
fileStatements.set(statementId, {
|
|
613
|
+
end: {
|
|
614
|
+
column: toIstanbulColumn(span.end.column),
|
|
615
|
+
line: span.end.line
|
|
616
|
+
},
|
|
617
|
+
hitCount,
|
|
618
|
+
start: {
|
|
619
|
+
column: toIstanbulColumn(span.start.column),
|
|
620
|
+
line: span.start.line
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function passthroughFileFunctions(resources, fileCoverage, pendingFunctions) {
|
|
626
|
+
if (resources.coverageMap.functionMap === void 0) return;
|
|
627
|
+
let fileFunctions = pendingFunctions.get(resources.sourceKey);
|
|
628
|
+
if (fileFunctions === void 0) {
|
|
629
|
+
fileFunctions = [];
|
|
630
|
+
pendingFunctions.set(resources.sourceKey, fileFunctions);
|
|
631
|
+
}
|
|
632
|
+
for (const [functionId, rawEntry] of Object.entries(resources.coverageMap.functionMap)) {
|
|
633
|
+
const entry = functionEntrySchema(rawEntry);
|
|
634
|
+
if (entry instanceof type.errors) continue;
|
|
635
|
+
fileFunctions.push({
|
|
636
|
+
name: entry.name,
|
|
637
|
+
hitCount: fileCoverage.f?.[functionId] ?? 0,
|
|
638
|
+
loc: {
|
|
639
|
+
end: {
|
|
640
|
+
column: toIstanbulColumn(entry.location.end.column),
|
|
641
|
+
line: entry.location.end.line
|
|
642
|
+
},
|
|
643
|
+
start: {
|
|
644
|
+
column: toIstanbulColumn(entry.location.start.column),
|
|
645
|
+
line: entry.location.start.line
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
|
|
652
|
+
if (resources.coverageMap.branchMap === void 0) return;
|
|
653
|
+
let fileBranches = pendingBranches.get(resources.sourceKey);
|
|
654
|
+
if (fileBranches === void 0) {
|
|
655
|
+
fileBranches = [];
|
|
656
|
+
pendingBranches.set(resources.sourceKey, fileBranches);
|
|
657
|
+
}
|
|
658
|
+
for (const [branchId, rawEntry] of Object.entries(resources.coverageMap.branchMap)) {
|
|
659
|
+
const entry = branchEntrySchema(rawEntry);
|
|
660
|
+
if (entry instanceof type.errors) continue;
|
|
661
|
+
const armHitCounts = fileCoverage.b?.[branchId] ?? [];
|
|
662
|
+
const locations = [];
|
|
663
|
+
for (const rawLocation of entry.locations) {
|
|
664
|
+
const location = spanSchema(rawLocation);
|
|
665
|
+
if (location instanceof type.errors) continue;
|
|
666
|
+
locations.push({
|
|
667
|
+
end: {
|
|
668
|
+
column: toIstanbulColumn(location.end.column),
|
|
669
|
+
line: location.end.line
|
|
670
|
+
},
|
|
671
|
+
start: {
|
|
672
|
+
column: toIstanbulColumn(location.start.column),
|
|
673
|
+
line: location.start.line
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
if (locations.length === 0) continue;
|
|
678
|
+
const firstLocation = locations[0];
|
|
679
|
+
const lastLocation = locations[locations.length - 1];
|
|
680
|
+
assert(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
|
|
681
|
+
fileBranches.push({
|
|
682
|
+
armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
|
|
683
|
+
loc: {
|
|
684
|
+
end: lastLocation.end,
|
|
685
|
+
start: firstLocation.start
|
|
686
|
+
},
|
|
687
|
+
locations,
|
|
688
|
+
type: entry.type
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
465
692
|
function mapStatement(traceMap, span) {
|
|
466
693
|
const mappedStart = originalPositionFor(traceMap, {
|
|
467
694
|
column: Math.max(0, span.start.column - 1),
|
|
@@ -702,26 +929,7 @@ function buildResult(pending, pendingFunctions, pendingBranches) {
|
|
|
702
929
|
return { files };
|
|
703
930
|
}
|
|
704
931
|
//#endregion
|
|
705
|
-
//#region src/
|
|
706
|
-
const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
|
|
707
|
-
"assign",
|
|
708
|
-
"break",
|
|
709
|
-
"compoundassign",
|
|
710
|
-
"conditional",
|
|
711
|
-
"continue",
|
|
712
|
-
"do",
|
|
713
|
-
"expression",
|
|
714
|
-
"for",
|
|
715
|
-
"forin",
|
|
716
|
-
"function",
|
|
717
|
-
"local",
|
|
718
|
-
"localfunction",
|
|
719
|
-
"repeat",
|
|
720
|
-
"return",
|
|
721
|
-
"while"
|
|
722
|
-
]);
|
|
723
|
-
//#endregion
|
|
724
|
-
//#region src/coverage/luau-visitor.ts
|
|
932
|
+
//#region src/luau/visitor.ts
|
|
725
933
|
function visitExpression(expression, visitor) {
|
|
726
934
|
if (visitor.visitExpr?.(expression) === false) return;
|
|
727
935
|
const { tag } = expression;
|
|
@@ -992,6 +1200,23 @@ function visitExprInstantiate(node, visitor) {
|
|
|
992
1200
|
}
|
|
993
1201
|
//#endregion
|
|
994
1202
|
//#region src/coverage/coverage-collector.ts
|
|
1203
|
+
const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
|
|
1204
|
+
"assign",
|
|
1205
|
+
"break",
|
|
1206
|
+
"compoundassign",
|
|
1207
|
+
"conditional",
|
|
1208
|
+
"continue",
|
|
1209
|
+
"do",
|
|
1210
|
+
"expression",
|
|
1211
|
+
"for",
|
|
1212
|
+
"forin",
|
|
1213
|
+
"function",
|
|
1214
|
+
"local",
|
|
1215
|
+
"localfunction",
|
|
1216
|
+
"repeat",
|
|
1217
|
+
"return",
|
|
1218
|
+
"while"
|
|
1219
|
+
]);
|
|
995
1220
|
const END_KEYWORD_LENGTH = 3;
|
|
996
1221
|
function collectCoverage(root) {
|
|
997
1222
|
let statementIndex = 1;
|
|
@@ -1231,9 +1456,6 @@ function buildCoverageMap$1(result) {
|
|
|
1231
1456
|
};
|
|
1232
1457
|
}
|
|
1233
1458
|
//#endregion
|
|
1234
|
-
//#region src/coverage/parse-ast.luau
|
|
1235
|
-
var parse_ast_default = "local fs = require(\"@std/fs\")\nlocal json = require(\"@std/json\")\nlocal process = require(\"@std/process\")\nlocal syntax = require(\"@std/syntax\")\n\nlocal rawArgs = process.args\nlocal userArgs: { string } = {}\nlocal pastSeparator = false\n\nfor _, arg in rawArgs do\n if pastSeparator then\n table.insert(userArgs, arg)\n elseif arg == \"--\" then\n pastSeparator = true\n end\nend\n\nlocal luauRoot = userArgs[1]\nlocal outputDir = userArgs[2]\nif not luauRoot or not outputDir then\n error(\"Usage: lute run parse-ast.luau -- <luau-root> <output-dir>\")\nend\n\nluauRoot = string.gsub(luauRoot, \"\\\\\", \"/\")\noutputDir = string.gsub(outputDir, \"\\\\\", \"/\")\n\n-- Fields to keep per AST tag (beyond tag/kind/location which are always kept).\n-- Tags shared by stat/expr variants are merged — nil fields are harmless.\nlocal KEEP: { [string]: { string } } = {\n assign = { \"values\", \"variables\" },\n binary = { \"lhsoperand\", \"rhsoperand\" },\n block = { \"statements\" },\n call = { \"arguments\", \"func\" },\n cast = { \"operand\" },\n compoundassign = { \"value\", \"variable\" },\n conditional = { \"condition\", \"thenblock\", \"elseifs\", \"elseblock\", \"thenexpr\", \"elseexpr\" },\n [\"do\"] = { \"body\" },\n expression = { \"expression\" },\n [\"for\"] = { \"body\", \"from\", \"to\", \"step\" },\n forin = { \"body\", \"values\" },\n [\"function\"] = { \"body\", \"name\", \"func\" },\n global = { \"name\" },\n group = { \"expression\" },\n index = { \"expression\", \"index\" },\n indexname = { \"expression\", \"accessor\", \"index\" },\n instantiate = { \"expr\" },\n interpolatedstring = { \"expressions\" },\n [\"local\"] = { \"values\", \"variables\" },\n localfunction = { \"name\", \"func\" },\n [\"repeat\"] = { \"body\", \"condition\" },\n [\"return\"] = { \"expressions\" },\n table = { \"entries\" },\n unary = { \"operand\" },\n [\"while\"] = { \"body\", \"condition\" },\n}\n\nlocal function strip(value: any): any\n if type(value) ~= \"table\" then\n return value\n end\n\n -- LuauSpan — has beginline, no tag\n if value.beginline ~= nil then\n return value\n end\n\n -- Token — has text but no tag, reduce to {text}\n if value.text ~= nil and value.tag == nil then\n return { text = value.text }\n end\n\n -- AST node — has tag, keep only allowlisted fields\n if value.tag ~= nil then\n local result = { tag = value.tag, kind = value.kind, location = value.location }\n local fields = KEEP[value.tag :: string]\n if fields then\n for _, field in fields do\n if value[field] ~= nil then\n result[field] = strip(value[field])\n end\n end\n end\n\n return result\n end\n\n -- Other tables (arrays, Pairs, ElseIf structs) — recurse all fields\n local result: any = {}\n for k, v in value do\n result[k] = strip(v)\n end\n\n return result\nend\n\n-- Discover .luau files recursively, skipping node_modules, dot dirs, spec/test files\nlocal function discoverFiles(directory: string, relativeTo: string, results: { string })\n local entries = fs.listdirectory(directory)\n for _, entry in entries do\n local fullPath = directory .. \"/\" .. entry.name\n if entry.type == \"dir\" then\n if entry.name == \"node_modules\" or entry.name == \".jest-roblox-coverage\" then\n continue\n end\n\n if string.sub(entry.name, 1, 1) == \".\" then\n continue\n end\n\n discoverFiles(fullPath, relativeTo, results)\n elseif\n entry.type == \"file\"\n and (string.sub(entry.name, -5) == \".luau\" or string.sub(entry.name, -4) == \".lua\")\n then\n if\n string.sub(entry.name, -10) == \".spec.luau\"\n or string.sub(entry.name, -10) == \".test.luau\"\n or string.sub(entry.name, -9) == \".spec.lua\"\n or string.sub(entry.name, -9) == \".test.lua\"\n or string.sub(entry.name, -10) == \".snap.luau\"\n or string.sub(entry.name, -9) == \".snap.lua\"\n then\n continue\n end\n\n -- Compute relative path\n local relative = string.sub(fullPath, #relativeTo + 2)\n table.insert(results, relative)\n end\n end\nend\n\nlocal function dirname(filepath: string): string\n local pos = string.find(filepath, \"/[^/]*$\")\n if pos then\n return string.sub(filepath, 1, pos - 1)\n end\n\n return \"\"\nend\n\nlocal files: { string } = {}\ndiscoverFiles(luauRoot, luauRoot, files)\n\n-- Parse, strip, and write per-file AST JSON\nfor _, relativePath in files do\n local fullPath = luauRoot .. \"/\" .. relativePath\n local source = fs.readfiletostring(fullPath)\n local parseResult = syntax.parse(source)\n local stripped = strip(parseResult.root)\n\n local outPath = outputDir .. \"/\" .. relativePath .. \".json\"\n local dir = dirname(outPath)\n if dir ~= \"\" then\n fs.createdirectory(dir, { makeparents = true })\n end\n\n fs.writestringtofile(outPath, json.serialize(stripped))\nend\n\n-- Print file list to stdout (tiny — just paths)\nprint(json.serialize(files :: json.array))\n";
|
|
1236
|
-
//#endregion
|
|
1237
1459
|
//#region src/coverage/probe-inserter.ts
|
|
1238
1460
|
function insertProbes(source, result, fileKey) {
|
|
1239
1461
|
const lines = splitLines(source);
|
|
@@ -1288,7 +1510,7 @@ function applyProbes(mutableLines, probes) {
|
|
|
1288
1510
|
assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
|
|
1289
1511
|
const before = line.slice(0, column - 1);
|
|
1290
1512
|
const after = line.slice(column - 1);
|
|
1291
|
-
mutableLines[lineIndex] = before + text + after;
|
|
1513
|
+
mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
|
|
1292
1514
|
}
|
|
1293
1515
|
}
|
|
1294
1516
|
function extractModeDirective(lines) {
|
|
@@ -1653,8 +1875,12 @@ function writeManifest(manifestPath, allFiles, luauRoots, placeFile) {
|
|
|
1653
1875
|
function buildRojoProject(rojoProjectPath, roots, placeFile) {
|
|
1654
1876
|
const rojoProjectRaw = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
|
|
1655
1877
|
if (rojoProjectRaw instanceof type.errors) throw new Error(`Malformed Rojo project JSON: ${rojoProjectRaw.toString()}`);
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1878
|
+
const projectRelocation = path$1.relative(COVERAGE_DIR, path$1.dirname(rojoProjectPath)).replaceAll("\\", "/");
|
|
1879
|
+
const rewritten = rewriteRojoProject({
|
|
1880
|
+
...rojoProjectRaw,
|
|
1881
|
+
tree: resolveNestedProjects(rojoProjectRaw.tree, path$1.dirname(rojoProjectPath))
|
|
1882
|
+
}, {
|
|
1883
|
+
projectRelocation,
|
|
1658
1884
|
roots
|
|
1659
1885
|
});
|
|
1660
1886
|
const rewrittenProjectPath = path$1.join(COVERAGE_DIR, path$1.basename(rojoProjectPath));
|
|
@@ -1798,7 +2024,7 @@ function globSync(pattern, options = {}) {
|
|
|
1798
2024
|
return walkDirectory(cwd, cwd).filter((file) => matchesGlobPattern(file, pattern));
|
|
1799
2025
|
}
|
|
1800
2026
|
function matchesGlobPattern(filePath, pattern) {
|
|
1801
|
-
const regexPattern = pattern.replace(/\./g, "\\.").replace(
|
|
2027
|
+
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*\*\//g, "{{DOUBLESTAR_SLASH}}").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLESTAR_SLASH\}\}/g, "(.+/)?");
|
|
1802
2028
|
return new RegExp(`^${regexPattern}$`).test(filePath);
|
|
1803
2029
|
}
|
|
1804
2030
|
function walkDirectory(directoryPath, baseDirectory) {
|
|
@@ -1833,6 +2059,7 @@ Options:
|
|
|
1833
2059
|
--gameOutput <path> Write game output (print/warn/error) to file
|
|
1834
2060
|
--sourceMap Map Luau stack traces to TypeScript source
|
|
1835
2061
|
--rojoProject <path> Path to rojo project file (auto-detected if not set)
|
|
2062
|
+
--passWithNoTests Exit with 0 when no test files are found
|
|
1836
2063
|
--verbose Show individual test results
|
|
1837
2064
|
--silent Suppress output
|
|
1838
2065
|
--no-color Disable colored output
|
|
@@ -1894,6 +2121,7 @@ function parseArgs(args) {
|
|
|
1894
2121
|
"no-color": { type: "boolean" },
|
|
1895
2122
|
"no-show-luau": { type: "boolean" },
|
|
1896
2123
|
"outputFile": { type: "string" },
|
|
2124
|
+
"passWithNoTests": { type: "boolean" },
|
|
1897
2125
|
"pollInterval": { type: "string" },
|
|
1898
2126
|
"port": { type: "string" },
|
|
1899
2127
|
"project": {
|
|
@@ -1949,6 +2177,7 @@ function parseArgs(args) {
|
|
|
1949
2177
|
gameOutput: values.gameOutput,
|
|
1950
2178
|
help: values.help,
|
|
1951
2179
|
outputFile: values.outputFile,
|
|
2180
|
+
passWithNoTests: values.passWithNoTests,
|
|
1952
2181
|
pollInterval,
|
|
1953
2182
|
port,
|
|
1954
2183
|
project: values.project,
|
|
@@ -2095,7 +2324,11 @@ function printFinalStatus(passed) {
|
|
|
2095
2324
|
process.stdout.write(`${badge}\n`);
|
|
2096
2325
|
}
|
|
2097
2326
|
function processCoverage(config, coverageData) {
|
|
2098
|
-
if (!config.collectCoverage
|
|
2327
|
+
if (!config.collectCoverage) return true;
|
|
2328
|
+
if (coverageData === void 0) {
|
|
2329
|
+
if (!config.silent) process.stderr.write("Warning: coverage data was empty — the Rojo project may point at uninstrumented source\n");
|
|
2330
|
+
return true;
|
|
2331
|
+
}
|
|
2099
2332
|
const manifest = loadCoverageManifest(config.rootDir);
|
|
2100
2333
|
if (manifest === void 0) {
|
|
2101
2334
|
if (!config.silent) process.stderr.write("Warning: Coverage manifest not found, skipping TS mapping\n");
|
|
@@ -2277,7 +2510,7 @@ function loadRojoTree(config) {
|
|
|
2277
2510
|
const content = fs$1.readFileSync(rojoPath, "utf8");
|
|
2278
2511
|
const validated = rojoProjectSchema(JSON.parse(content));
|
|
2279
2512
|
if (validated instanceof type.errors) throw new Error(`Invalid Rojo project: ${validated.summary}`);
|
|
2280
|
-
return validated.tree;
|
|
2513
|
+
return resolveNestedProjects(validated.tree, path$1.dirname(rojoPath));
|
|
2281
2514
|
}
|
|
2282
2515
|
const STUB_SKIP_KEYS = new Set([
|
|
2283
2516
|
"outDir",
|
|
@@ -2386,6 +2619,7 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
|
|
|
2386
2619
|
tsconfig: rootConfig.typecheckTsconfig
|
|
2387
2620
|
}) : void 0;
|
|
2388
2621
|
if (projectResults.length === 0 && typecheckResult === void 0) {
|
|
2622
|
+
if (rootConfig.passWithNoTests) return 0;
|
|
2389
2623
|
console.error("No test files found in any project");
|
|
2390
2624
|
return 2;
|
|
2391
2625
|
}
|
|
@@ -2414,12 +2648,14 @@ async function runSingleProject(config, cliFiles) {
|
|
|
2414
2648
|
resolveSetupFilePaths(config);
|
|
2415
2649
|
const discovery = discoverTestFiles(config, cliFiles);
|
|
2416
2650
|
if (discovery.files.length === 0) {
|
|
2651
|
+
if (config.passWithNoTests) return 0;
|
|
2417
2652
|
console.error("No test files found");
|
|
2418
2653
|
return 2;
|
|
2419
2654
|
}
|
|
2420
2655
|
const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
|
|
2421
2656
|
const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
|
|
2422
2657
|
if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
|
|
2658
|
+
if (config.passWithNoTests) return 0;
|
|
2423
2659
|
console.error("No test files found for the selected mode");
|
|
2424
2660
|
return 2;
|
|
2425
2661
|
}
|
|
@@ -2531,6 +2767,7 @@ function mergeCliWithConfig(cli, config) {
|
|
|
2531
2767
|
formatters: resolveFormatters(cli, config),
|
|
2532
2768
|
gameOutput: cli.gameOutput ?? config.gameOutput,
|
|
2533
2769
|
outputFile: cli.outputFile ?? config.outputFile,
|
|
2770
|
+
passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
|
|
2534
2771
|
pollInterval: cli.pollInterval ?? config.pollInterval,
|
|
2535
2772
|
port: cli.port ?? config.port,
|
|
2536
2773
|
rojoProject: cli.rojoProject ?? config.rojoProject,
|
|
@@ -825,6 +825,7 @@ interface Config extends Except<Argv, "projects" | "rootDir" | "setupFiles" | "s
|
|
|
825
825
|
gameOutput?: string;
|
|
826
826
|
jestPath?: string;
|
|
827
827
|
luauRoots?: Array<string>;
|
|
828
|
+
passWithNoTests?: boolean;
|
|
828
829
|
placeFile?: string;
|
|
829
830
|
pollInterval?: number;
|
|
830
831
|
port?: number;
|
|
@@ -852,6 +853,7 @@ interface ResolvedConfig extends Except<Config, "projects"> {
|
|
|
852
853
|
coverageDirectory: string;
|
|
853
854
|
coveragePathIgnorePatterns: Array<string>;
|
|
854
855
|
coverageReporters: Array<CoverageReporter>;
|
|
856
|
+
passWithNoTests: boolean;
|
|
855
857
|
placeFile: string;
|
|
856
858
|
pollInterval: number;
|
|
857
859
|
port: number;
|
|
@@ -884,6 +886,7 @@ interface CliOptions {
|
|
|
884
886
|
gameOutput?: string;
|
|
885
887
|
help?: boolean;
|
|
886
888
|
outputFile?: string;
|
|
889
|
+
passWithNoTests?: boolean;
|
|
887
890
|
pollInterval?: number;
|
|
888
891
|
port?: number;
|
|
889
892
|
project?: Array<string>;
|