@isentinel/jest-roblox 0.1.2 → 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 CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-DqZE3wME.mjs";
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,11 +1,11 @@
1
- import { D as createOpenCloudBackend, I as isValidBackend, L as LuauScriptError, M as ROOT_ONLY_KEYS, N as VALID_BACKENDS, O as hashBuffer, S as loadConfig$1, T as createStudioBackend, _ as formatResult, a as formatAnnotations, b as formatBanner, c as execute, d as findFormatterOptions, g as formatMultiProjectResult, i as runTypecheck, 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, x as rojoProjectSchema, y as formatTypecheckSummary } from "./game-output-BMGxhjkE.mjs";
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";
5
5
  import * as fs$1 from "node:fs";
6
- import fs, { readFileSync } from "node:fs";
6
+ import fs from "node:fs";
7
7
  import * as path$1 from "node:path";
8
- import path, { dirname, join } from "node:path";
8
+ import path from "node:path";
9
9
  import process from "node:process";
10
10
  import { parseArgs as parseArgs$1 } from "node:util";
11
11
  import { isAgent } from "std-env";
@@ -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.2";
26
+ var version = "0.1.3";
27
27
  //#endregion
28
28
  //#region src/backends/auto.ts
29
29
  var StudioWithFallback = class {
@@ -115,39 +115,95 @@ function stripTsExtension(pattern) {
115
115
  return pattern.replace(/\.(tsx?|luau?)$/, "");
116
116
  }
117
117
  //#endregion
118
- //#region src/utils/rojo-tree.ts
119
- function resolveNestedProjects(tree, rootDirectory) {
120
- return resolveTree(tree, rootDirectory, /* @__PURE__ */ new Set());
121
- }
122
- function collectPaths(node, result) {
123
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
124
- else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
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;
125
156
  }
126
- function resolveTree(node, rootDirectory, visited) {
127
- const resolved = {};
128
- for (const [key, value] of Object.entries(node)) {
129
- if (key === "$path" && typeof value === "string" && value.endsWith(".project.json")) {
130
- const projectPath = join(rootDirectory, value);
131
- if (visited.has(projectPath)) throw new Error(`Circular project reference: ${value}`);
132
- const chain = new Set(visited);
133
- chain.add(projectPath);
134
- let content;
135
- try {
136
- content = readFileSync(projectPath, "utf-8");
137
- } catch (err) {
138
- throw new Error(`Could not read nested Rojo project: ${value}`, { cause: err });
139
- }
140
- const innerTree = resolveTree(JSON.parse(content).tree, dirname(projectPath), chain);
141
- for (const [innerKey, innerValue] of Object.entries(innerTree)) resolved[innerKey] = innerValue;
142
- continue;
143
- }
144
- if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
145
- resolved[key] = value;
146
- continue;
147
- }
148
- resolved[key] = resolveTree(value, rootDirectory, visited);
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 });
149
190
  }
150
- return resolved;
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;
151
207
  }
152
208
  //#endregion
153
209
  //#region src/config/projects.ts
@@ -249,6 +305,8 @@ function resolveProjectConfig(project, rootConfig, rojoTree) {
249
305
  };
250
306
  }
