@isentinel/jest-roblox 0.1.4 → 0.2.0
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/bin/jest-roblox.js +3 -2
- package/dist/cli.mjs +236 -51
- package/dist/{game-output-BL7u7qMT.mjs → game-output-C0KykXIi.mjs} +73 -59
- package/dist/index.mjs +1 -1
- package/dist/sea/jest-roblox +0 -0
- package/dist/sea-entry.cjs +18361 -17955
- package/loaders/luau-raw.d.mts +34 -0
- package/loaders/luau-raw.mjs +33 -0
- package/package.json +14 -13
package/bin/jest-roblox.js
CHANGED
|
@@ -5,9 +5,10 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
|
|
6
6
|
const sourceEntry = resolve(dirname(fileURLToPath(import.meta.url)), "../src/cli.ts");
|
|
7
7
|
|
|
8
|
+
const { register } = await import("node:module");
|
|
9
|
+
register("../loaders/luau-raw.mjs", import.meta.url);
|
|
10
|
+
|
|
8
11
|
if (existsSync(sourceEntry)) {
|
|
9
|
-
const { register } = await import("node:module");
|
|
10
|
-
register("../loaders/luau-raw.mjs", import.meta.url);
|
|
11
12
|
const { main } = await import("../src/cli.ts");
|
|
12
13
|
main();
|
|
13
14
|
} else {
|
package/dist/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as createOpenCloudBackend, B as LuauScriptError, C as formatTypecheckSummary, F as ROOT_ONLY_KEYS, I as VALID_BACKENDS, O as createStudioBackend, T as loadConfig$1, _ as writeJsonFile, a as formatAnnotations, b as formatMultiProjectResult, c as execute, d as resolveTsconfigDirectories, f as collectPaths, h as findFormatterOptions, i as runTypecheck, j as hashBuffer, l as formatExecuteOutput, m as rojoProjectSchema, n as parseGameOutput, o as formatJobSummary, p as resolveNestedProjects, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, u as loadCoverageManifest, v as formatAgentMultiProject, w as formatBanner, x as formatResult, z as isValidBackend } from "./game-output-C0KykXIi.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 { getTsconfig } from "get-tsconfig";
|
|
18
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
17
19
|
import * as cp from "node:child_process";
|
|
18
20
|
import { RojoResolver } from "@roblox-ts/rojo-resolver";
|
|
19
|
-
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
20
|
-
import { getTsconfig } from "get-tsconfig";
|
|
21
21
|
import picomatch from "picomatch";
|
|
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.
|
|
26
|
+
var version = "0.2.0";
|
|
27
27
|
//#endregion
|
|
28
28
|
//#region src/backends/auto.ts
|
|
29
29
|
var StudioWithFallback = class {
|
|
@@ -156,7 +156,7 @@ function evalTable(entries) {
|
|
|
156
156
|
}
|
|
157
157
|
//#endregion
|
|
158
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
|
|
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
160
|
//#endregion
|
|
161
161
|
//#region src/config/luau-config-loader.ts
|
|
162
162
|
let cachedTemporaryDirectory$1;
|
|
@@ -326,6 +326,7 @@ async function loadProjectConfigFile(filePath, cwd) {
|
|
|
326
326
|
}
|
|
327
327
|
const { config } = result;
|
|
328
328
|
if ((typeof config.displayName === "string" ? config.displayName : config.displayName.name) === "") throw new Error(`Project config file "${filePath}" must have a displayName`);
|
|
329
|
+
deriveIncludeFromTestMatch(config, path$1.posix.dirname(filePath), resolveTsconfigDirectories(cwd));
|
|
329
330
|
return config;
|
|
330
331
|
}
|
|
331
332
|
async function resolveAllProjects(entries, rootConfig, rojoTree, cwd) {
|
|
@@ -348,6 +349,25 @@ function mergeProjectConfig(rootConfig, project) {
|
|
|
348
349
|
for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
|
|
349
350
|
return merged;
|
|
350
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* When a project config provides `testMatch` but not `include`, derive
|
|
354
|
+
* `include` by appending `.ts` and `.tsx` extensions. This lets users
|
|
355
|
+
* write project configs with the standard Jest `testMatch` field without
|
|
356
|
+
* needing the CLI-specific `include`.
|
|
357
|
+
*/
|
|
358
|
+
function deriveIncludeFromTestMatch(config, configDirectory, tsconfig) {
|
|
359
|
+
const raw = config;
|
|
360
|
+
if (raw["include"] !== void 0) return;
|
|
361
|
+
if (!Array.isArray(raw["testMatch"])) return;
|
|
362
|
+
config.include = raw["testMatch"].flatMap((pattern) => {
|
|
363
|
+
return (/\.(tsx?|luau?)$/.test(pattern) ? [pattern] : [`${pattern}.ts`, `${pattern}.tsx`]).map((extension) => path$1.posix.join(configDirectory, extension));
|
|
364
|
+
});
|
|
365
|
+
const { outDir, rootDir } = tsconfig;
|
|
366
|
+
if (raw["outDir"] === void 0 && rootDir !== void 0 && outDir !== void 0) {
|
|
367
|
+
const rootPrefix = `${rootDir}/`;
|
|
368
|
+
if (configDirectory.startsWith(rootPrefix)) config.outDir = `${outDir}/${configDirectory.slice(rootPrefix.length)}`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
351
371
|
const LUAU_BOOLEAN_KEYS = [
|
|
352
372
|
"automock",
|
|
353
373
|
"clearMocks",
|
|
@@ -527,6 +547,28 @@ function findShadowStubs(directory) {
|
|
|
527
547
|
return results;
|
|
528
548
|
}
|
|
529
549
|
//#endregion
|
|
550
|
+
//#region src/coverage/derive-coverage-from.ts
|
|
551
|
+
/**
|
|
552
|
+
* Derives `collectCoverageFrom` glob patterns from project `include` patterns.
|
|
553
|
+
*
|
|
554
|
+
* Extracts the static root directory from each include pattern and generates
|
|
555
|
+
* coverage globs that match all `.ts` source files within those roots, excluding
|
|
556
|
+
* test files. Returns `undefined` when no roots can be extracted (preserving
|
|
557
|
+
* default all-files behavior).
|
|
558
|
+
*/
|
|
559
|
+
function deriveCoverageFromIncludes(projects) {
|
|
560
|
+
const roots = /* @__PURE__ */ new Set();
|
|
561
|
+
for (const project of projects) for (const pattern of project.include) try {
|
|
562
|
+
const { root } = extractStaticRoot(pattern);
|
|
563
|
+
roots.add(root);
|
|
564
|
+
} catch {}
|
|
565
|
+
if (roots.size === 0) return;
|
|
566
|
+
const patterns = [];
|
|
567
|
+
for (const root of roots) patterns.push(`${root}/**/*.ts`);
|
|
568
|
+
patterns.push("!**/*.spec.ts", "!**/*.test.ts");
|
|
569
|
+
return patterns;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
530
572
|
//#region src/coverage/mapper.ts
|
|
531
573
|
const positionSchema = type({
|
|
532
574
|
column: "number",
|
|
@@ -569,6 +611,7 @@ function mapCoverageToTypeScript(coverageData, manifest) {
|
|
|
569
611
|
} else {
|
|
570
612
|
const mapped = {
|
|
571
613
|
coverageMap: resources.coverageMap,
|
|
614
|
+
sourceMapDirectory: resources.sourceMapDirectory,
|
|
572
615
|
traceMap: resources.traceMap
|
|
573
616
|
};
|
|
574
617
|
mapFileFunctions(mapped, fileCoverage, pendingFunctions, mapFileStatements(mapped, fileCoverage, pendingStatements));
|
|
@@ -590,9 +633,11 @@ function loadFileResources(record) {
|
|
|
590
633
|
try {
|
|
591
634
|
traceMap = new TraceMap(fs$1.readFileSync(record.sourceMapPath, "utf-8"));
|
|
592
635
|
} catch {}
|
|
636
|
+
const sourceMapDirectory = path$1.posix.dirname(record.sourceMapPath);
|
|
593
637
|
return {
|
|
594
638
|
coverageMap: parsed,
|
|
595
639
|
sourceKey: record.key,
|
|
640
|
+
sourceMapDirectory,
|
|
596
641
|
traceMap
|
|
597
642
|
};
|
|
598
643
|
}
|
|
@@ -689,7 +734,19 @@ function passthroughFileBranches(resources, fileCoverage, pendingBranches) {
|
|
|
689
734
|
});
|
|
690
735
|
}
|
|
691
736
|
}
|
|
692
|
-
|
|
737
|
+
/**
|
|
738
|
+
* Resolves a source path from a source map against the source map's directory.
|
|
739
|
+
* Source maps produce paths relative to the .map file (e.g.,
|
|
740
|
+
* `../../../packages/src/file.ts` from `out/packages/src/file.lua.map`).
|
|
741
|
+
* Joining with the map directory normalizes these to cwd-relative paths.
|
|
742
|
+
* Paths that are already cwd-relative (no `..` prefix) pass through unchanged.
|
|
743
|
+
*/
|
|
744
|
+
function resolveSourcePath(source, sourceMapDirectory) {
|
|
745
|
+
const normalized = source.replaceAll("\\", "/");
|
|
746
|
+
if (!normalized.startsWith("..")) return normalized;
|
|
747
|
+
return path$1.posix.normalize(path$1.posix.join(sourceMapDirectory, normalized));
|
|
748
|
+
}
|
|
749
|
+
function mapStatement(traceMap, span, sourceMapDirectory) {
|
|
693
750
|
const mappedStart = originalPositionFor(traceMap, {
|
|
694
751
|
column: Math.max(0, span.start.column - 1),
|
|
695
752
|
line: span.start.line
|
|
@@ -699,16 +756,17 @@ function mapStatement(traceMap, span) {
|
|
|
699
756
|
line: span.end.line
|
|
700
757
|
});
|
|
701
758
|
if (mappedStart.source === null || mappedEnd.source === null || mappedStart.source !== mappedEnd.source) return;
|
|
759
|
+
const resolvedSource = resolveSourcePath(mappedStart.source, sourceMapDirectory);
|
|
702
760
|
return {
|
|
703
761
|
end: {
|
|
704
762
|
column: mappedEnd.column,
|
|
705
763
|
line: mappedEnd.line,
|
|
706
|
-
source:
|
|
764
|
+
source: resolvedSource
|
|
707
765
|
},
|
|
708
766
|
start: {
|
|
709
767
|
column: mappedStart.column,
|
|
710
768
|
line: mappedStart.line,
|
|
711
|
-
source:
|
|
769
|
+
source: resolvedSource
|
|
712
770
|
}
|
|
713
771
|
};
|
|
714
772
|
}
|
|
@@ -750,7 +808,7 @@ function mapFileStatements(resources, fileCoverage, pending) {
|
|
|
750
808
|
const span = spanSchema(rawSpan);
|
|
751
809
|
if (span instanceof type.errors) continue;
|
|
752
810
|
const hitCount = fileCoverage.s[statementId] ?? 0;
|
|
753
|
-
const mapped = mapStatement(resources.traceMap, span);
|
|
811
|
+
const mapped = mapStatement(resources.traceMap, span, resources.sourceMapDirectory);
|
|
754
812
|
if (mapped === void 0) continue;
|
|
755
813
|
resolvedTsPaths.add(mapped.start.source);
|
|
756
814
|
addOrCoalesce(pending, mapped.start, mapped.end, hitCount);
|
|
@@ -763,7 +821,7 @@ function mapFileFunctions(resources, fileCoverage, pendingFunctions, resolvedTsP
|
|
|
763
821
|
const entry = functionEntrySchema(rawEntry);
|
|
764
822
|
if (entry instanceof type.errors) continue;
|
|
765
823
|
const hitCount = fileCoverage.f?.[functionId] ?? 0;
|
|
766
|
-
const mapped = mapStatement(resources.traceMap, entry.location);
|
|
824
|
+
const mapped = mapStatement(resources.traceMap, entry.location, resources.sourceMapDirectory);
|
|
767
825
|
if (mapped !== void 0) {
|
|
768
826
|
const tsPath = mapped.start.source;
|
|
769
827
|
let fileFunctions = pendingFunctions.get(tsPath);
|
|
@@ -810,13 +868,13 @@ function mapFileFunctions(resources, fileCoverage, pendingFunctions, resolvedTsP
|
|
|
810
868
|
});
|
|
811
869
|
}
|
|
812
870
|
}
|
|
813
|
-
function mapBranchArmLocations(traceMap, rawLocations) {
|
|
871
|
+
function mapBranchArmLocations(traceMap, rawLocations, sourceMapDirectory) {
|
|
814
872
|
const mappedLocations = [];
|
|
815
873
|
let tsPath;
|
|
816
874
|
for (const rawLocation of rawLocations) {
|
|
817
875
|
const location = spanSchema(rawLocation);
|
|
818
876
|
if (location instanceof type.errors) return;
|
|
819
|
-
const mapped = mapStatement(traceMap, location);
|
|
877
|
+
const mapped = mapStatement(traceMap, location, sourceMapDirectory);
|
|
820
878
|
if (mapped === void 0) return;
|
|
821
879
|
if (tsPath === void 0) tsPath = mapped.start.source;
|
|
822
880
|
else if (tsPath !== mapped.start.source) return;
|
|
@@ -843,7 +901,7 @@ function mapFileBranches(resources, fileCoverage, pendingBranches) {
|
|
|
843
901
|
const entry = branchEntrySchema(rawEntry);
|
|
844
902
|
if (entry instanceof type.errors) continue;
|
|
845
903
|
const armHitCounts = fileCoverage.b?.[branchId] ?? [];
|
|
846
|
-
const result = mapBranchArmLocations(resources.traceMap, entry.locations);
|
|
904
|
+
const result = mapBranchArmLocations(resources.traceMap, entry.locations, resources.sourceMapDirectory);
|
|
847
905
|
if (result === void 0) continue;
|
|
848
906
|
let fileBranches = pendingBranches.get(result.tsPath);
|
|
849
907
|
if (fileBranches === void 0) {
|
|
@@ -1386,17 +1444,17 @@ function collectCoverage(root) {
|
|
|
1386
1444
|
bodyFirstColumn: 0,
|
|
1387
1445
|
bodyFirstLine: 0,
|
|
1388
1446
|
location: {
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1447
|
+
beginColumn: node.location.beginColumn,
|
|
1448
|
+
beginLine: node.location.beginLine,
|
|
1449
|
+
endColumn: node.location.beginColumn,
|
|
1450
|
+
endLine: node.location.beginLine
|
|
1393
1451
|
}
|
|
1394
1452
|
});
|
|
1395
1453
|
implicitElseProbes.push({
|
|
1396
1454
|
armIndex: branch.arms.length,
|
|
1397
1455
|
branchIndex,
|
|
1398
|
-
endColumn: node.location.
|
|
1399
|
-
endLine: node.location.
|
|
1456
|
+
endColumn: node.location.endColumn - END_KEYWORD_LENGTH,
|
|
1457
|
+
endLine: node.location.endLine
|
|
1400
1458
|
});
|
|
1401
1459
|
}
|
|
1402
1460
|
branches.push(branch);
|
|
@@ -1429,12 +1487,12 @@ function collectCoverage(root) {
|
|
|
1429
1487
|
function getBodyFirstStatement(block) {
|
|
1430
1488
|
const first = block.statements[0];
|
|
1431
1489
|
if (first !== void 0) return {
|
|
1432
|
-
column: first.location.
|
|
1433
|
-
line: first.location.
|
|
1490
|
+
column: first.location.beginColumn,
|
|
1491
|
+
line: first.location.beginLine
|
|
1434
1492
|
};
|
|
1435
1493
|
return {
|
|
1436
|
-
column: block.location.
|
|
1437
|
-
line: block.location.
|
|
1494
|
+
column: block.location.beginColumn,
|
|
1495
|
+
line: block.location.beginLine
|
|
1438
1496
|
};
|
|
1439
1497
|
}
|
|
1440
1498
|
function extractExprName(expr) {
|
|
@@ -1451,12 +1509,12 @@ function buildCoverageMap$1(result) {
|
|
|
1451
1509
|
const statementMap = {};
|
|
1452
1510
|
for (const statement of result.statements) statementMap[String(statement.index)] = {
|
|
1453
1511
|
end: {
|
|
1454
|
-
column: statement.location.
|
|
1455
|
-
line: statement.location.
|
|
1512
|
+
column: statement.location.endColumn,
|
|
1513
|
+
line: statement.location.endLine
|
|
1456
1514
|
},
|
|
1457
1515
|
start: {
|
|
1458
|
-
column: statement.location.
|
|
1459
|
-
line: statement.location.
|
|
1516
|
+
column: statement.location.beginColumn,
|
|
1517
|
+
line: statement.location.beginLine
|
|
1460
1518
|
}
|
|
1461
1519
|
};
|
|
1462
1520
|
const functionMap = {};
|
|
@@ -1464,12 +1522,12 @@ function buildCoverageMap$1(result) {
|
|
|
1464
1522
|
name: func.name,
|
|
1465
1523
|
location: {
|
|
1466
1524
|
end: {
|
|
1467
|
-
column: func.location.
|
|
1468
|
-
line: func.location.
|
|
1525
|
+
column: func.location.endColumn,
|
|
1526
|
+
line: func.location.endLine
|
|
1469
1527
|
},
|
|
1470
1528
|
start: {
|
|
1471
|
-
column: func.location.
|
|
1472
|
-
line: func.location.
|
|
1529
|
+
column: func.location.beginColumn,
|
|
1530
|
+
line: func.location.beginLine
|
|
1473
1531
|
}
|
|
1474
1532
|
}
|
|
1475
1533
|
};
|
|
@@ -1478,12 +1536,12 @@ function buildCoverageMap$1(result) {
|
|
|
1478
1536
|
locations: branch.arms.map((arm) => {
|
|
1479
1537
|
return {
|
|
1480
1538
|
end: {
|
|
1481
|
-
column: arm.location.
|
|
1482
|
-
line: arm.location.
|
|
1539
|
+
column: arm.location.endColumn,
|
|
1540
|
+
line: arm.location.endLine
|
|
1483
1541
|
},
|
|
1484
1542
|
start: {
|
|
1485
|
-
column: arm.location.
|
|
1486
|
-
line: arm.location.
|
|
1543
|
+
column: arm.location.beginColumn,
|
|
1544
|
+
line: arm.location.beginLine
|
|
1487
1545
|
}
|
|
1488
1546
|
};
|
|
1489
1547
|
}),
|
|
@@ -1505,8 +1563,8 @@ function insertProbes(source, result, fileKey) {
|
|
|
1505
1563
|
function collectProbes(result) {
|
|
1506
1564
|
const probes = [];
|
|
1507
1565
|
for (const stmt of result.statements) probes.push({
|
|
1508
|
-
column: stmt.location.
|
|
1509
|
-
line: stmt.location.
|
|
1566
|
+
column: stmt.location.beginColumn,
|
|
1567
|
+
line: stmt.location.beginLine,
|
|
1510
1568
|
text: `__cov_s[${stmt.index}] += 1; `
|
|
1511
1569
|
});
|
|
1512
1570
|
for (const func of result.functions) if (func.bodyFirstLine > 0) probes.push({
|
|
@@ -1528,12 +1586,12 @@ function collectProbes(result) {
|
|
|
1528
1586
|
text: `else __cov_b[${probe.branchIndex}][${probe.armIndex}] += 1 `
|
|
1529
1587
|
});
|
|
1530
1588
|
for (const probe of result.exprIfProbes) probes.push({
|
|
1531
|
-
column: probe.exprLocation.
|
|
1532
|
-
line: probe.exprLocation.
|
|
1589
|
+
column: probe.exprLocation.beginColumn,
|
|
1590
|
+
line: probe.exprLocation.beginLine,
|
|
1533
1591
|
text: `__cov_br(${probe.branchIndex}, ${probe.armIndex}, `
|
|
1534
1592
|
}, {
|
|
1535
|
-
column: probe.exprLocation.
|
|
1536
|
-
line: probe.exprLocation.
|
|
1593
|
+
column: probe.exprLocation.endColumn,
|
|
1594
|
+
line: probe.exprLocation.endLine,
|
|
1537
1595
|
text: ")"
|
|
1538
1596
|
});
|
|
1539
1597
|
probes.sort((a, b) => {
|
|
@@ -1753,10 +1811,30 @@ function walkTree(node, context) {
|
|
|
1753
1811
|
//#endregion
|
|
1754
1812
|
//#region src/coverage/prepare.ts
|
|
1755
1813
|
const COVERAGE_DIR = ".jest-roblox-coverage";
|
|
1814
|
+
/**
|
|
1815
|
+
* Suffixes for files that are not instrumented for coverage but still need
|
|
1816
|
+
* syncing to the shadow directory. Matches parse-ast.luau:131-139.
|
|
1817
|
+
*/
|
|
1818
|
+
const NON_INSTRUMENTED_SUFFIXES = [
|
|
1819
|
+
".spec.luau",
|
|
1820
|
+
".test.luau",
|
|
1821
|
+
".spec.lua",
|
|
1822
|
+
".test.lua",
|
|
1823
|
+
".snap.luau",
|
|
1824
|
+
".snap.lua"
|
|
1825
|
+
];
|
|
1826
|
+
function isNonInstrumentedFile(filename) {
|
|
1827
|
+
return NON_INSTRUMENTED_SUFFIXES.some((suffix) => filename.endsWith(suffix));
|
|
1828
|
+
}
|
|
1756
1829
|
const previousManifestSchema = type({
|
|
1757
1830
|
"files": type({ "[string]": { sourceHash: "string" } }),
|
|
1758
1831
|
"instrumenterVersion": "number",
|
|
1759
1832
|
"luauRoots": "string[]",
|
|
1833
|
+
"nonInstrumentedFiles?": type({ "[string]": {
|
|
1834
|
+
shadowPath: "string",
|
|
1835
|
+
sourceHash: "string",
|
|
1836
|
+
sourcePath: "string"
|
|
1837
|
+
} }),
|
|
1760
1838
|
"placeFilePath?": "string",
|
|
1761
1839
|
"shadowDir": "string",
|
|
1762
1840
|
"version": "number"
|
|
@@ -1782,12 +1860,14 @@ function prepareCoverage(config, beforeBuild) {
|
|
|
1782
1860
|
const useIncremental = canUseIncremental(previousManifest, config);
|
|
1783
1861
|
if (!useIncremental && fs$1.existsSync(COVERAGE_DIR)) fs$1.rmSync(COVERAGE_DIR, { recursive: true });
|
|
1784
1862
|
const allFiles = {};
|
|
1863
|
+
const allNonInstrumented = {};
|
|
1785
1864
|
const roots = [];
|
|
1786
1865
|
let hasChanges = !useIncremental;
|
|
1787
1866
|
for (const luauRoot of luauRoots) {
|
|
1788
1867
|
const rootResult = instrumentRootWithCache(luauRoot, useIncremental, previousManifest);
|
|
1789
1868
|
if (rootResult.changed) hasChanges = true;
|
|
1790
1869
|
Object.assign(allFiles, rootResult.files);
|
|
1870
|
+
Object.assign(allNonInstrumented, rootResult.nonInstrumentedFiles);
|
|
1791
1871
|
roots.push(rootResult.rootEntry);
|
|
1792
1872
|
}
|
|
1793
1873
|
if (useIncremental && previousManifest !== void 0) {
|
|
@@ -1799,7 +1879,13 @@ function prepareCoverage(config, beforeBuild) {
|
|
|
1799
1879
|
if (beforeBuild(COVERAGE_DIR)) hasChanges = true;
|
|
1800
1880
|
}
|
|
1801
1881
|
const placeFile = path$1.join(COVERAGE_DIR, "game.rbxl");
|
|
1802
|
-
const manifest = writeManifest(
|
|
1882
|
+
const manifest = writeManifest({
|
|
1883
|
+
allFiles,
|
|
1884
|
+
luauRoots,
|
|
1885
|
+
manifestPath,
|
|
1886
|
+
nonInstrumentedFiles: allNonInstrumented,
|
|
1887
|
+
placeFile
|
|
1888
|
+
});
|
|
1803
1889
|
if (!hasChanges && previousManifest?.placeFilePath !== void 0) return {
|
|
1804
1890
|
manifest,
|
|
1805
1891
|
placeFile: previousManifest.placeFilePath
|
|
@@ -1866,6 +1952,64 @@ function carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles) {
|
|
|
1866
1952
|
Object.assign(allFiles, { [fileKey]: previousManifest.files[fileKey] });
|
|
1867
1953
|
}
|
|
1868
1954
|
}
|
|
1955
|
+
function discoverNonInstrumentedFiles(directory, relativeTo, results) {
|
|
1956
|
+
const entries = fs$1.readdirSync(directory, { withFileTypes: true });
|
|
1957
|
+
for (const entry of entries) {
|
|
1958
|
+
const fullPath = path$1.join(directory, entry.name).replaceAll("\\", "/");
|
|
1959
|
+
if (entry.isDirectory()) {
|
|
1960
|
+
if (entry.name === "node_modules" || entry.name === COVERAGE_DIR) continue;
|
|
1961
|
+
if (entry.name.startsWith(".")) continue;
|
|
1962
|
+
discoverNonInstrumentedFiles(fullPath, relativeTo, results);
|
|
1963
|
+
} else if (isNonInstrumentedFile(entry.name)) {
|
|
1964
|
+
const relative = fullPath.slice(relativeTo.length + 1);
|
|
1965
|
+
results.push(relative);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
function pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, currentFiles) {
|
|
1970
|
+
if (previousNonInstrumented === void 0) return false;
|
|
1971
|
+
let changed = false;
|
|
1972
|
+
for (const [fileKey, record] of Object.entries(previousNonInstrumented)) {
|
|
1973
|
+
if (!fileKey.startsWith(`${posixRoot}/`)) continue;
|
|
1974
|
+
if (fileKey in currentFiles) continue;
|
|
1975
|
+
try {
|
|
1976
|
+
if (fs$1.existsSync(record.shadowPath)) fs$1.unlinkSync(record.shadowPath);
|
|
1977
|
+
} catch {}
|
|
1978
|
+
changed = true;
|
|
1979
|
+
}
|
|
1980
|
+
return changed;
|
|
1981
|
+
}
|
|
1982
|
+
function syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousNonInstrumented) {
|
|
1983
|
+
const posixRoot = luauRoot.replaceAll("\\", "/");
|
|
1984
|
+
const discovered = [];
|
|
1985
|
+
discoverNonInstrumentedFiles(posixRoot, posixRoot, discovered);
|
|
1986
|
+
const files = {};
|
|
1987
|
+
let changed = false;
|
|
1988
|
+
for (const relativePath of discovered) {
|
|
1989
|
+
const sourcePath = `${posixRoot}/${relativePath}`;
|
|
1990
|
+
const shadowPath = `${shadowDirectory}/${relativePath}`;
|
|
1991
|
+
const currentHash = hashBuffer(fs$1.readFileSync(path$1.resolve(sourcePath)));
|
|
1992
|
+
const previousRecord = previousNonInstrumented?.[sourcePath];
|
|
1993
|
+
if (previousRecord?.sourceHash === currentHash) {
|
|
1994
|
+
files[sourcePath] = previousRecord;
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
const outputDirectory = path$1.dirname(shadowPath);
|
|
1998
|
+
fs$1.mkdirSync(outputDirectory, { recursive: true });
|
|
1999
|
+
fs$1.copyFileSync(path$1.resolve(sourcePath), shadowPath);
|
|
2000
|
+
files[sourcePath] = {
|
|
2001
|
+
shadowPath,
|
|
2002
|
+
sourceHash: currentHash,
|
|
2003
|
+
sourcePath
|
|
2004
|
+
};
|
|
2005
|
+
changed = true;
|
|
2006
|
+
}
|
|
2007
|
+
changed = pruneStaleNonInstrumented(posixRoot, previousNonInstrumented, files) || changed;
|
|
2008
|
+
return {
|
|
2009
|
+
changed,
|
|
2010
|
+
files
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
1869
2013
|
function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
|
|
1870
2014
|
const shadowDirectory = path$1.join(COVERAGE_DIR, luauRoot).replaceAll("\\", "/");
|
|
1871
2015
|
let changed = false;
|
|
@@ -1887,10 +2031,13 @@ function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
|
|
|
1887
2031
|
if (Object.keys(files).length > 0) changed = true;
|
|
1888
2032
|
const allFiles = { ...files };
|
|
1889
2033
|
if (useIncremental && previousManifest !== void 0 && skipFiles !== void 0) carryForwardRecords(luauRoot, previousManifest, allFiles, skipFiles);
|
|
2034
|
+
const syncResult = syncNonInstrumentedFiles(luauRoot, shadowDirectory, previousManifest?.nonInstrumentedFiles);
|
|
2035
|
+
if (syncResult.changed) changed = true;
|
|
1890
2036
|
const relocatedShadowDirectory = path$1.relative(COVERAGE_DIR, shadowDirectory).replaceAll("\\", "/");
|
|
1891
2037
|
return {
|
|
1892
2038
|
changed,
|
|
1893
2039
|
files: allFiles,
|
|
2040
|
+
nonInstrumentedFiles: syncResult.files,
|
|
1894
2041
|
rootEntry: {
|
|
1895
2042
|
luauRoot,
|
|
1896
2043
|
relocatedShadowDirectory,
|
|
@@ -1898,12 +2045,14 @@ function instrumentRootWithCache(luauRoot, useIncremental, previousManifest) {
|
|
|
1898
2045
|
}
|
|
1899
2046
|
};
|
|
1900
2047
|
}
|
|
1901
|
-
function writeManifest(
|
|
2048
|
+
function writeManifest(options) {
|
|
2049
|
+
const { allFiles, luauRoots, manifestPath, nonInstrumentedFiles, placeFile } = options;
|
|
1902
2050
|
const manifest = {
|
|
1903
2051
|
files: allFiles,
|
|
1904
2052
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1905
2053
|
instrumenterVersion: 2,
|
|
1906
2054
|
luauRoots,
|
|
2055
|
+
nonInstrumentedFiles,
|
|
1907
2056
|
placeFilePath: placeFile,
|
|
1908
2057
|
shadowDir: COVERAGE_DIR,
|
|
1909
2058
|
version: 1
|
|
@@ -1941,6 +2090,7 @@ function canUseIncremental(previousManifest, config) {
|
|
|
1941
2090
|
if (!config.cache) return false;
|
|
1942
2091
|
if (previousManifest === void 0) return false;
|
|
1943
2092
|
if (previousManifest.instrumenterVersion !== 2) return false;
|
|
2093
|
+
if (previousManifest.nonInstrumentedFiles === void 0) return false;
|
|
1944
2094
|
return true;
|
|
1945
2095
|
}
|
|
1946
2096
|
function detectDeletedFiles(previousManifest, currentFiles) {
|
|
@@ -1975,15 +2125,31 @@ function printCoverageHeader() {
|
|
|
1975
2125
|
const header = ` ${color.blue("%")} ${color.dim("Coverage report from")} ${color.yellow("istanbul")}`;
|
|
1976
2126
|
process.stdout.write(`\n${header}\n`);
|
|
1977
2127
|
}
|
|
2128
|
+
const TEXT_REPORTERS = new Set(["text", "text-summary"]);
|
|
1978
2129
|
function generateReports(options) {
|
|
1979
2130
|
const coverageMap = buildCoverageMap(filterMappedFiles(options.mapped, options.collectCoverageFrom));
|
|
1980
2131
|
const context = istanbulReport.createContext({
|
|
1981
2132
|
coverageMap,
|
|
2133
|
+
defaultSummarizer: options.agentMode === true ? "flat" : "pkg",
|
|
1982
2134
|
dir: options.coverageDirectory
|
|
1983
2135
|
});
|
|
2136
|
+
const terminalColumns = getTerminalColumns();
|
|
2137
|
+
const allFilesFull = options.agentMode === true && isAllFilesFull(coverageMap);
|
|
1984
2138
|
for (const reporterName of options.reporters) {
|
|
1985
2139
|
if (!isValidReporter(reporterName)) throw new Error(`Unknown coverage reporter: ${reporterName}`);
|
|
1986
|
-
|
|
2140
|
+
if (allFilesFull && TEXT_REPORTERS.has(reporterName)) {
|
|
2141
|
+
const fileCount = coverageMap.files().length;
|
|
2142
|
+
const label = fileCount === 1 ? "file" : "files";
|
|
2143
|
+
process.stdout.write(`Coverage: 100% (${fileCount} ${label})\n`);
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
let reporterOptions = {};
|
|
2147
|
+
if (reporterName === "text") reporterOptions = {
|
|
2148
|
+
maxCols: terminalColumns,
|
|
2149
|
+
skipFull: options.agentMode === true
|
|
2150
|
+
};
|
|
2151
|
+
else if (TEXT_REPORTERS.has(reporterName)) reporterOptions = { skipFull: options.agentMode === true };
|
|
2152
|
+
istanbulReports.create(reporterName, reporterOptions).execute(context);
|
|
1987
2153
|
}
|
|
1988
2154
|
}
|
|
1989
2155
|
function checkThresholds(mapped, thresholds, collectCoverageFrom) {
|
|
@@ -2024,6 +2190,21 @@ function checkThresholds(mapped, thresholds, collectCoverageFrom) {
|
|
|
2024
2190
|
passed: failures.length === 0
|
|
2025
2191
|
};
|
|
2026
2192
|
}
|
|
2193
|
+
function getTerminalColumns() {
|
|
2194
|
+
if (process.stdout.columns !== void 0) return process.stdout.columns;
|
|
2195
|
+
const columnsEnvironment = process.env["COLUMNS"];
|
|
2196
|
+
if (columnsEnvironment === void 0) return;
|
|
2197
|
+
const parsed = Number(columnsEnvironment);
|
|
2198
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
2199
|
+
}
|
|
2200
|
+
function isAllFilesFull(coverageMap) {
|
|
2201
|
+
const files = coverageMap.files();
|
|
2202
|
+
if (files.length === 0) return false;
|
|
2203
|
+
return files.every((file) => {
|
|
2204
|
+
const summary = coverageMap.fileCoverageFor(file).toSummary();
|
|
2205
|
+
return summary.statements.pct === 100 && summary.branches.pct === 100 && summary.functions.pct === 100 && summary.lines.pct === 100;
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2027
2208
|
function buildCoverageMap(mapped) {
|
|
2028
2209
|
const coverageMap = istanbulCoverage.createCoverageMap({});
|
|
2029
2210
|
for (const [filePath, fileCoverage] of Object.entries(mapped.files)) {
|
|
@@ -2386,6 +2567,12 @@ function printFinalStatus(passed) {
|
|
|
2386
2567
|
const badge = passed ? color.bgGreen(color.black(color.bold(" PASS "))) : color.bgRed(color.white(color.bold(" FAIL ")));
|
|
2387
2568
|
process.stdout.write(`${badge}\n`);
|
|
2388
2569
|
}
|
|
2570
|
+
function hasFormatter(config, name) {
|
|
2571
|
+
return config.formatters?.some((entry) => Array.isArray(entry) ? entry[0] === name : entry === name) === true;
|
|
2572
|
+
}
|
|
2573
|
+
function usesAgentFormatter(config) {
|
|
2574
|
+
return hasFormatter(config, "agent") && !config.verbose;
|
|
2575
|
+
}
|
|
2389
2576
|
function processCoverage(config, coverageData) {
|
|
2390
2577
|
if (!config.collectCoverage) return true;
|
|
2391
2578
|
if (coverageData === void 0) {
|
|
@@ -2401,6 +2588,7 @@ function processCoverage(config, coverageData) {
|
|
|
2401
2588
|
const coverageDirectory = path$1.resolve(config.rootDir, config.coverageDirectory);
|
|
2402
2589
|
if (!config.silent) printCoverageHeader();
|
|
2403
2590
|
generateReports({
|
|
2591
|
+
agentMode: usesAgentFormatter(config),
|
|
2404
2592
|
collectCoverageFrom: config.collectCoverageFrom,
|
|
2405
2593
|
coverageDirectory,
|
|
2406
2594
|
mapped,
|
|
@@ -2434,18 +2622,12 @@ function runGitHubActionsFormatter(config, result, sourceMapper) {
|
|
|
2434
2622
|
}
|
|
2435
2623
|
}
|
|
2436
2624
|
}
|
|
2437
|
-
function hasFormatter(config, name) {
|
|
2438
|
-
return config.formatters?.some((entry) => Array.isArray(entry) ? entry[0] === name : entry === name) === true;
|
|
2439
|
-
}
|
|
2440
2625
|
function getAgentMaxFailures(config) {
|
|
2441
2626
|
assert(config.formatters !== void 0, "formatters is set by resolveFormatters");
|
|
2442
2627
|
const options = findFormatterOptions(config.formatters, "agent");
|
|
2443
2628
|
if (options !== void 0 && typeof options["maxFailures"] === "number") return options["maxFailures"];
|
|
2444
2629
|
return 10;
|
|
2445
2630
|
}
|
|
2446
|
-
function usesAgentFormatter(config) {
|
|
2447
|
-
return hasFormatter(config, "agent") && !config.verbose;
|
|
2448
|
-
}
|
|
2449
2631
|
function usesDefaultFormatter(config) {
|
|
2450
2632
|
return !hasFormatter(config, "json") && !usesAgentFormatter(config);
|
|
2451
2633
|
}
|
|
@@ -2688,7 +2870,10 @@ async function runMultiProject(cli, rootConfig, projectEntries) {
|
|
|
2688
2870
|
return 2;
|
|
2689
2871
|
}
|
|
2690
2872
|
if (projectResults.length === 0) return outputResults(rootConfig, typecheckResult, void 0, preCoverageMs);
|
|
2691
|
-
return outputMultiProjectResults(
|
|
2873
|
+
return outputMultiProjectResults({
|
|
2874
|
+
...rootConfig,
|
|
2875
|
+
collectCoverageFrom: rootConfig.collectCoverageFrom ?? deriveCoverageFromIncludes(projects)
|
|
2876
|
+
}, projectResults, typecheckResult, preCoverageMs);
|
|
2692
2877
|
}
|
|
2693
2878
|
async function executeRuntimeTests(config, testFiles, totalFiles) {
|
|
2694
2879
|
if (!config.silent && !usesAgentFormatter(config) && !hasFormatter(config, "json") && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
|