251
307
  async function loadProjectConfigFile(filePath, cwd) {
308
+ const luauConfigPath = findLuauConfigFile(filePath, cwd);
309
+ if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
252
310
  let result;
253
311
  try {
254
312
  result = await loadConfig({
@@ -290,6 +348,38 @@ function mergeProjectConfig(rootConfig, project) {
290
348
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
291
349
  return merged;
292
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
+ }
293
383
  function matchNodePath(childNode, targetPath, childDataModelPath) {
294
384
  const nodePath = childNode.$path;
295
385
  if (typeof nodePath !== "string") return;
@@ -365,6 +455,8 @@ function serializeToLuau(config) {
365
455
  }
366
456
  function generateProjectConfigs(projects) {
367
457
  for (const project of projects) {
458
+ const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
459
+ if (fs.existsSync(luauConfigPath)) continue;
368
460
  const content = serializeToLuau(project.config);
369
461
  const directory = path.dirname(project.outputPath);
370
462
  fs.mkdirSync(directory, { recursive: true });
@@ -470,27 +562,133 @@ function mapCoverageToTypeScript(coverageData, manifest) {
470
562
  if (record === void 0) continue;
471
563
  const resources = loadFileResources(record);
472
564
  if (resources === void 0) continue;
473
- mapFileFunctions(resources, fileCoverage, pendingFunctions, mapFileStatements(resources, fileCoverage, pendingStatements));
474
- mapFileBranches(resources, fileCoverage, pendingBranches);
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
+ }
475
577
  }
476
578
  return buildResult(pendingStatements, pendingFunctions, pendingBranches);
477
579
  }
478
580
  function loadFileResources(record) {
479
581
  let coverageMapRaw;
480
- let sourceMapRaw;
481
582
  try {
482
583
  coverageMapRaw = fs$1.readFileSync(record.coverageMapPath, "utf-8");
483
- sourceMapRaw = fs$1.readFileSync(record.sourceMapPath, "utf-8");
484
584
  } catch {
485
585
  return;
486
586
  }
487
587
  const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
488
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 {}
489
593
  return {
490
594
  coverageMap: parsed,
491
- traceMap: new TraceMap(sourceMapRaw)
595
+ sourceKey: record.key,
596
+ traceMap
492
597
  };
493
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
+ }
494
692
  function mapStatement(traceMap, span) {
495
693
  const mappedStart = originalPositionFor(traceMap, {
496
694
  column: Math.max(0, span.start.column - 1),
@@ -731,26 +929,7 @@ function buildResult(pending, pendingFunctions, pendingBranches) {
731
929
  return { files };
732
930
  }
733
931
  //#endregion
734
- //#region src/types/luau-ast.ts
735
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
736
- "assign",
737
- "break",
738
- "compoundassign",
739
- "conditional",
740
- "continue",
741
- "do",
742
- "expression",
743
- "for",
744
- "forin",
745
- "function",
746
- "local",
747
- "localfunction",
748
- "repeat",
749
- "return",
750
- "while"
751
- ]);
752
- //#endregion
753
- //#region src/coverage/luau-visitor.ts
932
+ //#region src/luau/visitor.ts
754
933
  function visitExpression(expression, visitor) {
755
934
  if (visitor.visitExpr?.(expression) === false) return;
756
935
  const { tag } = expression;
@@ -1021,6 +1200,23 @@ function visitExprInstantiate(node, visitor) {
1021
1200
  }
1022
1201
  //#endregion
1023
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
+ ]);
1024
1220
  const END_KEYWORD_LENGTH = 3;
1025
1221
  function collectCoverage(root) {
1026
1222
  let statementIndex = 1;
@@ -1260,9 +1456,6 @@ function buildCoverageMap$1(result) {
1260
1456
  };
1261
1457
  }
1262
1458
  //#endregion
1263
- //#region src/coverage/parse-ast.luau
1264
- 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";
1265
- //#endregion
1266
1459
  //#region src/coverage/probe-inserter.ts
1267
1460
  function insertProbes(source, result, fileKey) {
1268
1461
  const lines = splitLines(source);
@@ -1317,7 +1510,7 @@ function applyProbes(mutableLines, probes) {
1317
1510
  assert(line !== void 0, `Invalid probe line number: ${probeLine}`);
1318
1511
  const before = line.slice(0, column - 1);
1319
1512
  const after = line.slice(column - 1);
1320
- mutableLines[lineIndex] = before + text + after;
1513
+ mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
1321
1514
  }
1322
1515
  }
1323
1516
  function extractModeDirective(lines) {
@@ -1866,6 +2059,7 @@ Options:
1866
2059
  --gameOutput <path> Write game output (print/warn/error) to file
1867
2060
  --sourceMap Map Luau stack traces to TypeScript source
1868
2061
  --rojoProject <path> Path to rojo project file (auto-detected if not set)
2062
+ --passWithNoTests Exit with 0 when no test files are found
1869
2063
  --verbose Show individual test results
1870
2064
  --silent Suppress output
1871
2065
  --no-color Disable colored output
@@ -1927,6 +2121,7 @@ function parseArgs(args) {
1927
2121
  "no-color": { type: "boolean" },
1928
2122
  "no-show-luau": { type: "boolean" },
1929
2123
  "outputFile": { type: "string" },
2124
+ "passWithNoTests": { type: "boolean" },
1930
2125
  "pollInterval": { type: "string" },
1931
2126
  "port": { type: "string" },
1932
2127
  "project": {
@@ -1982,6 +2177,7 @@ function parseArgs(args) {
1982
2177
  gameOutput: values.gameOutput,
1983
2178
  help: values.help,
1984
2179
  outputFile: values.outputFile,
2180
+ passWithNoTests: values.passWithNoTests,
1985
2181
  pollInterval,
1986
2182
  port,
1987
2183
  project: values.project,
@@ -2423,6 +2619,7 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
2423
2619
  tsconfig: rootConfig.typecheckTsconfig
2424
2620
  }) : void 0;
2425
2621
  if (projectResults.length === 0 && typecheckResult === void 0) {
2622
+ if (rootConfig.passWithNoTests) return 0;
2426
2623
  console.error("No test files found in any project");
2427
2624
  return 2;
2428
2625
  }
@@ -2451,12 +2648,14 @@ async function runSingleProject(config, cliFiles) {
2451
2648
  resolveSetupFilePaths(config);
2452
2649
  const discovery = discoverTestFiles(config, cliFiles);
2453
2650
  if (discovery.files.length === 0) {
2651
+ if (config.passWithNoTests) return 0;
2454
2652
  console.error("No test files found");
2455
2653
  return 2;
2456
2654
  }
2457
2655
  const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
2458
2656
  const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
2459
2657
  if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
2658
+ if (config.passWithNoTests) return 0;
2460
2659
  console.error("No test files found for the selected mode");
2461
2660
  return 2;
2462
2661
  }
@@ -2568,6 +2767,7 @@ function mergeCliWithConfig(cli, config) {
2568
2767
  formatters: resolveFormatters(cli, config),
2569
2768
  gameOutput: cli.gameOutput ?? config.gameOutput,
2570
2769
  outputFile: cli.outputFile ?? config.outputFile,
2770
+ passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
2571
2771
  pollInterval: cli.pollInterval ?? config.pollInterval,
2572
2772
  port: cli.port ?? config.port,
2573
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>;
@@ -4,7 +4,7 @@ import assert from "node:assert";
4
4
  import * as fs$1 from "node:fs";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import * as path$1 from "node:path";
7
- import path from "node:path";
7
+ import path, { dirname, join, relative } from "node:path";
8
8
  import process from "node:process";
9
9
  import color from "tinyrainbow";
10
10
  import { WebSocketServer } from "ws";
@@ -14,9 +14,9 @@ import * as crypto from "node:crypto";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import buffer from "node:buffer";
16
16
  import { defuFn } from "defu";
17
+ import { execFileSync } from "node:child_process";
17
18
  import { TraceMap, originalPositionFor, sourceContentFor } from "@jridgewell/trace-mapping";
18
19
  import { getTsconfig } from "get-tsconfig";
19
- import { execFileSync } from "node:child_process";
20
20
  import hljs from "highlight.js/lib/core";
21
21
  import typescript from "highlight.js/lib/languages/typescript";
22
22
  import { parseJSONC } from "confbox";
@@ -243,6 +243,7 @@ const DEFAULT_CONFIG = {
243
243
  "**/rbxts_include/**"
244
244
  ],
245
245
  coverageReporters: ["text", "lcov"],
246
+ passWithNoTests: false,
246
247
  placeFile: "./game.rbxl",
247
248
  pollInterval: 500,
248
249
  port: 3001,
@@ -361,6 +362,7 @@ const configSchema = type({
361
362
  "maxWorkers?": type("number").or(type("string")),
362
363
  "noStackTrace?": "boolean",
363
364
  "outputFile?": "string",
365
+ "passWithNoTests?": "boolean",
364
366
  "placeFile?": "string",
365
367
  "pollInterval?": "number",
366
368
  "port?": "number",
@@ -822,6 +824,52 @@ function resolveFunctionValues(config) {
822
824
  return resolved;
823
825
  }
824
826
  //#endregion
827
+ //#region src/utils/rojo-tree.ts
828
+ function resolveNestedProjects(tree, rootDirectory) {
829
+ return resolveTree(tree, rootDirectory, rootDirectory, /* @__PURE__ */ new Set());
830
+ }
831
+ function collectPaths(node, result) {
832
+ for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
833
+ else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
834
+ }
835
+ function inlineNestedProject(projectPath, currentDirectory, originalRoot, visited) {
836
+ const chain = new Set(visited);
837
+ chain.add(projectPath);
838
+ let content;
839
+ try {
840
+ content = readFileSync(projectPath, "utf-8");
841
+ } catch (err) {
842
+ const relativePath = relative(currentDirectory, projectPath);
843
+ throw new Error(`Could not read nested Rojo project: ${relativePath}`, { cause: err });
844
+ }
845
+ return resolveTree(JSON.parse(content).tree, dirname(projectPath), originalRoot, chain);
846
+ }
847
+ function resolveRootRelativePath(currentDirectory, value, originalRoot) {
848
+ return relative(originalRoot, join(currentDirectory, value)).replaceAll("\\", "/");
849
+ }
850
+ function resolveTree(node, currentDirectory, originalRoot, visited) {
851
+ const resolved = {};
852
+ for (const [key, value] of Object.entries(node)) {
853
+ if (key === "$path" && typeof value === "string" && value.endsWith(".project.json")) {
854
+ const projectPath = join(currentDirectory, value);
855
+ if (visited.has(projectPath)) throw new Error(`Circular project reference: ${value}`);
856
+ const innerTree = inlineNestedProject(projectPath, currentDirectory, originalRoot, visited);
857
+ for (const [innerKey, innerValue] of Object.entries(innerTree)) resolved[innerKey] = innerValue;
858
+ continue;
859
+ }
860
+ if (key === "$path" && typeof value === "string") {
861
+ resolved[key] = resolveRootRelativePath(currentDirectory, value, originalRoot);
862
+ continue;
863
+ }
864
+ if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
865
+ resolved[key] = value;
866
+ continue;
867
+ }
868
+ resolved[key] = resolveTree(value, currentDirectory, originalRoot, visited);
869
+ }
870
+ return resolved;
871
+ }
872
+ //#endregion
825
873
  //#region src/types/rojo.ts
826
874
  const rojoProjectSchema = type({
827
875
  "name": "string",
@@ -2487,9 +2535,13 @@ function buildSourceMapper(config, tsconfigMappings) {
2487
2535
  try {
2488
2536
  const rojoResult = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
2489
2537
  if (rojoResult instanceof type.errors) return;
2538
+ const resolvedTree = resolveNestedProjects(rojoResult.tree, path$1.dirname(rojoProjectPath));
2490
2539
  return createSourceMapper({
2491
2540
  mappings: tsconfigMappings,
2492
- rojoProject: rojoResult
2541
+ rojoProject: {
2542
+ ...rojoResult,
2543
+ tree: resolvedTree
2544
+ }
2493
2545
  });
2494
2546
  } catch {
2495
2547
  return;
@@ -2567,9 +2619,13 @@ function writeSnapshots(snapshotWrites, config, tsconfigMappings) {
2567
2619
  process.stderr.write("Warning: Cannot write snapshots - invalid rojo project\n");
2568
2620
  return;
2569
2621
  }
2622
+ const resolvedTree = resolveNestedProjects(rojoResult.tree, path$1.dirname(rojoProjectPath));
2570
2623
  const resolver = createSnapshotPathResolver({
2571
2624
  mappings: tsconfigMappings,
2572
- rojoProject: rojoResult
2625
+ rojoProject: {
2626
+ ...rojoResult,
2627
+ tree: resolvedTree
2628
+ }
2573
2629
  });
2574
2630
  let written = 0;
2575
2631
  for (const [virtualPath, content] of Object.entries(snapshotWrites)) {
@@ -3027,4 +3083,4 @@ function writeGameOutput(filePath, entries) {
3027
3083
  fs$1.writeFileSync(absolutePath, JSON.stringify(entries, null, 2));
3028
3084
  }
3029
3085
  //#endregion
3030
- export { generateTestScript as A, resolveConfig as C, createOpenCloudBackend as D, OpenCloudBackend as E, defineProject as F, isValidBackend as I, LuauScriptError as L, ROOT_ONLY_KEYS as M, VALID_BACKENDS as N, hashBuffer as O, defineConfig as P, extractJsonFromOutput as R, loadConfig$1 as S, createStudioBackend as T, formatResult as _, formatAnnotations as a, formatBanner as b, execute as c, findFormatterOptions as d, formatJson as f, formatMultiProjectResult as g, formatFailure as h, runTypecheck as i, DEFAULT_CONFIG as j, buildJestArgv as k, formatExecuteOutput as l, formatCompactMultiProject as m, parseGameOutput as n, formatJobSummary as o, writeJsonFile as p, writeGameOutput as r, resolveGitHubActionsOptions as s, formatGameOutputNotice as t, loadCoverageManifest as u, formatTestSummary as v, StudioBackend as w, rojoProjectSchema as x, formatTypecheckSummary as y, parseJestOutput as z };
3086
+ export { hashBuffer as A, extractJsonFromOutput as B, resolveNestedProjects as C, createStudioBackend as D, StudioBackend as E, VALID_BACKENDS as F, defineConfig as I, defineProject as L, generateTestScript as M, DEFAULT_CONFIG as N, OpenCloudBackend as O, ROOT_ONLY_KEYS as P, isValidBackend as R, collectPaths as S, resolveConfig as T, parseJestOutput as V, formatResult as _, formatAnnotations as a, formatBanner as b, execute as c, findFormatterOptions as d, formatJson as f, formatMultiProjectResult as g, formatFailure as h, runTypecheck as i, buildJestArgv as j, createOpenCloudBackend as k, formatExecuteOutput as l, formatCompactMultiProject as m, parseGameOutput as n, formatJobSummary as o, writeJsonFile as p, writeGameOutput as r, resolveGitHubActionsOptions as s, formatGameOutputNotice as t, loadCoverageManifest as u, formatTestSummary as v, loadConfig$1 as w, rojoProjectSchema as x, formatTypecheckSummary as y, LuauScriptError as z };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as defineConfig, C as FormatterEntry, D as ROOT_ONLY_KEYS, E as ProjectTestConfig, M as Argv, O as ResolvedConfig, S as DisplayName, T as ProjectEntry, _ as ResolvedProjectConfig, a as formatExecuteOutput, b as ConfigInput, c as Backend, d as extractJsonFromOutput, f as parseJestOutput, g as TestStatus, h as TestFileResult, i as execute, j as defineProject, k as SnapshotFormatOptions, l as BackendOptions, m as TestCaseResult, n as ExecuteResult, o as TimingResult, p as JestResult, r as FormatOutputOptions, s as SourceMapper, t as ExecuteOptions, u as BackendResult, v as CliOptions, w as InlineProjectConfig, x as DEFAULT_CONFIG, y as Config } from "./executor-DqZE3wME.mjs";
1
+ import { A as defineConfig, C as FormatterEntry, D as ROOT_ONLY_KEYS, E as ProjectTestConfig, M as Argv, O as ResolvedConfig, S as DisplayName, T as ProjectEntry, _ as ResolvedProjectConfig, a as formatExecuteOutput, b as ConfigInput, c as Backend, d as extractJsonFromOutput, f as parseJestOutput, g as TestStatus, h as TestFileResult, i as execute, j as defineProject, k as SnapshotFormatOptions, l as BackendOptions, m as TestCaseResult, n as ExecuteResult, o as TimingResult, p as JestResult, r as FormatOutputOptions, s as SourceMapper, t as ExecuteOptions, u as BackendResult, v as CliOptions, w as InlineProjectConfig, x as DEFAULT_CONFIG, y as Config } from "./executor-D6BzDfQ_.mjs";
2
2
  import { WebSocket, WebSocketServer } from "ws";
3
3
  import buffer from "node:buffer";
4
4
 
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as generateTestScript, C as resolveConfig, D as createOpenCloudBackend, E as OpenCloudBackend, F as defineProject, M as ROOT_ONLY_KEYS, P as defineConfig, R as extractJsonFromOutput, S as loadConfig, T as createStudioBackend, _ as formatResult, a as formatAnnotations, c as execute, f as formatJson, h as formatFailure, i as runTypecheck, j as DEFAULT_CONFIG, k as buildJestArgv, l as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, t as formatGameOutputNotice, v as formatTestSummary, w as StudioBackend, z as parseJestOutput } from "./game-output-BMGxhjkE.mjs";
1
+ import { B as extractJsonFromOutput, D as createStudioBackend, E as StudioBackend, I as defineConfig, L as defineProject, M as generateTestScript, N as DEFAULT_CONFIG, O as OpenCloudBackend, P as ROOT_ONLY_KEYS, T as resolveConfig, V as parseJestOutput, _ as formatResult, a as formatAnnotations, c as execute, f as formatJson, h as formatFailure, i as runTypecheck, j as buildJestArgv, k as createOpenCloudBackend, l as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as writeJsonFile, r as writeGameOutput, t as formatGameOutputNotice, v as formatTestSummary, w as loadConfig } from "./game-output-BU-9pJ93.mjs";
2
2
  export { DEFAULT_CONFIG, OpenCloudBackend, ROOT_ONLY_KEYS, StudioBackend, buildJestArgv, createOpenCloudBackend, createStudioBackend, defineConfig, defineProject, execute, extractJsonFromOutput, formatAnnotations, formatExecuteOutput, formatFailure, formatGameOutputNotice, formatJobSummary, formatJson, formatResult, formatTestSummary, generateTestScript, loadConfig, parseGameOutput, parseJestOutput, resolveConfig, runTypecheck, writeGameOutput, writeJsonFile };
Binary file
@@ -7640,7 +7640,7 @@ function f$9() {
7640
7640
  var C$5 = f$9();
7641
7641
  //#endregion
7642
7642
  //#region package.json
7643
- var version = "0.1.2";
7643
+ var version = "0.1.3";
7644
7644
  //#endregion
7645
7645
  //#region node_modules/.pnpm/ws@8.18.0/node_modules/ws/lib/stream.js
7646
7646
  var require_stream = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -11358,7 +11358,7 @@ function normalizeString(path, allowAboveRoot) {
11358
11358
  }
11359
11359
  return res;
11360
11360
  }
11361
- var _DRIVE_LETTER_START_RE, _UNC_REGEX, _IS_ABSOLUTE_RE, _DRIVE_LETTER_RE, _ROOT_FOLDER_RE, _EXTNAME_RE, normalize, join$2, resolve$2, isAbsolute$1, extname$2, relative, dirname$1, basename;
11361
+ var _DRIVE_LETTER_START_RE, _UNC_REGEX, _IS_ABSOLUTE_RE, _DRIVE_LETTER_RE, _ROOT_FOLDER_RE, _EXTNAME_RE, normalize, join$2, resolve$2, isAbsolute$1, extname$2, relative$1, dirname$1, basename;
11362
11362
  var init_pathe_M_eThtNZ = __esmMin((() => {
11363
11363
  _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
11364
11364
  _UNC_REGEX = /^[/\\]{2}/;
@@ -11420,7 +11420,7 @@ var init_pathe_M_eThtNZ = __esmMin((() => {
11420
11420
  const match = _EXTNAME_RE.exec(normalizeWindowsPath$1(p));
11421
11421
  return match && match[1] || "";
11422
11422
  };
11423
- relative = function(from, to) {
11423
+ relative$1 = function(from, to) {
11424
11424
  const _from = resolve$2(from).replace(_ROOT_FOLDER_RE, "$1").split("/");
11425
11425
  const _to = resolve$2(to).replace(_ROOT_FOLDER_RE, "$1").split("/");
11426
11426
  if (_to[0][1] === ":" && _from[0][1] === ":" && _from[0] !== _to[0]) return _to.join("/");
@@ -35720,7 +35720,7 @@ function currentShell() {
35720
35720
  function startShell(cwd) {
35721
35721
  cwd = resolve$2(cwd);
35722
35722
  const shell = currentShell();
35723
- console.info(`(experimental) Opening shell in ${relative(process.cwd(), cwd)}...`);
35723
+ console.info(`(experimental) Opening shell in ${relative$1(process.cwd(), cwd)}...`);
35724
35724
  (0, node_child_process.spawnSync)(shell, [], {
35725
35725
  cwd,
35726
35726
  shell: true,
@@ -41031,6 +41031,7 @@ const DEFAULT_CONFIG = {
41031
41031
  "**/rbxts_include/**"
41032
41032
  ],
41033
41033
  coverageReporters: ["text", "lcov"],
41034
+ passWithNoTests: false,
41034
41035
  placeFile: "./game.rbxl",
41035
41036
  pollInterval: 500,
41036
41037
  port: 3001,
@@ -41149,6 +41150,7 @@ const configSchema = type({
41149
41150
  "maxWorkers?": type("number").or(type("string")),
41150
41151
  "noStackTrace?": "boolean",
41151
41152
  "outputFile?": "string",
41153
+ "passWithNoTests?": "boolean",
41152
41154
  "placeFile?": "string",
41153
41155
  "pollInterval?": "number",
41154
41156
  "port?": "number",
@@ -41703,39 +41705,141 @@ function stripTsExtension(pattern) {
41703
41705
  //#endregion
41704
41706
  //#region src/utils/rojo-tree.ts
41705
41707
  function resolveNestedProjects(tree, rootDirectory) {
41706
- return resolveTree(tree, rootDirectory, /* @__PURE__ */ new Set());
41708
+ return resolveTree(tree, rootDirectory, rootDirectory, /* @__PURE__ */ new Set());
41707
41709
  }
41708
41710
  function collectPaths(node, result) {
41709
41711
  for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
41710
41712
  else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
41711
41713
  }
41712
- function resolveTree(node, rootDirectory, visited) {
41714
+ function inlineNestedProject(projectPath, currentDirectory, originalRoot, visited) {
41715
+ const chain = new Set(visited);
41716
+ chain.add(projectPath);
41717
+ let content;
41718
+ try {
41719
+ content = (0, node_fs.readFileSync)(projectPath, "utf-8");
41720
+ } catch (err) {
41721
+ const relativePath = (0, node_path.relative)(currentDirectory, projectPath);
41722
+ throw new Error(`Could not read nested Rojo project: ${relativePath}`, { cause: err });
41723
+ }
41724
+ return resolveTree(JSON.parse(content).tree, (0, node_path.dirname)(projectPath), originalRoot, chain);
41725
+ }
41726
+ function resolveRootRelativePath(currentDirectory, value, originalRoot) {
41727
+ return (0, node_path.relative)(originalRoot, (0, node_path.join)(currentDirectory, value)).replaceAll("\\", "/");
41728
+ }
41729
+ function resolveTree(node, currentDirectory, originalRoot, visited) {
41713
41730
  const resolved = {};
41714
41731
  for (const [key, value] of Object.entries(node)) {
41715
41732
  if (key === "$path" && typeof value === "string" && value.endsWith(".project.json")) {
41716
- const projectPath = (0, node_path.join)(rootDirectory, value);
41733
+ const projectPath = (0, node_path.join)(currentDirectory, value);
41717
41734
  if (visited.has(projectPath)) throw new Error(`Circular project reference: ${value}`);
41718
- const chain = new Set(visited);
41719
- chain.add(projectPath);
41720
- let content;
41721
- try {
41722
- content = (0, node_fs.readFileSync)(projectPath, "utf-8");
41723
- } catch (err) {
41724
- throw new Error(`Could not read nested Rojo project: ${value}`, { cause: err });
41725
- }
41726
- const innerTree = resolveTree(JSON.parse(content).tree, (0, node_path.dirname)(projectPath), chain);
41735
+ const innerTree = inlineNestedProject(projectPath, currentDirectory, originalRoot, visited);
41727
41736
  for (const [innerKey, innerValue] of Object.entries(innerTree)) resolved[innerKey] = innerValue;
41728
41737
  continue;
41729
41738
  }
41739
+ if (key === "$path" && typeof value === "string") {
41740
+ resolved[key] = resolveRootRelativePath(currentDirectory, value, originalRoot);
41741
+ continue;
41742
+ }
41730
41743
  if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
41731
41744
  resolved[key] = value;
41732
41745
  continue;
41733
41746
  }
41734
- resolved[key] = resolveTree(value, rootDirectory, visited);
41747
+ resolved[key] = resolveTree(value, currentDirectory, originalRoot, visited);
41735
41748
  }
41736
41749
  return resolved;
41737
41750
  }
41738
41751
  //#endregion
41752
+ //#region src/luau/eval-literals.ts
41753
+ /**
41754
+ * Evaluate the first return expression in a Lute-stripped AST root block,
41755
+ * supporting only literal values (string, boolean, number, nil, table, cast).
41756
+ *
41757
+ * Accepts `unknown` and narrows safely — no type casts on JSON.parse needed.
41758
+ */
41759
+ function evalLuauReturnLiterals(root) {
41760
+ if (!isObject(root) || !Array.isArray(root["statements"])) throw new Error("Config file has no return statement");
41761
+ const returnStat = root["statements"].find((stat) => isObject(stat) && stat["tag"] === "return");
41762
+ if (!isObject(returnStat) || !Array.isArray(returnStat["expressions"])) throw new Error("Config file has no return statement");
41763
+ const first = returnStat["expressions"][0];
41764
+ if (!isObject(first) || !("node" in first)) throw new Error("Return statement has no expressions");
41765
+ return evalExpr(first["node"]);
41766
+ }
41767
+ function isObject(value) {
41768
+ return typeof value === "object" && value !== null;
41769
+ }
41770
+ function evalExpr(node) {
41771
+ if (!isObject(node)) return;
41772
+ let current = node;
41773
+ while (current["tag"] === "cast" && isObject(current["operand"])) current = current["operand"];
41774
+ const { tag } = current;
41775
+ if (tag === "boolean" || tag === "number") return current["value"];
41776
+ if (tag === "string") return current["text"];
41777
+ if (tag === "table" && Array.isArray(current["entries"])) return evalTable(current["entries"]);
41778
+ }
41779
+ function evalTable(entries) {
41780
+ if (entries.length === 0) return {};
41781
+ const first = entries[0];
41782
+ if (isObject(first) && first["kind"] === "list") return entries.map((entry) => isObject(entry) ? evalExpr(entry["value"]) : void 0);
41783
+ const result = {};
41784
+ for (const entry of entries) {
41785
+ if (!isObject(entry) || entry["kind"] !== "record") continue;
41786
+ const { key, value } = entry;
41787
+ if (isObject(key) && typeof key["text"] === "string") result[key["text"]] = evalExpr(value);
41788
+ }
41789
+ return result;
41790
+ }
41791
+ //#endregion
41792
+ //#region src/luau/parse-ast.luau
41793
+ 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";
41794
+ //#endregion
41795
+ //#region src/config/luau-config-loader.ts
41796
+ let cachedTemporaryDirectory$1;
41797
+ /**
41798
+ * Parse a .luau config file via Lute and evaluate its return expression.
41799
+ */
41800
+ function loadLuauConfig(filePath) {
41801
+ const temporaryDirectory = getTemporaryDirectory$1();
41802
+ const scriptPath = node_path.join(temporaryDirectory, "parse-ast.luau");
41803
+ node_fs.writeFileSync(scriptPath, parse_ast_default);
41804
+ let stdout;
41805
+ try {
41806
+ stdout = node_child_process.execFileSync("lute", [
41807
+ "run",
41808
+ scriptPath,
41809
+ "--",
41810
+ node_path.resolve(filePath)
41811
+ ], {
41812
+ encoding: "utf-8",
41813
+ maxBuffer: 1024 * 1024
41814
+ });
41815
+ } catch (err) {
41816
+ 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");
41817
+ throw new Error(`Failed to evaluate Luau config ${filePath}`, { cause: err });
41818
+ }
41819
+ let ast;
41820
+ try {
41821
+ ast = JSON.parse(stdout);
41822
+ } catch (err) {
41823
+ throw new Error(`Failed to parse AST JSON from Luau config ${filePath}`, { cause: err });
41824
+ }
41825
+ const result = evalLuauReturnLiterals(ast);
41826
+ if (typeof result !== "object" || result === null) throw new Error(`Luau config ${filePath} must return a table`);
41827
+ return result;
41828
+ }
41829
+ /**
41830
+ * Check if `<cwd>/<directoryOrFile>/jest.config.luau` exists. Returns the
41831
+ * resolved path if found, undefined otherwise.
41832
+ */
41833
+ function findLuauConfigFile(directoryOrFile, cwd) {
41834
+ const resolved = node_path.resolve(cwd, directoryOrFile, "jest.config.luau");
41835
+ if (node_fs.existsSync(resolved)) return resolved;
41836
+ }
41837
+ function getTemporaryDirectory$1() {
41838
+ if (cachedTemporaryDirectory$1 !== void 0 && node_fs.existsSync(cachedTemporaryDirectory$1)) return cachedTemporaryDirectory$1;
41839
+ cachedTemporaryDirectory$1 = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "jest-roblox-luau-config-"));
41840
+ return cachedTemporaryDirectory$1;
41841
+ }
41842
+ //#endregion
41739
41843
  //#region src/config/projects.ts
41740
41844
  function extractStaticRoot(pattern) {
41741
41845
  const globChars = new Set([
@@ -41835,6 +41939,8 @@ function resolveProjectConfig(project, rootConfig, rojoTree) {
41835
41939
  };
41836
41940
  }
41837
41941
  async function loadProjectConfigFile(filePath, cwd) {
41942
+ const luauConfigPath = findLuauConfigFile(filePath, cwd);
41943
+ if (luauConfigPath !== void 0) return buildProjectConfigFromLuau(luauConfigPath, filePath);
41838
41944
  let result;
41839
41945
  try {
41840
41946
  result = await loadConfig$1({
@@ -41876,6 +41982,38 @@ function mergeProjectConfig(rootConfig, project) {
41876
41982
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
41877
41983
  return merged;
41878
41984
  }
41985
+ const LUAU_BOOLEAN_KEYS = [
41986
+ "automock",
41987
+ "clearMocks",
41988
+ "injectGlobals",
41989
+ "mockDataModel",
41990
+ "resetMocks",
41991
+ "resetModules",
41992
+ "restoreMocks"
41993
+ ];
41994
+ const LUAU_NUMBER_KEYS = ["slowTestThreshold", "testTimeout"];
41995
+ const LUAU_STRING_KEYS = ["testEnvironment"];
41996
+ const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
41997
+ function copyLuauOptionalFields(raw, config) {
41998
+ const record = config;
41999
+ for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
42000
+ for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
42001
+ for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
42002
+ for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
42003
+ }
42004
+ function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
42005
+ const raw = loadLuauConfig(luauConfigPath);
42006
+ const { displayName } = raw;
42007
+ if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
42008
+ const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
42009
+ const config = {
42010
+ displayName,
42011
+ include: testMatch !== void 0 ? testMatch.map((pattern) => node_path.posix.join(directoryPath, `${pattern}.luau`)) : [node_path.posix.join(directoryPath, "**/*.spec.luau")]
42012
+ };
42013
+ if (testMatch !== void 0) config.testMatch = testMatch;
42014
+ copyLuauOptionalFields(raw, config);
42015
+ return config;
42016
+ }
41879
42017
  function matchNodePath(childNode, targetPath, childDataModelPath) {
41880
42018
  const nodePath = childNode.$path;
41881
42019
  if (typeof nodePath !== "string") return;
@@ -49913,6 +50051,8 @@ function serializeToLuau(config) {
49913
50051
  }
49914
50052
  function generateProjectConfigs(projects) {
49915
50053
  for (const project of projects) {
50054
+ const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
50055
+ if (node_fs.default.existsSync(luauConfigPath)) continue;
49916
50056
  const content = serializeToLuau(project.config);
49917
50057
  const directory = node_path.default.dirname(project.outputPath);
49918
50058
  node_fs.default.mkdirSync(directory, { recursive: true });
@@ -50452,27 +50592,133 @@ function mapCoverageToTypeScript(coverageData, manifest) {
50452
50592
  if (record === void 0) continue;
50453
50593
  const resources = loadFileResources(record);
50454
50594
  if (resources === void 0) continue;
50455
- mapFileFunctions(resources, fileCoverage, pendingFunctions, mapFileStatements(resources, fileCoverage, pendingStatements));
50456
- mapFileBranches(resources, fileCoverage, pendingBranches);
50595
+ if (resources.traceMap === void 0) {
50596
+ passthroughFileStatements(resources, fileCoverage, pendingStatements);
50597
+ passthroughFileFunctions(resources, fileCoverage, pendingFunctions);
50598
+ passthroughFileBranches(resources, fileCoverage, pendingBranches);
50599
+ } else {
50600
+ const mapped = {
50601
+ coverageMap: resources.coverageMap,
50602
+ traceMap: resources.traceMap
50603
+ };
50604
+ mapFileFunctions(mapped, fileCoverage, pendingFunctions, mapFileStatements(mapped, fileCoverage, pendingStatements));
50605
+ mapFileBranches(mapped, fileCoverage, pendingBranches);
50606
+ }
50457
50607
  }
50458
50608
  return buildResult(pendingStatements, pendingFunctions, pendingBranches);
50459
50609
  }
50460
50610
  function loadFileResources(record) {
50461
50611
  let coverageMapRaw;
50462
- let sourceMapRaw;
50463
50612
  try {
50464
50613
  coverageMapRaw = node_fs.readFileSync(record.coverageMapPath, "utf-8");
50465
- sourceMapRaw = node_fs.readFileSync(record.sourceMapPath, "utf-8");
50466
50614
  } catch {
50467
50615
  return;
50468
50616
  }
50469
50617
  const parsed = coverageMapSchema(JSON.parse(coverageMapRaw));
50470
50618
  if (parsed instanceof type.errors) return;
50619
+ let traceMap;
50620
+ try {
50621
+ traceMap = new TraceMap(node_fs.readFileSync(record.sourceMapPath, "utf-8"));
50622
+ } catch {}
50471
50623
  return {
50472
50624
  coverageMap: parsed,
50473
- traceMap: new TraceMap(sourceMapRaw)
50625
+ sourceKey: record.key,
50626
+ traceMap
50474
50627
  };
50475
50628
  }
50629
+ function toIstanbulColumn(luauColumn) {
50630
+ return Math.max(0, luauColumn - 1);
50631
+ }
50632
+ function passthroughFileStatements(resources, fileCoverage, pending) {
50633
+ let fileStatements = pending.get(resources.sourceKey);
50634
+ if (fileStatements === void 0) {
50635
+ fileStatements = /* @__PURE__ */ new Map();
50636
+ pending.set(resources.sourceKey, fileStatements);
50637
+ }
50638
+ for (const [statementId, rawSpan] of Object.entries(resources.coverageMap.statementMap)) {
50639
+ const span = spanSchema(rawSpan);
50640
+ if (span instanceof type.errors) continue;
50641
+ const hitCount = fileCoverage.s[statementId] ?? 0;
50642
+ fileStatements.set(statementId, {
50643
+ end: {
50644
+ column: toIstanbulColumn(span.end.column),
50645
+ line: span.end.line
50646
+ },
50647
+ hitCount,
50648
+ start: {
50649
+ column: toIstanbulColumn(span.start.column),
50650
+ line: span.start.line
50651
+ }
50652
+ });
50653
+ }
50654
+ }
50655
+ function passthroughFileFunctions(resources, fileCoverage, pendingFunctions) {
50656
+ if (resources.coverageMap.functionMap === void 0) return;
50657
+ let fileFunctions = pendingFunctions.get(resources.sourceKey);
50658
+ if (fileFunctions === void 0) {
50659
+ fileFunctions = [];
50660
+ pendingFunctions.set(resources.sourceKey, fileFunctions);
50661
+ }
50662
+ for (const [functionId, rawEntry] of Object.entries(resources.coverageMap.functionMap)) {
50663
+ const entry = functionEntrySchema(rawEntry);
50664
+ if (entry instanceof type.errors) continue;
50665
+ fileFunctions.push({
50666
+ name: entry.name,
50667
+ hitCount: fileCoverage.f?.[functionId] ?? 0,
50668
+ loc: {
50669
+ end: {
50670
+ column: toIstanbulColumn(entry.location.end.column),
50671
+ line: entry.location.end.line
50672
+ },
50673
+ start: {
50674
+ column: toIstanbulColumn(entry.location.start.column),
50675
+ line: entry.location.start.line
50676
+ }
50677
+ }
50678
+ });
50679
+ }
50680
+ }
50681
+ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
50682
+ if (resources.coverageMap.branchMap === void 0) return;
50683
+ let fileBranches = pendingBranches.get(resources.sourceKey);
50684
+ if (fileBranches === void 0) {
50685
+ fileBranches = [];
50686
+ pendingBranches.set(resources.sourceKey, fileBranches);
50687
+ }
50688
+ for (const [branchId, rawEntry] of Object.entries(resources.coverageMap.branchMap)) {
50689
+ const entry = branchEntrySchema(rawEntry);
50690
+ if (entry instanceof type.errors) continue;
50691
+ const armHitCounts = fileCoverage.b?.[branchId] ?? [];
50692
+ const locations = [];
50693
+ for (const rawLocation of entry.locations) {
50694
+ const location = spanSchema(rawLocation);
50695
+ if (location instanceof type.errors) continue;
50696
+ locations.push({
50697
+ end: {
50698
+ column: toIstanbulColumn(location.end.column),
50699
+ line: location.end.line
50700
+ },
50701
+ start: {
50702
+ column: toIstanbulColumn(location.start.column),
50703
+ line: location.start.line
50704
+ }
50705
+ });
50706
+ }
50707
+ if (locations.length === 0) continue;
50708
+ const firstLocation = locations[0];
50709
+ const lastLocation = locations[locations.length - 1];
50710
+ (0, node_assert.default)(firstLocation !== void 0 && lastLocation !== void 0, "Branch locations must not be empty after filtering");
50711
+ fileBranches.push({
50712
+ armHitCounts: entry.locations.map((_, index) => armHitCounts[index] ?? 0),
50713
+ loc: {
50714
+ end: lastLocation.end,
50715
+ start: firstLocation.start
50716
+ },
50717
+ locations,
50718
+ type: entry.type
50719
+ });
50720
+ }
50721
+ }
50476
50722
  function mapStatement(traceMap, span) {
50477
50723
  const mappedStart = originalPositionFor(traceMap, {
50478
50724
  column: Math.max(0, span.start.column - 1),
@@ -53219,26 +53465,7 @@ const rojoProjectSchema = type({
53219
53465
  "tree": "object"
53220
53466
  }).as();
53221
53467
  //#endregion
53222
- //#region src/types/luau-ast.ts
53223
- const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
53224
- "assign",
53225
- "break",
53226
- "compoundassign",
53227
- "conditional",
53228
- "continue",
53229
- "do",
53230
- "expression",
53231
- "for",
53232
- "forin",
53233
- "function",
53234
- "local",
53235
- "localfunction",
53236
- "repeat",
53237
- "return",
53238
- "while"
53239
- ]);
53240
- //#endregion
53241
- //#region src/coverage/luau-visitor.ts
53468
+ //#region src/luau/visitor.ts
53242
53469
  function visitExpression(expression, visitor) {
53243
53470
  if (visitor.visitExpr?.(expression) === false) return;
53244
53471
  const { tag } = expression;
@@ -53509,6 +53736,23 @@ function visitExprInstantiate(node, visitor) {
53509
53736
  }
53510
53737
  //#endregion
53511
53738
  //#region src/coverage/coverage-collector.ts
53739
+ const INSTRUMENTABLE_STATEMENT_TAGS = new Set([
53740
+ "assign",
53741
+ "break",
53742
+ "compoundassign",
53743
+ "conditional",
53744
+ "continue",
53745
+ "do",
53746
+ "expression",
53747
+ "for",
53748
+ "forin",
53749
+ "function",
53750
+ "local",
53751
+ "localfunction",
53752
+ "repeat",
53753
+ "return",
53754
+ "while"
53755
+ ]);
53512
53756
  const END_KEYWORD_LENGTH = 3;
53513
53757
  function collectCoverage(root) {
53514
53758
  let statementIndex = 1;
@@ -53748,9 +53992,6 @@ function buildCoverageMap$1(result) {
53748
53992
  };
53749
53993
  }
53750
53994
  //#endregion
53751
- //#region src/coverage/parse-ast.luau
53752
- 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";
53753
- //#endregion
53754
53995
  //#region src/coverage/probe-inserter.ts
53755
53996
  function insertProbes(source, result, fileKey) {
53756
53997
  const lines = splitLines(source);
@@ -53805,7 +54046,7 @@ function applyProbes(mutableLines, probes) {
53805
54046
  (0, node_assert.default)(line !== void 0, `Invalid probe line number: ${probeLine}`);
53806
54047
  const before = line.slice(0, column - 1);
53807
54048
  const after = line.slice(column - 1);
53808
- mutableLines[lineIndex] = before + text + after;
54049
+ mutableLines[lineIndex] = before + (before.length > 0 && !/\s$/.test(before) && /^[a-zA-Z_]/.test(text) ? " " : "") + text + after;
53809
54050
  }
53810
54051
  }
53811
54052
  function extractModeDirective(lines) {
@@ -60302,9 +60543,13 @@ function buildSourceMapper(config, tsconfigMappings) {
60302
60543
  try {
60303
60544
  const rojoResult = rojoProjectSchema(JSON.parse(node_fs.readFileSync(rojoProjectPath, "utf-8")));
60304
60545
  if (rojoResult instanceof type.errors) return;
60546
+ const resolvedTree = resolveNestedProjects(rojoResult.tree, node_path.dirname(rojoProjectPath));
60305
60547
  return createSourceMapper({
60306
60548
  mappings: tsconfigMappings,
60307
- rojoProject: rojoResult
60549
+ rojoProject: {
60550
+ ...rojoResult,
60551
+ tree: resolvedTree
60552
+ }
60308
60553
  });
60309
60554
  } catch {
60310
60555
  return;
@@ -60382,9 +60627,13 @@ function writeSnapshots(snapshotWrites, config, tsconfigMappings) {
60382
60627
  node_process.default.stderr.write("Warning: Cannot write snapshots - invalid rojo project\n");
60383
60628
  return;
60384
60629
  }
60630
+ const resolvedTree = resolveNestedProjects(rojoResult.tree, node_path.dirname(rojoProjectPath));
60385
60631
  const resolver = createSnapshotPathResolver({
60386
60632
  mappings: tsconfigMappings,
60387
- rojoProject: rojoResult
60633
+ rojoProject: {
60634
+ ...rojoResult,
60635
+ tree: resolvedTree
60636
+ }
60388
60637
  });
60389
60638
  let written = 0;
60390
60639
  for (const [virtualPath, content] of Object.entries(snapshotWrites)) {
@@ -60886,6 +61135,7 @@ Options:
60886
61135
  --gameOutput <path> Write game output (print/warn/error) to file
60887
61136
  --sourceMap Map Luau stack traces to TypeScript source
60888
61137
  --rojoProject <path> Path to rojo project file (auto-detected if not set)
61138
+ --passWithNoTests Exit with 0 when no test files are found
60889
61139
  --verbose Show individual test results
60890
61140
  --silent Suppress output
60891
61141
  --no-color Disable colored output
@@ -60947,6 +61197,7 @@ function parseArgs(args) {
60947
61197
  "no-color": { type: "boolean" },
60948
61198
  "no-show-luau": { type: "boolean" },
60949
61199
  "outputFile": { type: "string" },
61200
+ "passWithNoTests": { type: "boolean" },
60950
61201
  "pollInterval": { type: "string" },
60951
61202
  "port": { type: "string" },
60952
61203
  "project": {
@@ -61002,6 +61253,7 @@ function parseArgs(args) {
61002
61253
  gameOutput: values.gameOutput,
61003
61254
  help: values.help,
61004
61255
  outputFile: values.outputFile,
61256
+ passWithNoTests: values.passWithNoTests,
61005
61257
  pollInterval,
61006
61258
  port,
61007
61259
  project: values.project,
@@ -61443,6 +61695,7 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
61443
61695
  tsconfig: rootConfig.typecheckTsconfig
61444
61696
  }) : void 0;
61445
61697
  if (projectResults.length === 0 && typecheckResult === void 0) {
61698
+ if (rootConfig.passWithNoTests) return 0;
61446
61699
  console.error("No test files found in any project");
61447
61700
  return 2;
61448
61701
  }
@@ -61471,12 +61724,14 @@ async function runSingleProject(config, cliFiles) {
61471
61724
  resolveSetupFilePaths(config);
61472
61725
  const discovery = discoverTestFiles(config, cliFiles);
61473
61726
  if (discovery.files.length === 0) {
61727
+ if (config.passWithNoTests) return 0;
61474
61728
  console.error("No test files found");
61475
61729
  return 2;
61476
61730
  }
61477
61731
  const typeTestFiles = config.typecheck ? discovery.files.filter((file) => TYPE_TEST_PATTERN.test(file)) : [];
61478
61732
  const runtimeTestFiles = config.typecheckOnly ? [] : discovery.files.filter((file) => !TYPE_TEST_PATTERN.test(file));
61479
61733
  if (typeTestFiles.length === 0 && runtimeTestFiles.length === 0) {
61734
+ if (config.passWithNoTests) return 0;
61480
61735
  console.error("No test files found for the selected mode");
61481
61736
  return 2;
61482
61737
  }
@@ -61588,6 +61843,7 @@ function mergeCliWithConfig(cli, config) {
61588
61843
  formatters: resolveFormatters(cli, config),
61589
61844
  gameOutput: cli.gameOutput ?? config.gameOutput,
61590
61845
  outputFile: cli.outputFile ?? config.outputFile,
61846
+ passWithNoTests: cli.passWithNoTests ?? config.passWithNoTests,
61591
61847
  pollInterval: cli.pollInterval ?? config.pollInterval,
61592
61848
  port: cli.port ?? config.port,
61593
61849
  rojoProject: cli.rojoProject ?? config.rojoProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/jest-roblox",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
5
  "keywords": [
6
6
  "jest",
@@ -60,7 +60,7 @@
60
60
  "@isentinel/tsconfig": "1.2.0",
61
61
  "@oxc-project/types": "0.120.0",
62
62
  "@rbxts/jest": "3.13.3-ts.1",
63
- "@rbxts/types": "1.0.911",
63
+ "@rbxts/types": "1.0.912",
64
64
  "@total-typescript/shoehorn": "0.1.2",
65
65
  "@tsdown/exe": "0.21.4",
66
66
  "@types/istanbul-lib-coverage": "2.0.6",