@isentinel/jest-roblox 0.2.7 → 0.3.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.
@@ -1,3643 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import { type } from "arktype";
3
- import assert from "node:assert";
4
- import * as fs$1 from "node:fs";
5
- import { existsSync, readFileSync } from "node:fs";
6
- import * as path$1 from "node:path";
7
- import path, { dirname, join, relative } from "node:path";
8
- import process from "node:process";
9
- import color from "tinyrainbow";
10
- import { WebSocketServer } from "ws";
11
- import { homedir, tmpdir } from "node:os";
12
- import * as crypto from "node:crypto";
13
- import { randomUUID } from "node:crypto";
14
- import buffer from "node:buffer";
15
- import { createDefineConfig, loadConfig } from "c12";
16
- import { defuFn } from "defu";
17
- import { getTsconfig } from "get-tsconfig";
18
- import { TraceMap, originalPositionFor, sourceContentFor } from "@jridgewell/trace-mapping";
19
- import hljs from "highlight.js/lib/core";
20
- import typescript from "highlight.js/lib/languages/typescript";
21
- import { execFileSync } from "node:child_process";
22
- import { parseJSONC } from "confbox";
23
- import { Visitor, parseSync } from "oxc-parser";
24
- //#region src/reporter/parser.ts
25
- const TASK_SCRIPT_PREFIX = /^TaskScript:\d+:\s*/;
26
- var LuauScriptError = class extends Error {
27
- gameOutput;
28
- constructor(rawMessage) {
29
- super(rawMessage.replace(TASK_SCRIPT_PREFIX, ""));
30
- }
31
- };
32
- const jestResultSchema = type({
33
- numFailedTests: "number",
34
- numPassedTests: "number",
35
- numPendingTests: "number",
36
- numTotalTests: "number",
37
- startTime: "number",
38
- success: "boolean",
39
- testResults: "object[]"
40
- });
41
- function extractJsonFromOutput(output) {
42
- const lines = output.split("\n");
43
- let braceCount = 0;
44
- let collecting = false;
45
- const jsonLines = [];
46
- for (const line of lines) {
47
- if (!collecting && line.trim().startsWith("{")) {
48
- collecting = true;
49
- braceCount = 0;
50
- jsonLines.length = 0;
51
- }
52
- if (!collecting) continue;
53
- jsonLines.push(line);
54
- braceCount += countBraces(line);
55
- if (braceCount !== 0) continue;
56
- const candidate = jsonLines.join("\n").trim();
57
- if (isValidJson(candidate)) return candidate;
58
- collecting = false;
59
- }
60
- }
61
- function parseJestOutput(output) {
62
- const trimmed = output.trim();
63
- if (trimmed.startsWith("{")) try {
64
- return parseParsedOutput(JSON.parse(trimmed));
65
- } catch {}
66
- const jsonString = extractJsonFromOutput(output);
67
- if (jsonString === void 0) throw new Error(`No valid Jest result JSON found in output, output was:\n${output}`);
68
- return parseParsedOutput(JSON.parse(jsonString));
69
- }
70
- function countBraces(line) {
71
- let count = 0;
72
- for (const character of line) {
73
- if (character === "{") count++;
74
- if (character === "}") count--;
75
- }
76
- return count;
77
- }
78
- function isValidJson(text) {
79
- try {
80
- JSON.parse(text);
81
- return true;
82
- } catch {
83
- return false;
84
- }
85
- }
86
- function extractExecutionError(object) {
87
- let current = object;
88
- while (current["parent"] !== void 0 && typeof current["parent"] === "object") current = current["parent"];
89
- return typeof current["error"] === "string" ? current["error"] : "Unknown error";
90
- }
91
- function extractLuauTiming(parsed) {
92
- const timing = parsed["_timing"];
93
- if (timing === void 0 || timing === null || typeof timing !== "object") return;
94
- const record = {};
95
- for (const [key, value] of Object.entries(timing)) if (typeof value === "number") record[key] = value;
96
- return Object.keys(record).length > 0 ? record : void 0;
97
- }
98
- /**
99
- * Luau 1-based integer-keyed tables serialize as JSON arrays.
100
- * Convert arrays to string-keyed Records with 1-based keys to match cov-map format.
101
- */
102
- function normalizeHitCounts(data) {
103
- if (Array.isArray(data)) {
104
- const result = {};
105
- let index = 0;
106
- for (const element of data) {
107
- result[String(index + 1)] = typeof element === "number" ? element : 0;
108
- index++;
109
- }
110
- return result;
111
- }
112
- if (typeof data === "object" && data !== null) return data;
113
- return {};
114
- }
115
- /**
116
- * Normalize branch hit counts from Luau's nested array format.
117
- * Luau serializes `__cov_b` as an array of arrays: `[[0,0,0], [0,0]]`.
118
- * Convert outer array to string-keyed Record with 1-based keys,
119
- * keeping inner arrays as-is.
120
- */
121
- function normalizeBranchCounts(data) {
122
- if (Array.isArray(data)) {
123
- const result = {};
124
- let index = 0;
125
- for (const inner of data) {
126
- if (Array.isArray(inner)) result[String(index + 1)] = inner.map((value) => typeof value === "number" ? value : 0);
127
- else result[String(index + 1)] = [];
128
- index++;
129
- }
130
- return result;
131
- }
132
- if (typeof data === "object" && data !== null) return data;
133
- return {};
134
- }
135
- function extractCoverageData(parsed) {
136
- const coverage = parsed["_coverage"];
137
- if (coverage === void 0 || coverage === null || typeof coverage !== "object") return;
138
- const record = {};
139
- for (const [key, value] of Object.entries(coverage)) if (typeof value === "object" && value !== null && "s" in value) {
140
- const raw = value;
141
- record[key] = {
142
- b: raw.b !== void 0 ? normalizeBranchCounts(raw.b) : void 0,
143
- f: raw.f !== void 0 ? normalizeHitCounts(raw.f) : void 0,
144
- s: normalizeHitCounts(raw.s)
145
- };
146
- }
147
- return Object.keys(record).length > 0 ? record : void 0;
148
- }
149
- function extractSnapshotWrites(parsed) {
150
- const writes = parsed["_snapshotWrites"];
151
- if (writes === void 0 || writes === null || typeof writes !== "object") return;
152
- const record = {};
153
- for (const [key, value] of Object.entries(writes)) if (typeof value === "string") record[key] = value;
154
- return Object.keys(record).length > 0 ? record : void 0;
155
- }
156
- function stringifyError(err) {
157
- if (typeof err === "string") return err;
158
- if (typeof err === "object" && err !== null && "message" in err && typeof err.message === "string") return err.message;
159
- if (typeof err === "object" && err !== null && "kind" in err && err["kind"] === "ExecutionError") return extractExecutionError(err);
160
- const serialized = JSON.stringify(err);
161
- assert(serialized !== void 0, "JSON-parsed values are always serializable");
162
- return serialized;
163
- }
164
- function unwrapResult(parsed) {
165
- if ("err" in parsed && parsed["success"] === false) throw new LuauScriptError(stringifyError(parsed["err"]));
166
- if ("value" in parsed && parsed["success"] === true) return parsed["value"];
167
- return parsed;
168
- }
169
- function validateJestResult(value) {
170
- const result = jestResultSchema(value);
171
- if (result instanceof type.errors) throw new Error(`Invalid Jest result: ${result.summary}`);
172
- return result;
173
- }
174
- function extractSetupSeconds(parsed) {
175
- const setup = parsed["_setup"];
176
- if (typeof setup !== "number") return;
177
- return setup;
178
- }
179
- function parseParsedOutput(parsed) {
180
- const coverageData = extractCoverageData(parsed);
181
- const luauTiming = extractLuauTiming(parsed);
182
- const setupSeconds = extractSetupSeconds(parsed);
183
- const snapshotWrites = extractSnapshotWrites(parsed);
184
- const unwrapped = unwrapResult(parsed);
185
- if (unwrapped["kind"] === "ExecutionError") {
186
- const errorMessage = extractExecutionError(unwrapped);
187
- throw new Error(`Jest execution failed: ${errorMessage}`);
188
- }
189
- if (unwrapped["results"] !== void 0 && typeof unwrapped["results"] === "object") return {
190
- coverageData,
191
- luauTiming,
192
- result: validateJestResult(unwrapped["results"]),
193
- setupSeconds,
194
- snapshotWrites
195
- };
196
- return {
197
- coverageData,
198
- luauTiming,
199
- result: validateJestResult(unwrapped),
200
- setupSeconds,
201
- snapshotWrites
202
- };
203
- }
204
- //#endregion
205
- //#region packages/roblox-runner/dist/index.mjs
206
- const CACHE_DIR_NAME = "jest-roblox";
207
- function getCacheDirectory() {
208
- const xdgCacheHome = process.env["XDG_CACHE_HOME"];
209
- if (xdgCacheHome !== void 0 && xdgCacheHome !== "") return path$1.join(xdgCacheHome, CACHE_DIR_NAME);
210
- if (process.platform === "win32") {
211
- const localAppData = process.env["LOCALAPPDATA"];
212
- if (localAppData !== void 0 && localAppData !== "") return path$1.join(localAppData, CACHE_DIR_NAME);
213
- return path$1.join(tmpdir(), CACHE_DIR_NAME);
214
- }
215
- return path$1.join(homedir(), ".cache", CACHE_DIR_NAME);
216
- }
217
- function getCacheKey(universeId, placeId) {
218
- return `${universeId}:${placeId}`;
219
- }
220
- function isUploaded(cache, key, fileHash) {
221
- return cache[key]?.fileHash === fileHash;
222
- }
223
- function markUploaded(cache, key, fileHash) {
224
- cache[key] = {
225
- fileHash,
226
- uploadedAt: Date.now()
227
- };
228
- }
229
- function readCache(cacheFilePath) {
230
- try {
231
- const data = fs$1.readFileSync(cacheFilePath, "utf-8");
232
- return JSON.parse(data);
233
- } catch {
234
- return {};
235
- }
236
- }
237
- function writeCache(cacheFilePath, cache) {
238
- const cacheDirectory = path$1.dirname(cacheFilePath);
239
- fs$1.mkdirSync(cacheDirectory, { recursive: true });
240
- fs$1.writeFileSync(cacheFilePath, JSON.stringify(cache, null, 2));
241
- }
242
- function hashBuffer(data) {
243
- return crypto.createHash("sha256").update(data).digest("hex");
244
- }
245
- function createFetchClient(defaultHeaders) {
246
- return { async request(method, url, options) {
247
- const headers = {
248
- ...defaultHeaders,
249
- ...options?.headers
250
- };
251
- const fetchOptions = {
252
- headers,
253
- method
254
- };
255
- if (options?.body !== void 0) if (options.body instanceof buffer.Buffer) fetchOptions.body = options.body;
256
- else {
257
- fetchOptions.body = JSON.stringify(options.body);
258
- headers["Content-Type"] = "application/json";
259
- }
260
- const response = await fetch(url, fetchOptions);
261
- return {
262
- body: await (response.headers.get("content-type")?.includes("application/json") ?? false ? response.json() : response.text()),
263
- headers: { "retry-after": response.headers.get("retry-after") ?? void 0 },
264
- ok: response.ok,
265
- status: response.status
266
- };
267
- } };
268
- }
269
- type({ path: "string" });
270
- type({
271
- "error?": { "message?": "string" },
272
- "output?": { "results?": "string[]" },
273
- "state": "'CANCELLED' | 'COMPLETE' | 'FAILED' | 'PROCESSING'"
274
- });
275
- type({
276
- outputs: "string[]",
277
- request_id: "string",
278
- type: "'results'"
279
- });
280
- //#endregion
281
- //#region src/config/schema.ts
282
- const ROOT_ONLY_KEYS = new Set([
283
- "backend",
284
- "cache",
285
- "collectCoverage",
286
- "collectCoverageFrom",
287
- "coverageDirectory",
288
- "coveragePathIgnorePatterns",
289
- "coverageReporters",
290
- "coverageThreshold",
291
- "formatters",
292
- "gameOutput",
293
- "jestPath",
294
- "luauRoots",
295
- "parallel",
296
- "placeFile",
297
- "pollInterval",
298
- "port",
299
- "rojoProject",
300
- "rootDir",
301
- "showLuau",
302
- "sourceMap",
303
- "timeout",
304
- "typecheck",
305
- "typecheckOnly",
306
- "typecheckTsconfig"
307
- ]);
308
- const VALID_BACKENDS = new Set([
309
- "auto",
310
- "open-cloud",
311
- "studio"
312
- ]);
313
- function isValidBackend(value) {
314
- return VALID_BACKENDS.has(value);
315
- }
316
- const DEFAULT_CONFIG = {
317
- backend: "auto",
318
- cache: true,
319
- collectCoverage: false,
320
- color: true,
321
- coverageDirectory: "coverage",
322
- coveragePathIgnorePatterns: [
323
- "**/*.spec.lua",
324
- "**/*.spec.luau",
325
- "**/*.test.lua",
326
- "**/*.test.luau",
327
- "**/node_modules/**",
328
- "**/rbxts_include/**"
329
- ],
330
- coverageReporters: ["text", "lcov"],
331
- passWithNoTests: false,
332
- placeFile: "./game.rbxl",
333
- pollInterval: 500,
334
- port: 3001,
335
- rootDir: process.cwd(),
336
- showLuau: true,
337
- silent: false,
338
- sourceMap: true,
339
- testMatch: [
340
- "**/*.spec.ts",
341
- "**/*.spec.tsx",
342
- "**/*.test.ts",
343
- "**/*.test.tsx",
344
- "**/*.spec-d.ts",
345
- "**/*.test-d.ts",
346
- "**/*.spec.lua",
347
- "**/*.spec.luau",
348
- "**/*.test.lua",
349
- "**/*.test.luau"
350
- ],
351
- testPathIgnorePatterns: [
352
- "/node_modules/",
353
- "/dist/",
354
- "/out/"
355
- ],
356
- timeout: 3e5,
357
- typecheck: false,
358
- typecheckOnly: false,
359
- verbose: false
360
- };
361
- const snapshotFormatSchema = type({
362
- "+": "reject",
363
- "callToJSON?": "boolean",
364
- "escapeRegex?": "boolean",
365
- "escapeString?": "boolean",
366
- "indent?": "number",
367
- "maxDepth?": "number",
368
- "min?": "boolean",
369
- "printBasicPrototype?": "boolean",
370
- "printFunctionName?": "boolean"
371
- });
372
- const coverageThresholdSchema = type({
373
- "+": "reject",
374
- "branches?": "number",
375
- "functions?": "number",
376
- "lines?": "number",
377
- "statements?": "number"
378
- });
379
- const displayNameSchema = type({
380
- "name": "string",
381
- "+": "reject",
382
- "color": "string"
383
- });
384
- const projectTestConfigSchema = type({
385
- "+": "reject",
386
- "automock?": "boolean",
387
- "clearMocks?": "boolean",
388
- "displayName": type("string").or(displayNameSchema),
389
- "exclude?": "string[]",
390
- "include": "string[]",
391
- "injectGlobals?": "boolean",
392
- "mockDataModel?": "boolean",
393
- "outDir?": "string",
394
- "resetMocks?": "boolean",
395
- "resetModules?": "boolean",
396
- "restoreMocks?": "boolean",
397
- "root?": "string",
398
- "setupFiles?": "string[]",
399
- "setupFilesAfterEnv?": "string[]",
400
- "slowTestThreshold?": "number",
401
- "snapshotFormat?": snapshotFormatSchema,
402
- "snapshotSerializers?": "string[]",
403
- "testEnvironment?": "string",
404
- "testEnvironmentOptions?": type("string").or(type("object")),
405
- "testMatch?": "string[]",
406
- "testPathIgnorePatterns?": "string[]",
407
- "testRegex?": type("string").or(type("string[]")),
408
- "testTimeout?": "number"
409
- });
410
- const inlineProjectSchema = type({
411
- "+": "reject",
412
- "test": projectTestConfigSchema
413
- });
414
- const formatterEntrySchema = type("string").or(type(["string", type("object")]));
415
- const projectEntrySchema = type("string").or(inlineProjectSchema);
416
- const configSchema = type({
417
- "+": "reject",
418
- "all?": "boolean",
419
- "automock?": "boolean",
420
- "backend?": type("'auto'|'open-cloud'|'studio'"),
421
- "bail?": type("boolean").or(type("number")),
422
- "cache?": "boolean",
423
- "changedSince?": "string",
424
- "ci?": "boolean",
425
- "clearCache?": "boolean",
426
- "clearMocks?": "boolean",
427
- "collectCoverage?": "boolean",
428
- "collectCoverageFrom?": "string[]",
429
- "color?": "boolean",
430
- "colors?": "boolean",
431
- "config?": "string",
432
- "coverage?": "boolean",
433
- "coverageDirectory?": "string",
434
- "coveragePathIgnorePatterns?": "string[]",
435
- "coverageReporters?": "string[]",
436
- "coverageThreshold?": coverageThresholdSchema,
437
- "debug?": "boolean",
438
- "env?": "string",
439
- "expand?": "boolean",
440
- "formatters?": formatterEntrySchema.array(),
441
- "gameOutput?": "string",
442
- "globals?": "string",
443
- "init?": "boolean",
444
- "injectGlobals?": "boolean",
445
- "jestPath?": "string",
446
- "luauRoots?": "string[]",
447
- "maxWorkers?": type("number").or(type("string")),
448
- "noStackTrace?": "boolean",
449
- "outputFile?": "string",
450
- "parallel?": type("'auto'").or("number.integer >= 1"),
451
- "passWithNoTests?": "boolean",
452
- "placeFile?": "string",
453
- "pollInterval?": "number",
454
- "port?": "number",
455
- "preset?": "string",
456
- "projects?": projectEntrySchema.array(),
457
- "reporters?": "string[]",
458
- "resetMocks?": "boolean",
459
- "resetModules?": "boolean",
460
- "restoreMocks?": "boolean",
461
- "rojoProject?": "string",
462
- "rootDir?": "string",
463
- "roots?": "string[]",
464
- "runInBand?": "boolean",
465
- "selectProjects?": "string[]",
466
- "setupFiles?": "string[]",
467
- "setupFilesAfterEnv?": "string[]",
468
- "showConfig?": "boolean",
469
- "showLuau?": "boolean",
470
- "silent?": "boolean",
471
- "snapshotFormat?": snapshotFormatSchema,
472
- "snapshotSerializers?": "string[]",
473
- "sourceMap?": "boolean",
474
- "testEnvironment?": "string",
475
- "testEnvironmentOptions?": type("string").or(type("object")),
476
- "testFailureExitCode?": "string",
477
- "testMatch?": "string[]",
478
- "testNamePattern?": "string",
479
- "testPathIgnorePatterns?": "string[]",
480
- "testPathPattern?": "string",
481
- "testRegex?": type("string").or(type("string[]")),
482
- "testTimeout?": "number",
483
- "timeout?": "number",
484
- "timers?": "string",
485
- "typecheck?": "boolean",
486
- "typecheckOnly?": "boolean",
487
- "typecheckTsconfig?": "string",
488
- "updateSnapshot?": "boolean",
489
- "verbose?": "boolean",
490
- "version?": "boolean"
491
- }).as();
492
- function validateConfig(raw) {
493
- const result = configSchema(raw);
494
- if (result instanceof type.errors) throw new Error(`Invalid config: ${result.summary}`);
495
- return result;
496
- }
497
- const defineConfig = createDefineConfig();
498
- const defineProject = createDefineConfig();
499
- //#endregion
500
- //#region src/test-runner.bundled.luau
501
- var test_runner_bundled_default = "type PatchState__DARKLUA_TYPE_a = {\n Runtime: any,\n originalRequireModule: any,\n accumulatedSeconds: { value: number },\n}\n\ntype PatchState__DARKLUA_TYPE_b = {\n robloxSharedExports: any,\n originalGetDataModelService: any,\n Runtime: any,\n originalRequireInternalModule: any,\n}\n\ntype Config__DARKLUA_TYPE_c = {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _coverage: boolean?,\n _timing: boolean?,\n}\n\ntype CapturedMessage__DARKLUA_TYPE_d = { message: string, messageType: number, timestamp: number }\n\ntype ProjectEntry__DARKLUA_TYPE_e = {\n jestOutput: string,\n gameOutput: string,\n elapsedMs: number,\n}\nlocal __JEST_MODULES={cache={}::any}do do local function __modImpl()--!strict\n\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n\nlocal module = {}\n\nfunction module.findInstance(path: string): Instance\n local parts = string.split(path, \"/\")\n\n local success, current = pcall(function()\n return game:FindService(parts[1])\n end)\n assert(success, `Failed to find service {parts[1]}: {current}`)\n\n for i = 2, #parts do\n assert(current, `Failed to find '{parts[i - 1]}' in path {path}`)\n current = current:FindFirstChild(parts[i])\n end\n\n assert(current, `Failed to find instance at path {path}`)\n\n return current\nend\n\nfunction module.getJest(config: { jestPath: string? }): ModuleScript\n local jestPath = config.jestPath\n if jestPath then\n local instance = module.findInstance(jestPath)\n assert(instance, `Failed to find Jest instance at path {jestPath}`)\n assert(instance:IsA(\"ModuleScript\"), `Instance at path {jestPath} is not a ModuleScript`)\n return instance :: ModuleScript\n end\n\n local jestInstance = ReplicatedStorage:FindFirstChild(\"Jest\", true)\n assert(jestInstance, \"Failed to find Jest instance in ReplicatedStorage\")\n assert(jestInstance:IsA(\"ModuleScript\"), \"Jest instance in ReplicatedStorage is not a ModuleScript\")\n return jestInstance\nend\n\nfunction module.findRobloxShared(jestModule: ModuleScript): Instance?\n local parent = jestModule.Parent\n if parent then\n local found = parent:FindFirstChild(\"RobloxShared\") or parent:FindFirstChild(\"jest-roblox-shared\")\n if found then\n return found\n end\n\n if parent.Parent then\n found = parent.Parent:FindFirstChild(\"RobloxShared\") or parent.Parent:FindFirstChild(\"jest-roblox-shared\")\n if found then\n return found\n end\n end\n end\n\n return game:FindFirstChild(\"RobloxShared\", true)\nend\n\nfunction module.findSiblingPackage(jestModule: ModuleScript, ...: string): Instance?\n local parent = jestModule.Parent\n if parent then\n for _, name in { ... } do\n local found = parent:FindFirstChild(name)\n if found then\n return found\n end\n end\n\n if parent.Parent then\n for _, name in { ... } do\n local found = parent.Parent:FindFirstChild(name)\n if found then\n return found\n end\n end\n end\n end\n\n for _, name in { ... } do\n local found = game:FindFirstChild(name, true)\n if found then\n return found\n end\n end\n\n return nil\nend\n\nreturn module\nend function __JEST_MODULES.a():typeof(__modImpl())local v=__JEST_MODULES.cache.a if not v then v={c=__modImpl()}__JEST_MODULES.cache.a=v end return v.c end end do local function __modImpl()--!strict\n\nlocal InstanceResolver = __JEST_MODULES.a()\n\nlocal module = {}\n\n\n\n\n\n\n\nfunction module.patch(\n jestModule: ModuleScript,\n setupFiles: { Instance }?,\n setupFilesAfterEnv: { Instance }?\n): PatchState__DARKLUA_TYPE_a?\n local setupSet: { [Instance]: boolean } = {}\n\n if setupFiles then\n for _, inst in setupFiles do\n setupSet[inst] = true\n end\n end\n\n if setupFilesAfterEnv then\n for _, inst in setupFilesAfterEnv do\n setupSet[inst] = true\n end\n end\n\n if not next(setupSet) then\n return nil\n end\n\n local jestRuntimeModule = InstanceResolver.findSiblingPackage(jestModule, \"JestRuntime\", \"jest-runtime\")\n if not jestRuntimeModule then\n warn(\"Could not find JestRuntime; setup timing unavailable\")\n return nil\n end\n\n local Runtime = (require :: any)(jestRuntimeModule)\n local originalRequireModule = Runtime.requireModule\n local accumulated = { value = 0 }\n local insideSetupRequire = false\n\n Runtime.requireModule = function(self: any, moduleName: any, ...): any\n if not insideSetupRequire and typeof(moduleName) == \"Instance\" and setupSet[moduleName] then\n insideSetupRequire = true\n local t0 = os.clock()\n local results = table.pack(pcall(originalRequireModule, self, moduleName, ...))\n accumulated.value += os.clock() - t0\n insideSetupRequire = false\n\n if not results[1] then\n error(results[2], 0)\n end\n\n return table.unpack(results, 2, results.n)\n end\n\n return originalRequireModule(self, moduleName, ...)\n end\n\n return {\n Runtime = Runtime,\n originalRequireModule = originalRequireModule,\n accumulatedSeconds = accumulated,\n }\nend\n\nfunction module.unpatch(state: PatchState__DARKLUA_TYPE_a?)\n if not state then\n return\n end\n\n if state.Runtime and state.originalRequireModule then\n state.Runtime.requireModule = state.originalRequireModule\n end\nend\n\nfunction module.getSeconds(state: PatchState__DARKLUA_TYPE_a?): number\n if not state then\n return 0\n end\n\n return state.accumulatedSeconds.value\nend\n\nreturn module\nend function __JEST_MODULES.b():typeof(__modImpl())local v=__JEST_MODULES.cache.b if not v then v={c=__modImpl()}__JEST_MODULES.cache.b=v end return v.c end end do local function __modImpl()--!strict\n\nlocal function getInstancePath(instance: Instance): string\n local parts = {} :: { string }\n local current: Instance? = instance\n while current and current ~= game do\n table.insert(parts, 1, current.Name)\n current = current.Parent\n end\n\n return table.concat(parts, \"/\")\nend\n\nlocal CoreScriptSyncService = {}\n\nfunction CoreScriptSyncService:GetScriptFilePath(instance: Instance): string\n return getInstancePath(instance)\nend\n\nreturn CoreScriptSyncService\nend function __JEST_MODULES.c():typeof(__modImpl())local v=__JEST_MODULES.cache.c if not v then v={c=__modImpl()}__JEST_MODULES.cache.c=v end return v.c end end do local function __modImpl()--!strict\n\nlocal function normalizeSnapPath(path: string): string\n return (string.gsub(path, \"%.snap%.lua$\", \".snap.luau\"))\nend\n\nlocal function create(snapshotWrites: { [string]: string })\n local FileSystemService = {}\n\n function FileSystemService:WriteFile(path: string, contents: string)\n snapshotWrites[normalizeSnapPath(path)] = contents\n end\n\n function FileSystemService:CreateDirectories(_path: string) end\n\n function FileSystemService:Exists(path: string): boolean\n return snapshotWrites[normalizeSnapPath(path)] ~= nil\n end\n\n function FileSystemService:Remove(path: string)\n snapshotWrites[normalizeSnapPath(path)] = nil\n end\n\n function FileSystemService:IsRegularFile(path: string): boolean\n return snapshotWrites[normalizeSnapPath(path)] ~= nil\n end\n\n return FileSystemService\nend\n\nreturn create\nend function __JEST_MODULES.d():typeof(__modImpl())local v=__JEST_MODULES.cache.d if not v then v={c=__modImpl()}__JEST_MODULES.cache.d=v end return v.c end end do local function __modImpl()--!strict\n\nlocal CoreScriptSyncService = __JEST_MODULES.c()\nlocal InstanceResolver = __JEST_MODULES.a()\n\nlocal module = {}\n\nfunction module.createMockGetDataModelService(snapshotWrites: { [string]: string })\n local FileSystemService = __JEST_MODULES.d()(snapshotWrites)\n\n return function(service: string): any\n if service == \"FileSystemService\" then\n return FileSystemService\n elseif service == \"CoreScriptSyncService\" then\n return CoreScriptSyncService\n end\n\n local success, result = pcall(function()\n local service_ = game:GetService(service)\n local _ = service_.Name\n return service_\n end)\n\n return if success then result else nil\n end\nend\n\n\n\n\n\n\n\n\nfunction module.patch(jestModule: ModuleScript, snapshotWrites: { [string]: string }): PatchState__DARKLUA_TYPE_b?\n local mockGetDataModelService = module.createMockGetDataModelService(snapshotWrites)\n\n local robloxSharedInstance = InstanceResolver.findRobloxShared(jestModule)\n if not robloxSharedInstance then\n warn(\"Could not find RobloxShared; snapshot support unavailable\")\n return nil\n end\n\n local robloxSharedExports = (require :: any)(robloxSharedInstance)\n local originalGetDataModelService = robloxSharedExports.getDataModelService\n robloxSharedExports.getDataModelService = mockGetDataModelService\n\n local getDataModelServiceChild = robloxSharedInstance:FindFirstChild(\"getDataModelService\")\n\n local jestRuntimeModule = InstanceResolver.findSiblingPackage(jestModule, \"JestRuntime\", \"jest-runtime\")\n if not jestRuntimeModule then\n warn(\"Could not find JestRuntime; snapshot interception unavailable\")\n return {\n robloxSharedExports = robloxSharedExports,\n originalGetDataModelService = originalGetDataModelService,\n Runtime = nil,\n originalRequireInternalModule = nil,\n }\n end\n\n local Runtime = (require :: any)(jestRuntimeModule)\n local originalRequireInternalModule = Runtime.requireInternalModule\n\n Runtime.requireInternalModule = function(self: any, from: any, to: any, ...): any\n local target = if to ~= nil then to else from\n if getDataModelServiceChild and typeof(target) == \"Instance\" and target == getDataModelServiceChild then\n return mockGetDataModelService\n end\n\n return originalRequireInternalModule(self, from, to, ...)\n end\n\n return {\n robloxSharedExports = robloxSharedExports,\n originalGetDataModelService = originalGetDataModelService,\n Runtime = Runtime,\n originalRequireInternalModule = originalRequireInternalModule,\n }\nend\n\nfunction module.unpatch(state: PatchState__DARKLUA_TYPE_b?)\n if not state then\n return\n end\n\n if state.robloxSharedExports and state.originalGetDataModelService then\n state.robloxSharedExports.getDataModelService = state.originalGetDataModelService\n end\n\n if state.Runtime and state.originalRequireInternalModule then\n state.Runtime.requireInternalModule = state.originalRequireInternalModule\n end\nend\n\nreturn module\nend function __JEST_MODULES.e():typeof(__modImpl())local v=__JEST_MODULES.cache.e if not v then v={c=__modImpl()}__JEST_MODULES.cache.e=v end return v.c end end do local function __modImpl()--!strict\n\nlocal HttpService = game:GetService(\"HttpService\")\nlocal LogService = game:GetService(\"LogService\")\n\nlocal InstanceResolver = __JEST_MODULES.a()\nlocal SetupTiming = __JEST_MODULES.b()\nlocal SnapshotPatch = __JEST_MODULES.e()\n\n\n\n\n\n\n\n\n\n\nlocal function fail(err: string)\n return {\n success = false,\n err = err,\n }\nend\n\n\n\nlocal function interceptWriteable(writeable: any, buffer: { CapturedMessage__DARKLUA_TYPE_d }, messageType: number)\n local original = writeable._writeFn\n if typeof(original) ~= \"function\" then\n return function() end\n end\n\n writeable._writeFn = function(data: string)\n table.insert(buffer, {\n message = data,\n messageType = messageType,\n timestamp = os.clock(),\n })\n original(data)\n end\n\n return function()\n writeable._writeFn = original\n end\nend\n\nlocal module = {}\n\nfunction module.run(callingScript: LuaSourceContainer, config: Config__DARKLUA_TYPE_c): (string, string)\n local t0 = os.clock()\n local timingEnabled = config._timing\n local coverageEnabled = config._coverage\n\n local t_findJest0 = os.clock()\n local findSuccess, findValue = pcall(InstanceResolver.getJest, config)\n local t_findJest = os.clock()\n\n if not findSuccess then\n local logSuccess, logHistory = pcall(function()\n return HttpService:JSONEncode(LogService:GetLogHistory())\n end)\n\n return HttpService:JSONEncode(fail(findValue :: any)), if logSuccess then logHistory else \"[]\"\n end\n\n LogService:ClearOutput()\n\n local snapshotWrites: { [string]: string } = {}\n\n local t_patchSnapshot0 = os.clock()\n local patchState = SnapshotPatch.patch(findValue, snapshotWrites)\n local t_patchSnapshot = os.clock()\n\n local t_requireJest0 = os.clock()\n local Jest = (require :: any)(findValue)\n local t_requireJest = os.clock()\n\n -- Intercept Jest's stdout/stderr to capture output synchronously.\n -- Jest writes via process.stdout/stderr (Writeable objects whose _writeFn\n -- defaults to print). Wrapping _writeFn captures messages like\n -- \"No tests found\" that are printed just before exit(1) throws.\n local capturedMessages: { CapturedMessage__DARKLUA_TYPE_d } = {}\n local restoreStdout: (() -> ())?\n local restoreStderr: (() -> ())?\n\n local interceptOk = pcall(function()\n local nodeModules = findValue.Parent.Parent.Parent :: any\n local RobloxShared = (require :: any)(nodeModules[\"@rbxts-js\"].RobloxShared)\n local process = RobloxShared.nodeUtils.process\n\n restoreStdout = interceptWriteable(process.stdout, capturedMessages, 0)\n restoreStderr = interceptWriteable(process.stderr, capturedMessages, 1)\n end)\n\n if not interceptOk then\n restoreStdout = nil\n restoreStderr = nil\n end\n\n local function runTests()\n local t_resolveProjects0 = os.clock()\n local projects = {}\n\n assert(\n config.projects and #config.projects > 0,\n \"No projects configured. Set 'projects' in jest.config.ts or pass --projects.\"\n )\n\n for _, projectPath in config.projects do\n table.insert(projects, InstanceResolver.findInstance(projectPath))\n end\n\n config.projects = {}\n local t_resolveProjects = os.clock()\n\n local t_resolveSetupFiles0 = os.clock()\n if config.setupFiles and #config.setupFiles > 0 then\n local resolved = {}\n\n for _, setupPath in config.setupFiles do\n table.insert(resolved, InstanceResolver.findInstance(setupPath))\n end\n\n config.setupFiles = resolved :: any\n end\n if config.setupFilesAfterEnv and #config.setupFilesAfterEnv > 0 then\n local resolved = {}\n\n for _, setupPath in config.setupFilesAfterEnv do\n table.insert(resolved, InstanceResolver.findInstance(setupPath))\n end\n\n config.setupFilesAfterEnv = resolved :: any\n end\n local t_resolveSetupFiles = os.clock()\n\n local setupTimingState = SetupTiming.patch(\n findValue,\n config.setupFiles :: any,\n config.setupFilesAfterEnv :: any\n )\n\n -- Strip private keys before Jest.runCLI (safe: single-task execution per VM)\n config._timing = nil :: any\n config._coverage = nil :: any\n\n if coverageEnabled then\n _G.__jest_roblox_cov = {}\n end\n\n local t_jestRunCLI0 = os.clock()\n local runCLIOk, runCLIValue = pcall(function()\n return Jest.runCLI(callingScript, config, projects):expect()\n end)\n local t_jestRunCLI = os.clock()\n\n local setupSeconds = SetupTiming.getSeconds(setupTimingState)\n SetupTiming.unpatch(setupTimingState)\n\n if not runCLIOk then\n error(runCLIValue, 0)\n end\n\n local jestResult = runCLIValue\n\n local result: { [string]: any } = {\n success = true,\n value = jestResult,\n }\n\n if setupSeconds > 0 then\n result._setup = setupSeconds\n end\n\n if timingEnabled then\n result._timing = {\n findJest = t_findJest - t_findJest0,\n patchSnapshot = t_patchSnapshot - t_patchSnapshot0,\n requireJest = t_requireJest - t_requireJest0,\n resolveProjects = t_resolveProjects - t_resolveProjects0,\n resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,\n jestRunCLI = t_jestRunCLI - t_jestRunCLI0,\n total = os.clock() - t0,\n }\n end\n\n if next(snapshotWrites) then\n result._snapshotWrites = snapshotWrites\n end\n\n if coverageEnabled then\n result._coverage = _G.__jest_roblox_cov\n end\n\n return result\n end\n\n local jestDone = false\n local runSuccess = false\n local runValue: any = nil\n\n task.spawn(function()\n local ok, val = pcall(runTests)\n jestDone = true\n runSuccess = ok\n runValue = val\n end)\n\n local infiniteYieldMessage: string? = nil\n local watchdogConnection = LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)\n if\n messageType == Enum.MessageType.MessageWarning\n and string.find(message, \"Infinite yield possible\")\n and not infiniteYieldMessage\n then\n infiniteYieldMessage = message\n end\n end)\n\n while not jestDone and not infiniteYieldMessage do\n task.wait(0.1)\n end\n\n watchdogConnection:Disconnect()\n\n if restoreStdout then\n restoreStdout()\n end\n\n if restoreStderr then\n restoreStderr()\n end\n\n if not jestDone and infiniteYieldMessage then\n runSuccess = false\n runValue = \"Infinite yield detected, aborting tests: \" .. infiniteYieldMessage\n end\n\n SnapshotPatch.unpatch(patchState)\n\n local jestResult\n if not runSuccess then\n jestResult = HttpService:JSONEncode(fail(runValue :: any))\n else\n jestResult = HttpService:JSONEncode(runValue)\n end\n\n local logSuccess, logHistory = pcall(function()\n return HttpService:JSONEncode(capturedMessages)\n end)\n\n return jestResult, if logSuccess then logHistory else \"[]\"\nend\n\n\n\n\n\n\n\nlocal function encodeExecutionError(err: any): string\n return HttpService:JSONEncode({\n success = true,\n value = {\n kind = \"ExecutionError\",\n error = tostring(err),\n },\n })\nend\n\n-- TODO(runner-tests): dogfood harness for Runner.runProjects\nfunction module.runProjects(\n callingScript: LuaSourceContainer,\n configs: { Config__DARKLUA_TYPE_c }\n): { ProjectEntry__DARKLUA_TYPE_e }\n local entries: { ProjectEntry__DARKLUA_TYPE_e } = {}\n\n for index, cfg in configs do\n local start = os.clock()\n local ok, jestOutput, gameOutput = pcall(module.run, callingScript, cfg)\n local elapsedMs = math.floor((os.clock() - start) * 1000)\n\n if ok then\n entries[index] = {\n jestOutput = jestOutput :: string,\n gameOutput = gameOutput :: string,\n elapsedMs = elapsedMs,\n }\n else\n entries[index] = {\n jestOutput = encodeExecutionError(jestOutput),\n gameOutput = \"[]\",\n elapsedMs = elapsedMs,\n }\n end\n end\n\n return entries\nend\n\nreturn module\nend function __JEST_MODULES.f():typeof(__modImpl())local v=__JEST_MODULES.cache.f if not v then v={c=__modImpl()}__JEST_MODULES.cache.f=v end return v.c end end end--!strict\n\nlocal HttpService = game:GetService(\"HttpService\")\n\nlocal Runner = __JEST_MODULES.f()\n\nlocal payload = HttpService:JSONDecode([=[__CONFIG_JSON__]=])\nlocal entries = Runner.runProjects(script, payload.configs)\n\nreturn HttpService:JSONEncode({ entries = entries }), \"[]\"\n";
502
- //#endregion
503
- //#region src/test-script.ts
504
- function buildJestArgv(options) {
505
- const argv = {};
506
- for (const [key, value] of Object.entries(options.config)) if (!ROOT_ONLY_KEYS.has(key) && value !== void 0) argv[key] = value;
507
- if (options.config.jestPath !== void 0) argv["jestPath"] = options.config.jestPath;
508
- if (process.env["TIMING"] !== void 0) argv["_timing"] = true;
509
- if (options.config.collectCoverage) argv["_coverage"] = true;
510
- return {
511
- ...argv,
512
- reporters: argv["reporters"] ?? [],
513
- testMatch: options.config.testMatch.map((pattern) => pattern.replace(/\.(tsx?|luau?)$/, ""))
514
- };
515
- }
516
- function generateTestScript(options) {
517
- const configs = (Array.isArray(options) ? options : [options]).map((input) => buildJestArgv(input));
518
- return test_runner_bundled_default.replace("__CONFIG_JSON__", () => JSON.stringify({ configs }));
519
- }
520
- //#endregion
521
- //#region src/backends/open-cloud.ts
522
- const PARALLEL_AUTO_CAP = 3;
523
- const DEFAULT_OPEN_CLOUD_BASE_URL = "https://apis.roblox.com";
524
- const RATE_LIMIT_DEFAULT_WAIT_MS = 5e3;
525
- const MAX_RATE_LIMIT_RETRIES = 5;
526
- const taskResponse = type({ path: "string" });
527
- const taskStatusResponse = type({
528
- "error?": { "message?": "string" },
529
- "output?": { "results?": "string[]" },
530
- "state": "'CANCELLED' | 'COMPLETE' | 'FAILED' | 'PROCESSING'"
531
- });
532
- const envelopeSchema$1 = type({ entries: type({
533
- "elapsedMs?": "number",
534
- "gameOutput?": "string",
535
- "jestOutput": "string"
536
- }).array() });
537
- var OpenCloudBackend = class {
538
- baseUrl;
539
- credentials;
540
- http;
541
- readFile;
542
- sleepFn;
543
- kind = "open-cloud";
544
- constructor(credentials, options) {
545
- this.baseUrl = resolveOpenCloudBaseUrl();
546
- this.credentials = credentials;
547
- this.http = options?.http ?? createFetchClient({ "x-api-key": credentials.apiKey });
548
- this.readFile = options?.readFile ?? ((filePath) => fs$1.readFileSync(filePath));
549
- this.sleepFn = options?.sleep ?? (async (ms) => {
550
- return new Promise((resolve) => {
551
- setTimeout(resolve, ms);
552
- });
553
- });
554
- }
555
- async runTests(options) {
556
- const { jobs, parallel } = options;
557
- if (jobs.length === 0) throw new Error("OpenCloudBackend requires at least one job");
558
- const primary = jobs[0];
559
- const placeFilePath = path$1.resolve(primary.config.rootDir, primary.config.placeFile);
560
- const cacheDirectory = getCacheDirectory();
561
- const cacheFilePath = path$1.join(cacheDirectory, "upload-cache.json");
562
- const uploadStart = Date.now();
563
- const placeData = this.readFile(placeFilePath);
564
- const fileHash = hashBuffer(placeData);
565
- const cacheKey = getCacheKey(this.credentials.universeId, this.credentials.placeId);
566
- const cache = readCache(cacheFilePath);
567
- const uploadCached = await this.uploadOrReuseCached({
568
- cache,
569
- cacheEnabled: primary.config.cache,
570
- cacheFilePath,
571
- cacheKey,
572
- fileHash,
573
- placeData
574
- });
575
- const uploadMs = Date.now() - uploadStart;
576
- const buckets = bucketJobs(jobs, parallel);
577
- const executionStart = Date.now();
578
- const bucketResults = await Promise.all(buckets.map(async (bucket) => this.runBucket(bucket)));
579
- const executionMs = Date.now() - executionStart;
580
- const flattened = Array.from({ length: jobs.length });
581
- for (const { indices, results } of bucketResults) for (const [positionInBucket, originalIndex] of indices.entries()) flattened[originalIndex] = results[positionInBucket];
582
- return {
583
- results: flattened,
584
- timing: {
585
- executionMs,
586
- uploadCached,
587
- uploadMs
588
- }
589
- };
590
- }
591
- async createExecutionTask(inputs, timeoutMs) {
592
- const url = `${this.baseUrl}/cloud/v2/universes/${this.credentials.universeId}/places/${this.credentials.placeId}/luau-execution-session-tasks`;
593
- const script = generateTestScript(inputs);
594
- const response = await this.http.request("POST", url, { body: {
595
- script,
596
- timeout: `${Math.floor(timeoutMs / 1e3)}s`
597
- } });
598
- if (!response.ok) throw new Error(`Failed to create execution task: ${response.status}`);
599
- return taskResponse.assert(response.body).path;
600
- }
601
- async pollForCompletion(taskPath, timeoutMs, pollIntervalMs) {
602
- const url = `${this.baseUrl}/cloud/v2/${taskPath}`;
603
- const startTime = Date.now();
604
- let rateLimitRetries = 0;
605
- while (Date.now() - startTime < timeoutMs) {
606
- const response = await this.http.request("GET", url);
607
- if (response.status === 429) {
608
- rateLimitRetries++;
609
- if (rateLimitRetries > MAX_RATE_LIMIT_RETRIES) throw new Error("Rate limited by Open Cloud API after multiple retries");
610
- const retryAfter = parseRetryAfter(response.headers);
611
- await this.sleepFn(retryAfter);
612
- continue;
613
- }
614
- if (!response.ok) throw new Error(`Failed to poll task: ${response.status}`);
615
- const body = taskStatusResponse.assert(response.body);
616
- switch (body.state) {
617
- case "COMPLETE": {
618
- const value = body.output?.results?.[0];
619
- if (value === void 0) throw new Error(`No test results in output. Got: ${JSON.stringify(body.output)}`);
620
- return {
621
- gameOutput: body.output?.results?.[1],
622
- jestOutput: value
623
- };
624
- }
625
- case "FAILED": throw new Error(body.error?.message ?? "Execution failed");
626
- case "CANCELLED": throw new Error("Execution was cancelled");
627
- case "PROCESSING":
628
- await this.sleepFn(pollIntervalMs);
629
- break;
630
- }
631
- }
632
- throw new Error("Execution timed out");
633
- }
634
- async runBucket(bucket) {
635
- const { indices, jobs } = bucket;
636
- const primary = jobs[0];
637
- const inputs = jobs.map((job) => {
638
- return {
639
- config: job.config,
640
- testFiles: job.testFiles
641
- };
642
- });
643
- const taskPath = await this.createExecutionTask(inputs, primary.config.timeout);
644
- const { gameOutput, jestOutput } = await this.pollForCompletion(taskPath, primary.config.timeout, primary.config.pollInterval);
645
- const entries = parseEnvelope(jestOutput);
646
- if (entries.length !== jobs.length) throw new Error(`Open Cloud backend returned ${entries.length.toString()} entries but bucket had ${jobs.length.toString()} jobs`);
647
- return {
648
- indices,
649
- results: entries.map((entry, index) => {
650
- return buildProjectResult(entry, jobs[index], gameOutput);
651
- })
652
- };
653
- }
654
- async uploadOrReuseCached({ cache, cacheEnabled, cacheFilePath, cacheKey, fileHash, placeData }) {
655
- if (cacheEnabled && isUploaded(cache, cacheKey, fileHash)) return true;
656
- await this.uploadPlaceData(placeData);
657
- markUploaded(cache, cacheKey, fileHash);
658
- writeCache(cacheFilePath, cache);
659
- return false;
660
- }
661
- async uploadPlaceData(placeData) {
662
- const url = `${this.baseUrl}/universes/v1/${this.credentials.universeId}/places/${this.credentials.placeId}/versions?versionType=Saved`;
663
- const response = await this.http.request("POST", url, {
664
- body: placeData,
665
- headers: { "Content-Type": "application/octet-stream" }
666
- });
667
- if (!response.ok) throw new Error(`Failed to upload place: ${response.status}`);
668
- }
669
- };
670
- function createOpenCloudBackend() {
671
- const apiKey = process.env["ROBLOX_OPEN_CLOUD_API_KEY"];
672
- if (apiKey === void 0) throw new Error("ROBLOX_OPEN_CLOUD_API_KEY environment variable is required");
673
- const universeId = process.env["ROBLOX_UNIVERSE_ID"];
674
- if (universeId === void 0) throw new Error("ROBLOX_UNIVERSE_ID environment variable is required");
675
- const placeId = process.env["ROBLOX_PLACE_ID"];
676
- if (placeId === void 0) throw new Error("ROBLOX_PLACE_ID environment variable is required");
677
- return new OpenCloudBackend({
678
- apiKey,
679
- placeId,
680
- universeId
681
- });
682
- }
683
- function resolveOpenCloudBaseUrl() {
684
- const override = process.env["JEST_ROBLOX_OPEN_CLOUD_BASE_URL"];
685
- if (override === void 0 || override.trim() === "") return DEFAULT_OPEN_CLOUD_BASE_URL;
686
- return override.replace(/\/+$/, "");
687
- }
688
- function resolveBucketCount(parallel, jobCount) {
689
- if (parallel === void 0) return 1;
690
- if (parallel === "auto") return Math.min(jobCount, PARALLEL_AUTO_CAP);
691
- if (parallel < 1) throw new Error(`--parallel must be >= 1, got ${parallel.toString()}`);
692
- return Math.min(Math.floor(parallel), jobCount);
693
- }
694
- function bucketJobs(jobs, parallel) {
695
- const bucketCount = resolveBucketCount(parallel, jobs.length);
696
- const buckets = [];
697
- for (let index = 0; index < bucketCount; index++) buckets.push({
698
- indices: [],
699
- jobs: []
700
- });
701
- for (const [originalIndex, job] of jobs.entries()) {
702
- const bucket = buckets[originalIndex % bucketCount];
703
- bucket.indices.push(originalIndex);
704
- bucket.jobs.push(job);
705
- }
706
- return buckets;
707
- }
708
- function parseEnvelope(jestOutput) {
709
- const envelope = envelopeSchema$1(JSON.parse(jestOutput));
710
- if (envelope instanceof type.errors) return [{
711
- elapsedMs: 0,
712
- jestOutput
713
- }];
714
- return envelope.entries;
715
- }
716
- function buildProjectResult(entry, job, fallbackGameOutput) {
717
- const gameOutput = entry.gameOutput ?? fallbackGameOutput;
718
- let parsed;
719
- try {
720
- parsed = parseJestOutput(entry.jestOutput);
721
- } catch (err) {
722
- if (err instanceof LuauScriptError) err.gameOutput = gameOutput;
723
- throw err;
724
- }
725
- return {
726
- coverageData: parsed.coverageData,
727
- displayColor: job.displayColor,
728
- displayName: job.displayName,
729
- elapsedMs: entry.elapsedMs ?? 0,
730
- gameOutput,
731
- luauTiming: parsed.luauTiming,
732
- result: parsed.result,
733
- setupMs: parsed.setupSeconds !== void 0 ? Math.round(parsed.setupSeconds * 1e3) : void 0,
734
- snapshotWrites: parsed.snapshotWrites
735
- };
736
- }
737
- function parseRetryAfter(headers) {
738
- const value = headers?.["retry-after"];
739
- if (value === void 0) return RATE_LIMIT_DEFAULT_WAIT_MS;
740
- const seconds = Number(value);
741
- if (Number.isNaN(seconds) || seconds <= 0) return RATE_LIMIT_DEFAULT_WAIT_MS;
742
- return seconds * 1e3;
743
- }
744
- //#endregion
745
- //#region src/backends/studio.ts
746
- const DEFAULT_STUDIO_TIMEOUT = 3e5;
747
- const envelopeSchema = type({ entries: type({
748
- "elapsedMs?": "number",
749
- "gameOutput?": "string",
750
- "jestOutput": "string"
751
- }).array() });
752
- const pluginMessageSchema = type({
753
- "gameOutput?": "string",
754
- "jestOutput": "string",
755
- "request_id": "string",
756
- "type": "'results'"
757
- });
758
- var StudioBackend = class {
759
- createServer;
760
- port;
761
- timeout;
762
- preConnected;
763
- wss;
764
- kind = "studio";
765
- constructor(options) {
766
- this.port = options.port;
767
- this.timeout = options.timeout ?? DEFAULT_STUDIO_TIMEOUT;
768
- this.createServer = options.createServer ?? ((port) => new WebSocketServer({ port }));
769
- this.preConnected = options.preConnected;
770
- }
771
- close() {
772
- const server = this.wss;
773
- this.wss = void 0;
774
- if (server === void 0) return;
775
- server.close();
776
- }
777
- async runTests(options) {
778
- const pre = this.preConnected;
779
- this.preConnected = void 0;
780
- this.wss ??= pre?.server ?? this.createServer(this.port);
781
- return this.executeViaPlugin(this.wss, options.jobs, pre?.socket);
782
- }
783
- buildProjectResult(entry, job, fallbackGameOutput) {
784
- const gameOutput = entry.gameOutput ?? fallbackGameOutput;
785
- let parsed;
786
- try {
787
- parsed = parseJestOutput(entry.jestOutput);
788
- } catch (err) {
789
- if (err instanceof LuauScriptError) err.gameOutput = gameOutput;
790
- throw err;
791
- }
792
- return {
793
- coverageData: parsed.coverageData,
794
- displayColor: job.displayColor,
795
- displayName: job.displayName,
796
- elapsedMs: entry.elapsedMs ?? 0,
797
- gameOutput,
798
- luauTiming: parsed.luauTiming,
799
- result: parsed.result,
800
- setupMs: parsed.setupSeconds !== void 0 ? Math.round(parsed.setupSeconds * 1e3) : void 0,
801
- snapshotWrites: parsed.snapshotWrites
802
- };
803
- }
804
- async executeViaPlugin(wss, jobs, existingSocket) {
805
- const requestId = randomUUID();
806
- const configs = jobs.map((job) => buildJestArgv(job));
807
- const executionStart = Date.now();
808
- const message = await this.waitForResult(wss, requestId, configs, existingSocket);
809
- const executionMs = Date.now() - executionStart;
810
- const entries = this.parseEnvelope(message.jestOutput);
811
- if (entries.length !== jobs.length) throw new Error(`Studio backend returned ${entries.length.toString()} entries but request had ${jobs.length.toString()} jobs`);
812
- return {
813
- results: entries.map((entry, index) => {
814
- const matched = jobs[index];
815
- return this.buildProjectResult(entry, matched, message.gameOutput);
816
- }),
817
- timing: { executionMs }
818
- };
819
- }
820
- parseEnvelope(jestOutput) {
821
- const envelope = envelopeSchema(JSON.parse(jestOutput));
822
- if (envelope instanceof type.errors) return [{
823
- elapsedMs: 0,
824
- jestOutput
825
- }];
826
- return envelope.entries;
827
- }
828
- async waitForResult(wss, requestId, configs, existingSocket) {
829
- return new Promise((resolve, reject) => {
830
- const timer = setTimeout(() => {
831
- reject(/* @__PURE__ */ new Error("Timed out waiting for Studio plugin connection"));
832
- }, this.timeout);
833
- function attachSocket(ws) {
834
- ws.send(JSON.stringify({
835
- action: "run_tests",
836
- config: { configs },
837
- request_id: requestId
838
- }));
839
- ws.on("message", (data) => {
840
- const message = pluginMessageSchema(JSON.parse(data.toString()));
841
- if (message instanceof type.errors) {
842
- clearTimeout(timer);
843
- reject(/* @__PURE__ */ new Error(`Invalid plugin message: ${message.summary}`));
844
- return;
845
- }
846
- if (message.request_id === requestId) {
847
- clearTimeout(timer);
848
- resolve(message);
849
- }
850
- });
851
- ws.on("close", () => {
852
- clearTimeout(timer);
853
- reject(/* @__PURE__ */ new Error("Studio plugin disconnected before sending results"));
854
- });
855
- ws.on("error", (err) => {
856
- clearTimeout(timer);
857
- reject(err);
858
- });
859
- }
860
- if (existingSocket) attachSocket(existingSocket);
861
- wss.on("connection", (ws) => {
862
- attachSocket(ws);
863
- });
864
- wss.on("error", (err) => {
865
- clearTimeout(timer);
866
- reject(err);
867
- });
868
- });
869
- }
870
- };
871
- function createStudioBackend(options) {
872
- return new StudioBackend(options);
873
- }
874
- //#endregion
875
- //#region src/config/loader.ts
876
- function applySnapshotFormatDefaults(config, isLuauProject) {
877
- if (config.snapshotFormat?.printBasicPrototype !== void 0) return config;
878
- return {
879
- ...config,
880
- snapshotFormat: {
881
- ...config.snapshotFormat,
882
- printBasicPrototype: isLuauProject
883
- }
884
- };
885
- }
886
- function resolveConfig(config) {
887
- validateConfig(config);
888
- const defined = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
889
- return Object.assign({}, DEFAULT_CONFIG, defined);
890
- }
891
- async function loadConfig$1(configPath, cwd = process.cwd()) {
892
- let result;
893
- const extendWarnings = [];
894
- const originalWarn = console.warn;
895
- try {
896
- console.warn = (...args) => {
897
- const message = args.join(" ");
898
- if (typeof message === "string" && message.includes("Cannot extend config")) {
899
- extendWarnings.push(message);
900
- return;
901
- }
902
- originalWarn.apply(console, args);
903
- };
904
- result = await loadConfig({
905
- name: "jest",
906
- configFile: configPath,
907
- configFileRequired: configPath !== void 0,
908
- cwd,
909
- dotenv: false,
910
- globalRc: false,
911
- import: isSea() ? seaImport : void 0,
912
- merger,
913
- omit$Keys: true,
914
- packageJson: false,
915
- rcFile: false
916
- });
917
- } catch (err) {
918
- if (configPath !== void 0) throw new Error(`Config file not found: ${configPath}`, { cause: err });
919
- throw err;
920
- } finally {
921
- console.warn = originalWarn;
922
- }
923
- if (extendWarnings.length > 0) {
924
- const extendsPath = extendWarnings[0]?.match(/`([^`]+)`/)?.[1];
925
- throw new Error(`Failed to resolve extends: "${extendsPath}". If the file exists, try adding the file extension (e.g. ".ts").`);
926
- }
927
- const config = resolveFunctionValues(result.config);
928
- config.rootDir ??= cwd;
929
- return resolveConfig(config);
930
- }
931
- function isSea() {
932
- return process.env["JEST_ROBLOX_SEA"] === "true";
933
- }
934
- async function seaImport(id) {
935
- if (id.endsWith(".json")) {
936
- const content = readFileSync(id, "utf-8");
937
- return JSON.parse(content);
938
- }
939
- return import(id);
940
- }
941
- function merger(...sources) {
942
- return defuFn(...sources.filter(Boolean));
943
- }
944
- function isMergerFunction(value) {
945
- return typeof value === "function";
946
- }
947
- function resolveFunctionValues(config) {
948
- const resolved = {};
949
- for (const [key, value] of Object.entries(config)) resolved[key] = isMergerFunction(value) ? value(void 0) : value;
950
- return resolved;
951
- }
952
- //#endregion
953
- //#region packages/rojo-utils/dist/index.mjs
954
- function resolveNestedProjects(tree, rootDirectory) {
955
- return resolveTree(tree, rootDirectory, rootDirectory, /* @__PURE__ */ new Set());
956
- }
957
- function collectPaths(node, result) {
958
- for (const [key, value] of Object.entries(node)) if (key === "$path" && typeof value === "string") result.push(value.replaceAll("\\", "/"));
959
- else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) collectPaths(value, result);
960
- }
961
- function inlineNestedProject(projectPath, currentDirectory, originalRoot, visited) {
962
- const chain = new Set(visited);
963
- chain.add(projectPath);
964
- let content;
965
- try {
966
- content = readFileSync(projectPath, "utf-8");
967
- } catch (err) {
968
- const relativePath = relative(currentDirectory, projectPath);
969
- throw new Error(`Could not read nested Rojo project: ${relativePath}`, { cause: err });
970
- }
971
- let project;
972
- try {
973
- project = JSON.parse(content);
974
- } catch (err) {
975
- const relativePath = relative(currentDirectory, projectPath);
976
- throw new Error(`Failed to parse nested Rojo project: ${relativePath}`, { cause: err });
977
- }
978
- return resolveTree(project.tree, dirname(projectPath), originalRoot, chain);
979
- }
980
- function resolveRootRelativePath(currentDirectory, value, originalRoot) {
981
- return relative(originalRoot, join(currentDirectory, value)).replaceAll("\\", "/");
982
- }
983
- function resolveTree(node, currentDirectory, originalRoot, visited) {
984
- const resolved = {};
985
- for (const [key, value] of Object.entries(node)) {
986
- if (key === "$path" && typeof value === "string" && value.endsWith(".project.json")) {
987
- const projectPath = join(currentDirectory, value);
988
- if (visited.has(projectPath)) throw new Error(`Circular project reference: ${value}`);
989
- const innerTree = inlineNestedProject(projectPath, currentDirectory, originalRoot, visited);
990
- for (const [innerKey, innerValue] of Object.entries(innerTree)) resolved[innerKey] = innerValue;
991
- continue;
992
- }
993
- if (key === "$path" && typeof value === "string") {
994
- resolved[key] = resolveRootRelativePath(currentDirectory, value, originalRoot);
995
- continue;
996
- }
997
- if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
998
- resolved[key] = value;
999
- continue;
1000
- }
1001
- resolved[key] = resolveTree(value, currentDirectory, originalRoot, visited);
1002
- }
1003
- return resolved;
1004
- }
1005
- function collectMounts(node, currentDataModelPath, classify) {
1006
- const result = [];
1007
- walk(node, currentDataModelPath, classify, result);
1008
- return result;
1009
- }
1010
- function pruneAncestors(paths) {
1011
- return paths.filter((candidate) => !paths.some((other) => other !== candidate && candidate.startsWith(`${other}/`)));
1012
- }
1013
- function addDirectoryMount(node, dataModelPath, classify, result) {
1014
- const rawPath = node.$path;
1015
- if (typeof rawPath !== "string") return;
1016
- if (rawPath.endsWith(".project.json")) return;
1017
- const fsPath = rawPath.replace(/\/$/, "");
1018
- if (classify(fsPath) === "directory") result.push({
1019
- dataModelPath,
1020
- fsPath
1021
- });
1022
- }
1023
- function isTreeChild(value) {
1024
- return typeof value === "object" && value !== null && !Array.isArray(value);
1025
- }
1026
- function walk(node, currentDataModelPath, classify, result) {
1027
- for (const [key, value] of Object.entries(node)) {
1028
- if (key.startsWith("$") || !isTreeChild(value)) continue;
1029
- const childDataModelPath = currentDataModelPath === "" ? key : `${currentDataModelPath}/${key}`;
1030
- addDirectoryMount(value, childDataModelPath, classify, result);
1031
- walk(value, childDataModelPath, classify, result);
1032
- }
1033
- }
1034
- function matchNodePath(childNode, targetPath, childDataModelPath) {
1035
- const nodePath = childNode.$path;
1036
- if (typeof nodePath !== "string") return;
1037
- const normalizedNodePath = nodePath.replace(/\/$/, "");
1038
- if (normalizedNodePath === targetPath) return childDataModelPath;
1039
- if (targetPath.startsWith(`${normalizedNodePath}/`)) return `${childDataModelPath}/${targetPath.slice(normalizedNodePath.length + 1)}`;
1040
- }
1041
- function findInTree(node, targetPath, currentDataModelPath) {
1042
- for (const [key, value] of Object.entries(node)) {
1043
- if (key.startsWith("$") || typeof value !== "object") continue;
1044
- const childNode = value;
1045
- const childDataModelPath = currentDataModelPath === "" ? key : `${currentDataModelPath}/${key}`;
1046
- const pathMatch = matchNodePath(childNode, targetPath, childDataModelPath);
1047
- if (pathMatch !== void 0) return pathMatch;
1048
- const found = findInTree(childNode, targetPath, childDataModelPath);
1049
- if (found !== void 0) return found;
1050
- }
1051
- }
1052
- //#endregion
1053
- //#region src/utils/normalize-windows-path.ts
1054
- const DRIVE_LETTER_START_REGEX = /^[A-Za-z]:\//;
1055
- function normalizeWindowsPath(input = "") {
1056
- if (!input) return input;
1057
- return input.replace(/\\/g, "/").replace(DRIVE_LETTER_START_REGEX, (driveLetterMatch) => driveLetterMatch.toUpperCase());
1058
- }
1059
- //#endregion
1060
- //#region src/utils/tsconfig-mapping.ts
1061
- function findMapping(filePath, mappings, key = "outDir") {
1062
- let best;
1063
- let bestLength = -1;
1064
- for (const mapping of mappings) {
1065
- const prefix = mapping[key];
1066
- if ((filePath === prefix || filePath.startsWith(`${prefix}/`)) && prefix.length > bestLength) {
1067
- best = mapping;
1068
- bestLength = prefix.length;
1069
- }
1070
- }
1071
- return best;
1072
- }
1073
- function replacePrefix(filePath, from, to) {
1074
- if (filePath === from) return to;
1075
- if (filePath.startsWith(`${from}/`)) return `${to}${filePath.slice(from.length)}`;
1076
- return filePath;
1077
- }
1078
- //#endregion
1079
- //#region src/source-mapper/column-finder.ts
1080
- /**
1081
- * Finds the column position of the failing matcher in an expect() call. Returns
1082
- * 1-indexed column position, or undefined if no expect is found.
1083
- */
1084
- function findExpectationColumn(lineText) {
1085
- if (!lineText) return;
1086
- const expectIndex = lineText.search(/\bexpect\s*[.(]/);
1087
- if (expectIndex === -1) return;
1088
- const afterExpect = lineText.slice(expectIndex);
1089
- const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
1090
- let lastMatcher = null;
1091
- for (const match of afterExpect.matchAll(matcherRegex)) lastMatcher = match;
1092
- const matcherName = lastMatcher?.[1];
1093
- if (lastMatcher === null || matcherName === void 0) return;
1094
- return expectIndex + (lastMatcher.index + lastMatcher[0].indexOf(matcherName)) + 1;
1095
- }
1096
- //#endregion
1097
- //#region src/source-mapper/path-resolver.ts
1098
- function createPathResolver(rojoProject, config) {
1099
- const rojoMappings = /* @__PURE__ */ new Map();
1100
- function walkTree(tree, prefix) {
1101
- for (const [key, value] of Object.entries(tree)) {
1102
- if (key.startsWith("$") || typeof value !== "object") continue;
1103
- const dataModelPath = prefix ? `${prefix}.${key}` : key;
1104
- const node = value;
1105
- if (typeof node.$path === "string") rojoMappings.set(dataModelPath, node.$path);
1106
- walkTree(node, dataModelPath);
1107
- }
1108
- }
1109
- walkTree(rojoProject.tree, "");
1110
- const tsconfigMappings = config?.mappings ?? [];
1111
- const sortedRojoMappings = [...rojoMappings.entries()].sort(([a], [b]) => b.length - a.length);
1112
- return { resolve(dataModelPath) {
1113
- for (const [prefix, basePath] of sortedRojoMappings) {
1114
- if (dataModelPath !== prefix && !dataModelPath.startsWith(`${prefix}.`)) continue;
1115
- const result = `${basePath}/${convertToFilePath(dataModelPath.slice(prefix.length + 1))}`;
1116
- const mapping = findMapping(result, tsconfigMappings);
1117
- if (mapping !== void 0) return {
1118
- filePath: `${luauInitToIndex(replacePrefix(result, mapping.outDir, mapping.rootDir).replace(/^\.\//, ""))}.ts`,
1119
- mapping
1120
- };
1121
- return { filePath: findLuaFile(result) };
1122
- }
1123
- } };
1124
- }
1125
- function convertToFilePath(suffix) {
1126
- const parts = suffix.split(".");
1127
- const result = [];
1128
- for (let index = 0; index < parts.length; index++) {
1129
- const part = parts[index];
1130
- const nextPart = parts[index + 1];
1131
- if ((nextPart === "spec" || nextPart === "test") && index + 2 === parts.length) {
1132
- result.push(`${part}.${nextPart}`);
1133
- index++;
1134
- } else result.push(part);
1135
- }
1136
- return result.join("/");
1137
- }
1138
- /** roblox-ts compiles index.ts → init.luau; reverse the rename for TS paths. */
1139
- function luauInitToIndex(filePath) {
1140
- return filePath.replace(/(^|\/)(init)(\.|\/)/, "$1index$3");
1141
- }
1142
- function findLuaFile(basePath) {
1143
- const luauPath = `${basePath}.luau`;
1144
- if (existsSync(luauPath)) return luauPath;
1145
- const luaPath = `${basePath}.lua`;
1146
- if (existsSync(luaPath)) return luaPath;
1147
- return luauPath;
1148
- }
1149
- //#endregion
1150
- //#region src/source-mapper/stack-parser.ts
1151
- const FRAME_REGEX = /\[string "([^"]+)"\]:(\d+)(?::(\d+))?/g;
1152
- function parseStack(input) {
1153
- const frames = [];
1154
- let firstMatchIndex = input.length;
1155
- for (const match of input.matchAll(FRAME_REGEX)) {
1156
- if (match.index < firstMatchIndex) firstMatchIndex = match.index;
1157
- frames.push({
1158
- column: match[3] !== void 0 ? Number(match[3]) : void 0,
1159
- dataModelPath: String(match[1]),
1160
- line: Number(match[2])
1161
- });
1162
- }
1163
- return {
1164
- frames,
1165
- message: input.slice(0, firstMatchIndex).trim()
1166
- };
1167
- }
1168
- //#endregion
1169
- //#region src/source-mapper/v3-mapper.ts
1170
- const mapCache = /* @__PURE__ */ new Map();
1171
- function getSourceContent(luauPath, source) {
1172
- const traced = getTraceMap(luauPath);
1173
- if (traced === void 0) return;
1174
- return sourceContentFor(traced, source);
1175
- }
1176
- function mapFromSourceMap(luauPath, luauLine, luauColumn = 0) {
1177
- const traced = getTraceMap(luauPath);
1178
- if (traced === void 0) return;
1179
- const result = originalPositionFor(traced, {
1180
- column: luauColumn,
1181
- line: luauLine
1182
- });
1183
- if (result.line === null) return;
1184
- return result;
1185
- }
1186
- function getTraceMap(luauPath) {
1187
- let traced = mapCache.get(luauPath);
1188
- if (traced !== void 0) return traced;
1189
- const mapPath = `${luauPath}.map`;
1190
- if (!fs$1.existsSync(mapPath)) return;
1191
- traced = new TraceMap(fs$1.readFileSync(mapPath, "utf-8"));
1192
- mapCache.set(luauPath, traced);
1193
- return traced;
1194
- }
1195
- //#endregion
1196
- //#region src/source-mapper/index.ts
1197
- function createSourceMapper(config) {
1198
- const pathResolver = createPathResolver(config.rojoProject, { mappings: config.mappings });
1199
- function mapFrame(frame) {
1200
- const resolved = pathResolver.resolve(frame.dataModelPath);
1201
- if (resolved === void 0) return;
1202
- if (resolved.mapping === void 0) return {
1203
- luauPath: resolved.filePath,
1204
- mapped: void 0
1205
- };
1206
- const { filePath, mapping } = resolved;
1207
- const luauPath = replacePrefix(filePath, mapping.rootDir, mapping.outDir).replace(/\.ts$/, ".luau");
1208
- const v3Result = mapFromSourceMap(luauPath, frame.line, frame.column);
1209
- if (v3Result !== void 0 && v3Result.source !== null && v3Result.line !== null) {
1210
- const mapDirectory = path$1.dirname(luauPath);
1211
- const resolvedTsPath = normalizeWindowsPath(path$1.resolve(mapDirectory, v3Result.source));
1212
- const tsLine = v3Result.line;
1213
- const embeddedContent = getSourceContent(luauPath, v3Result.source) ?? void 0;
1214
- const tsContent = embeddedContent ?? (fs$1.existsSync(resolvedTsPath) ? fs$1.readFileSync(resolvedTsPath, "utf-8") : void 0);
1215
- return {
1216
- luauPath,
1217
- mapped: {
1218
- column: tsContent !== void 0 ? findExpectationColumn(tsContent.split("\n")[tsLine - 1] ?? "") : void 0,
1219
- line: tsLine,
1220
- path: resolvedTsPath,
1221
- sourceContent: embeddedContent
1222
- }
1223
- };
1224
- }
1225
- return {
1226
- luauPath,
1227
- mapped: void 0
1228
- };
1229
- }
1230
- return {
1231
- mapFailureMessage(message) {
1232
- const parsed = parseStack(message);
1233
- let result = message;
1234
- for (const frame of parsed.frames) {
1235
- const frameResult = mapFrame(frame);
1236
- if (frameResult === void 0) continue;
1237
- const original = `[string "${frame.dataModelPath}"]:${frame.line}`;
1238
- if (frameResult.mapped !== void 0) {
1239
- const mapped = `${frameResult.mapped.path}:${frameResult.mapped.line}`;
1240
- result = result.replace(original, mapped);
1241
- } else {
1242
- const replacement = `${frameResult.luauPath}:${frame.line}`;
1243
- result = result.replace(original, replacement);
1244
- }
1245
- }
1246
- return result;
1247
- },
1248
- mapFailureWithLocations(message) {
1249
- const parsed = parseStack(message);
1250
- let mappedMessage = message;
1251
- const locations = [];
1252
- for (const frame of parsed.frames) {
1253
- const frameResult = mapFrame(frame);
1254
- if (frameResult === void 0) continue;
1255
- const original = `[string "${frame.dataModelPath}"]:${frame.line}`;
1256
- if (frameResult.mapped !== void 0) {
1257
- const mapped = `${frameResult.mapped.path}:${frameResult.mapped.line}`;
1258
- mappedMessage = mappedMessage.replace(original, mapped);
1259
- locations.push({
1260
- luauLine: frame.line,
1261
- luauPath: frameResult.luauPath,
1262
- sourceContent: frameResult.mapped.sourceContent,
1263
- tsColumn: frameResult.mapped.column,
1264
- tsLine: frameResult.mapped.line,
1265
- tsPath: frameResult.mapped.path
1266
- });
1267
- } else {
1268
- const replacement = `${frameResult.luauPath}:${frame.line}`;
1269
- mappedMessage = mappedMessage.replace(original, replacement);
1270
- if (locations.length === 0) locations.push({
1271
- luauLine: frame.line,
1272
- luauPath: frameResult.luauPath
1273
- });
1274
- }
1275
- }
1276
- return {
1277
- locations,
1278
- message: mappedMessage
1279
- };
1280
- },
1281
- resolveTestFilePath(testFilePath) {
1282
- const dataModelPath = testFilePath.replace(/^\//, "").replaceAll("/", ".");
1283
- return pathResolver.resolve(dataModelPath)?.filePath;
1284
- }
1285
- };
1286
- }
1287
- /**
1288
- * Compose multiple `SourceMapper`s into one that tries every child in order.
1289
- * Used by the multi-project CLI path so that failure messages and GitHub
1290
- * annotations can resolve frames from any project's TS/Luau mapping.
1291
- *
1292
- * Each child mapper only rewrites frames it can resolve, leaving the rest
1293
- * untouched. Chaining `mapFailureMessage` / `mapFailureWithLocations` calls
1294
- * through every child is therefore safe: later mappers see the partially
1295
- * rewritten string and still parse any remaining `[string "..."]` frames.
1296
- * Locations accumulate across mappers; `resolveTestFilePath` returns the
1297
- * first child's hit.
1298
- */
1299
- function combineSourceMappers(mappers) {
1300
- if (mappers.length === 0) return;
1301
- if (mappers.length === 1) return mappers[0];
1302
- return {
1303
- mapFailureMessage(message) {
1304
- let result = message;
1305
- for (const mapper of mappers) result = mapper.mapFailureMessage(result);
1306
- return result;
1307
- },
1308
- mapFailureWithLocations(message) {
1309
- let mappedMessage = message;
1310
- const locations = [];
1311
- for (const mapper of mappers) {
1312
- const partial = mapper.mapFailureWithLocations(mappedMessage);
1313
- mappedMessage = partial.message;
1314
- locations.push(...partial.locations);
1315
- }
1316
- return {
1317
- locations,
1318
- message: mappedMessage
1319
- };
1320
- },
1321
- resolveTestFilePath(testFilePath) {
1322
- for (const mapper of mappers) {
1323
- const resolved = mapper.resolveTestFilePath(testFilePath);
1324
- if (resolved !== void 0) return resolved;
1325
- }
1326
- }
1327
- };
1328
- }
1329
- function getSourceSnippet({ column, context = 2, filePath, line, sourceContent }) {
1330
- const content = sourceContent ?? (fs$1.existsSync(filePath) ? fs$1.readFileSync(filePath, "utf-8") : void 0);
1331
- if (content === void 0) return;
1332
- const allLines = content.split("\n");
1333
- const startLine = Math.max(1, line - context);
1334
- const endLine = Math.min(allLines.length, line + context);
1335
- const lines = [];
1336
- for (let index = startLine; index <= endLine; index++) {
1337
- const lineContent = allLines[index - 1];
1338
- assert(lineContent !== void 0, `index ${index} out of bounds`);
1339
- lines.push({
1340
- content: lineContent,
1341
- num: index
1342
- });
1343
- }
1344
- const failureLineContent = allLines[line - 1] ?? "";
1345
- return {
1346
- column: column ?? findExpectationColumn(failureLineContent),
1347
- failureLine: line,
1348
- lines
1349
- };
1350
- }
1351
- //#endregion
1352
- //#region src/types/jest-result.ts
1353
- function hasExecError(file) {
1354
- return file.failureMessage !== void 0 && file.failureMessage !== "" && file.testResults.length === 0;
1355
- }
1356
- //#endregion
1357
- //#region src/utils/banner.ts
1358
- const SEPARATOR$1 = "⎯";
1359
- const levelStyles = {
1360
- error: {
1361
- badge: (text) => color.bgRed(color.white(color.bold(text))),
1362
- separator: color.red
1363
- },
1364
- warn: {
1365
- badge: (text) => color.bgYellow(color.black(color.bold(text))),
1366
- separator: color.yellow
1367
- }
1368
- };
1369
- function formatBannerBar({ level, termWidth, title }) {
1370
- const width = termWidth ?? getDefaultWidth();
1371
- const styles = levelStyles[level];
1372
- const badgeText = ` ${title} `;
1373
- const badge = styles.badge(badgeText);
1374
- const remaining = width - badgeText.length;
1375
- const leftWidth = Math.max(1, Math.floor(remaining / 2));
1376
- const rightWidth = Math.max(1, remaining - leftWidth);
1377
- return `${styles.separator(SEPARATOR$1.repeat(leftWidth))}${badge}${styles.separator(SEPARATOR$1.repeat(rightWidth))}`;
1378
- }
1379
- function formatBanner({ body, level, termWidth, title }) {
1380
- const width = termWidth ?? getDefaultWidth();
1381
- const styles = levelStyles[level];
1382
- const header = formatBannerBar({
1383
- level,
1384
- termWidth: width,
1385
- title
1386
- });
1387
- const closing = styles.separator(SEPARATOR$1.repeat(width));
1388
- return `\n${header}\n${body.length > 0 ? `\n${body.join("\n")}\n` : ""}\n${closing}\n\n`;
1389
- }
1390
- function getDefaultWidth() {
1391
- return process.stderr.columns || 80;
1392
- }
1393
- //#endregion
1394
- //#region src/highlighter/luau-grammar.ts
1395
- const OPENING_LONG_BRACKET = "\\[=*\\[";
1396
- const CLOSING_LONG_BRACKET = "\\]=*\\]";
1397
- const BUILT_IN = "_G _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert __idiv __iter newproxy rawlen collectgarbage error getfenv getmetatable ipairs loadstring next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall self coroutine resume yield status wrap create running debug traceback math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os date difftime time clock string sub upper len rep find match char gmatch reverse byte format gsub lower table insert getn foreachi maxn foreach concat sort remove game workspace script plugin Instance Enum describe it expect test beforeAll afterAll beforeEach afterEach jest toBe toEqual toContain toThrow toHaveBeenCalled";
1398
- const KEYWORD = "and break continue do else elseif end for function if in local not or repeat return then until while type export";
1399
- function luauGrammar(hljs) {
1400
- const longBrackets = {
1401
- begin: OPENING_LONG_BRACKET,
1402
- contains: ["self"],
1403
- end: CLOSING_LONG_BRACKET
1404
- };
1405
- const comments = [hljs.COMMENT(`--(?!${OPENING_LONG_BRACKET})`, "$"), hljs.COMMENT(`--${OPENING_LONG_BRACKET}`, CLOSING_LONG_BRACKET, {
1406
- contains: [longBrackets],
1407
- relevance: 10
1408
- })];
1409
- return {
1410
- name: "Luau",
1411
- contains: [
1412
- ...comments,
1413
- {
1414
- beginKeywords: "function",
1415
- contains: [
1416
- hljs.inherit(hljs.TITLE_MODE, { begin: "([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*" }),
1417
- {
1418
- begin: "\\(",
1419
- contains: comments,
1420
- endsWithParent: true,
1421
- scope: "params"
1422
- },
1423
- ...comments
1424
- ],
1425
- end: "\\)",
1426
- scope: "function"
1427
- },
1428
- hljs.C_NUMBER_MODE,
1429
- hljs.APOS_STRING_MODE,
1430
- hljs.QUOTE_STRING_MODE,
1431
- {
1432
- begin: OPENING_LONG_BRACKET,
1433
- contains: [longBrackets],
1434
- end: CLOSING_LONG_BRACKET,
1435
- relevance: 5,
1436
- scope: "string"
1437
- },
1438
- {
1439
- begin: "`",
1440
- contains: [{
1441
- begin: "\\{",
1442
- end: "\\}",
1443
- scope: "subst"
1444
- }],
1445
- end: "`",
1446
- scope: "string"
1447
- }
1448
- ],
1449
- keywords: {
1450
- $pattern: hljs.UNDERSCORE_IDENT_RE,
1451
- built_in: BUILT_IN,
1452
- keyword: KEYWORD,
1453
- literal: "true false nil"
1454
- }
1455
- };
1456
- }
1457
- //#endregion
1458
- //#region src/utils/colors.ts
1459
- hljs.registerLanguage("luau", luauGrammar);
1460
- hljs.registerLanguage("typescript", typescript);
1461
- const EXTENSION_NAME_REGEX = /.(\.[^./]+|\.)$/;
1462
- const TS_SUPPORTED_EXTS = new Set(["js", "ts"].flatMap((lang) => [
1463
- `.${lang}`,
1464
- `.m${lang}`,
1465
- `.c${lang}`,
1466
- `.${lang}x`,
1467
- `.m${lang}x`,
1468
- `.c${lang}x`
1469
- ]));
1470
- const LUAU_SUPPORTED_EXTS = new Set([".lua", ".luau"]);
1471
- function highlightCode(id, source) {
1472
- const extension = extname(id);
1473
- if (LUAU_SUPPORTED_EXTS.has(extension)) return highlightLuau(source);
1474
- if (TS_SUPPORTED_EXTS.has(extension)) return highlightTypeScript(source);
1475
- return source;
1476
- }
1477
- function extname(path) {
1478
- if (path === "..") return "";
1479
- return EXTENSION_NAME_REGEX.exec(normalizeWindowsPath(path))?.[1] ?? "";
1480
- }
1481
- const HLJS_CLASS_TO_COLOR = {
1482
- "hljs-attr": color.blue,
1483
- "hljs-built_in": color.blue,
1484
- "hljs-comment": color.gray,
1485
- "hljs-function": color.blue,
1486
- "hljs-keyword": color.magenta,
1487
- "hljs-literal": color.blue,
1488
- "hljs-meta": color.gray,
1489
- "hljs-number": color.blue,
1490
- "hljs-operator": color.yellow,
1491
- "hljs-params": color.white,
1492
- "hljs-punctuation": color.yellow,
1493
- "hljs-regexp": color.cyan,
1494
- "hljs-string": color.green,
1495
- "hljs-subst": color.cyan,
1496
- "hljs-title": color.blue,
1497
- "hljs-type": color.yellow,
1498
- "hljs-variable": color.white
1499
- };
1500
- function convertHljsToAnsi(html) {
1501
- let result = html;
1502
- let previous = "";
1503
- while (result !== previous) {
1504
- previous = result;
1505
- result = result.replace(/<span class="([^"]+)">([^<]*)<\/span>/g, (_, cssClasses, content) => {
1506
- const primaryClass = String(cssClasses).split(" ")[0];
1507
- assert(primaryClass !== void 0, "split always returns ≥1 element");
1508
- const text = String(content);
1509
- const colorFunc = HLJS_CLASS_TO_COLOR[primaryClass];
1510
- return colorFunc?.(text) ?? text;
1511
- });
1512
- }
1513
- return result.replace(/&quot;/g, "\"").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
1514
- }
1515
- function highlightLuau(source) {
1516
- return convertHljsToAnsi(hljs.highlight(source, { language: "luau" }).value);
1517
- }
1518
- function highlightTypeScript(source) {
1519
- return convertHljsToAnsi(hljs.highlight(source, { language: "typescript" }).value).replace(/=>/g, color.yellow("=>"));
1520
- }
1521
- //#endregion
1522
- //#region src/formatters/formatter.ts
1523
- const EXEC_ERROR_HINTS = [[/loadstring\(\) is not available/, "loadstring() must be enabled for Jest to run. Add to your project.json:\n\n \"ServerScriptService\": {\n \"$properties\": {\n \"LoadStringEnabled\": true\n }\n }"]];
1524
- function getExecErrorHint(message) {
1525
- for (const [pattern, hint] of EXEC_ERROR_HINTS) if (pattern.test(message)) return hint;
1526
- }
1527
- function formatFailedTestsHeader(failCount, _styles) {
1528
- return formatBannerBar({
1529
- level: "error",
1530
- termWidth: getTerminalWidth(),
1531
- title: `Failed Tests ${failCount}`
1532
- });
1533
- }
1534
- function parseErrorMessage(message) {
1535
- const lines = message.split("\n");
1536
- const firstLine = lines[0];
1537
- assert(firstLine !== void 0, "split always returns ≥1 element");
1538
- const snapshotHeaderIndex = lines.findIndex((line) => /^- Snapshot\s+- \d+/.test(line));
1539
- if (snapshotHeaderIndex !== -1) {
1540
- const diffLines = [];
1541
- for (let index = snapshotHeaderIndex; index < lines.length; index++) {
1542
- const line = lines[index];
1543
- if (line.startsWith("[string ")) break;
1544
- diffLines.push(line);
1545
- }
1546
- return {
1547
- message: firstLine,
1548
- snapshotDiff: diffLines.join("\n").trimEnd()
1549
- };
1550
- }
1551
- const expectedMatch = message.match(/Expected\b.*?:\s*(.+)/);
1552
- const receivedMatch = message.match(/Received\b.*?:\s*(.+)/);
1553
- return {
1554
- expected: expectedMatch?.[1],
1555
- message: firstLine,
1556
- received: receivedMatch?.[1]
1557
- };
1558
- }
1559
- /**
1560
- * Extracts the meaningful error message from a Jest `failureMessage` string.
1561
- * Strips the "● Test suite failed to run" header, Roblox DataModel path chains,
1562
- * and stack trace lines.
1563
- */
1564
- function cleanExecErrorMessage(raw) {
1565
- if (raw === "") return "";
1566
- const lines = raw.split("\n");
1567
- let contentLine;
1568
- let pastHeader = false;
1569
- for (const line of lines) {
1570
- const trimmed = line.trim();
1571
- if (trimmed.startsWith("●")) {
1572
- pastHeader = true;
1573
- continue;
1574
- }
1575
- if (pastHeader && trimmed !== "") {
1576
- contentLine = trimmed;
1577
- break;
1578
- }
1579
- }
1580
- if (contentLine === void 0) return raw.trim();
1581
- return contentLine.replace(/^(?:[A-Za-z][\w.@-]*:\d+:\s*)+/, "");
1582
- }
1583
- function formatSourceSnippet(snippet, filePath, options) {
1584
- const useColor = options?.useColor ?? true;
1585
- const styles = options?.styles ?? createStyles(useColor);
1586
- const language = options?.language;
1587
- const lines = [];
1588
- const indent = " ";
1589
- const location = snippet.column !== void 0 ? `${filePath}:${snippet.failureLine}:${snippet.column}` : `${filePath}:${snippet.failureLine}`;
1590
- const langSuffix = language !== void 0 ? styles.dim(` (${language})`) : "";
1591
- lines.push(styles.location(` ❯ ${location}`) + langSuffix);
1592
- const maxLineNumber = Math.max(...snippet.lines.map((line) => line.num));
1593
- const padding = String(maxLineNumber).length;
1594
- for (const line of snippet.lines) {
1595
- const prefix = `${String(line.num).padStart(padding)}|`;
1596
- const highlighted = highlightSyntax(filePath, expandTabs(line.content), useColor);
1597
- if (line.num === snippet.failureLine) {
1598
- lines.push(`${indent}${styles.lineNumber(prefix)} ${highlighted}`);
1599
- if (snippet.column !== void 0) {
1600
- const beforeColumn = expandTabs(line.content.slice(0, snippet.column - 1));
1601
- const caretGutter = `${" ".repeat(padding)}|`;
1602
- const gutterPrefix = styles.lineNumber(caretGutter);
1603
- lines.push(`${indent}${gutterPrefix} ${" ".repeat(beforeColumn.length)}${styles.status.fail("^")}`);
1604
- }
1605
- } else lines.push(`${indent}${styles.lineNumber(prefix)} ${highlighted}`);
1606
- }
1607
- return lines.join("\n");
1608
- }
1609
- function parseSourceLocation(message) {
1610
- const match = message.match(/([^\s:]+\.(?:tsx?|luau?)):(\d+)(?::(\d+))?/);
1611
- if (match === null) return;
1612
- const [, filePath, lineStr, columnStr] = match;
1613
- assert(filePath !== void 0, "regex group 1 matched");
1614
- assert(lineStr !== void 0, "regex group 2 matched");
1615
- return {
1616
- column: columnStr !== void 0 ? Number.parseInt(columnStr, 10) : void 0,
1617
- line: Number.parseInt(lineStr, 10),
1618
- path: filePath
1619
- };
1620
- }
1621
- function resolveDisplayPath(testFilePath, sourceMapper) {
1622
- return sourceMapper?.resolveTestFilePath(testFilePath) ?? testFilePath;
1623
- }
1624
- function formatFailure({ failureIndex, filePath, showLuau = false, sourceMapper, styles, test, totalFailures, useColor = true }) {
1625
- const st = styles ?? createStyles(useColor);
1626
- const lines = [];
1627
- const pathParts = filePath !== void 0 ? [filePath] : [];
1628
- pathParts.push(...test.ancestorTitles, test.title);
1629
- const testPath = pathParts.join(" > ");
1630
- lines.push("", `${st.failBadge(" FAIL ")} ${st.status.fail(testPath)}`);
1631
- for (const originalMessage of test.failureMessages) lines.push(...formatFailureMessage(originalMessage, {
1632
- filePath,
1633
- showLuau,
1634
- sourceMapper,
1635
- styles: st,
1636
- useColor
1637
- }));
1638
- if (failureIndex !== void 0 && totalFailures !== void 0) {
1639
- const counter = `[${failureIndex}/${totalFailures}]`;
1640
- const termWidth = getTerminalWidth();
1641
- const fillWidth = Math.max(1, termWidth - counter.length - 3);
1642
- lines.push("", st.dim(st.status.fail(`${"⎯".repeat(fillWidth)}${counter}⎯`)));
1643
- }
1644
- return lines.map((line) => ` ${line}`).join("\n");
1645
- }
1646
- function formatRunHeader(options, styles) {
1647
- const st = styles ?? createStyles(options.color);
1648
- const header = `\n${st.runBadge(" RUN ")} ${st.location(`v${options.version}`)} ${st.lineNumber(options.rootDir)}`;
1649
- if (options.collectCoverage === true) return `${header}\n${`${st.dim(" Coverage enabled with")} ${st.status.pending("istanbul")}`}\n`;
1650
- return `${header}\n`;
1651
- }
1652
- function formatTestSummary(result, timing, styles, options) {
1653
- const st = styles ?? createStyles(true);
1654
- const lines = [];
1655
- const execErrorFiles = result.testResults.filter(hasExecError).length;
1656
- const totalFiles = result.testResults.length;
1657
- const failedFiles = result.testResults.filter((fr) => fr.numFailingTests > 0).length + execErrorFiles;
1658
- const skippedFiles = result.testResults.filter((fr) => fr.numFailingTests === 0 && fr.numPassingTests === 0 && !hasExecError(fr)).length;
1659
- const passedFiles = totalFiles - failedFiles - skippedFiles;
1660
- const fileParts = [];
1661
- if (passedFiles > 0) fileParts.push(st.summary.passed(`${passedFiles} passed`));
1662
- if (failedFiles > 0) fileParts.push(st.summary.failed(`${failedFiles} failed`));
1663
- if (skippedFiles > 0) fileParts.push(st.summary.pending(`${skippedFiles} skipped`));
1664
- const snapshotLine = formatSnapshotLine(result.snapshot, st);
1665
- if (snapshotLine !== void 0) lines.push(snapshotLine);
1666
- const fileTotalLabel = st.dim(`(${totalFiles})`);
1667
- lines.push(`${st.dim(" Test Files")} ${fileParts.join(" | ")} ${fileTotalLabel}`);
1668
- const testParts = [];
1669
- if (result.numPassedTests > 0) testParts.push(st.summary.passed(`${result.numPassedTests} passed`));
1670
- if (result.numFailedTests > 0) testParts.push(st.summary.failed(`${result.numFailedTests} failed`));
1671
- if (result.numPendingTests > 0) testParts.push(st.summary.pending(`${result.numPendingTests} skipped`));
1672
- const testTotalLabel = st.dim(`(${result.numTotalTests})`);
1673
- lines.push(`${st.dim(" Tests")} ${testParts.join(" | ")} ${testTotalLabel}`);
1674
- if (options?.typeErrors !== void 0) {
1675
- const typeErrorLabel = st.dim("Type Errors");
1676
- const typeErrorValue = options.typeErrors > 0 ? st.summary.failed(`${options.typeErrors} failed`) : st.dim("no errors");
1677
- lines.push(`${typeErrorLabel} ${typeErrorValue}`);
1678
- }
1679
- const startAtStr = new Date(timing.startTime).toLocaleTimeString("en-GB", { hour12: false });
1680
- lines.push(`${st.dim(" Start at")} ${startAtStr}`);
1681
- const setupMs = timing.setupMs ?? 0;
1682
- const environmentMs = Math.max(0, timing.executionMs - timing.testsMs - setupMs);
1683
- const uploadMs = timing.uploadMs ?? 0;
1684
- const coverageMs = timing.coverageMs ?? 0;
1685
- const cliMs = Math.max(0, timing.totalMs - uploadMs - timing.executionMs - coverageMs);
1686
- const breakdownParts = [];
1687
- if (timing.uploadMs !== void 0) breakdownParts.push(timing.uploadCached === true ? `upload ${timing.uploadMs}ms (cached)` : `upload ${timing.uploadMs}ms`);
1688
- breakdownParts.push(`environment ${environmentMs}ms`);
1689
- if (setupMs > 0) breakdownParts.push(`setup ${setupMs}ms`);
1690
- breakdownParts.push(`tests ${timing.testsMs}ms`, `cli ${cliMs}ms`);
1691
- if (coverageMs > 0) breakdownParts.push(`coverage ${coverageMs}ms`);
1692
- const breakdown = st.dim(`(${breakdownParts.join(", ")})`);
1693
- lines.push(`${st.dim(" Duration")} ${timing.totalMs}ms ${breakdown}`);
1694
- return lines.join("\n");
1695
- }
1696
- function formatResult(result, timing, options) {
1697
- const styles = createStyles(options.color);
1698
- const lines = [formatRunHeader(options, styles)];
1699
- for (const file of result.testResults) {
1700
- if (options.failuresOnly === true && file.numFailingTests === 0 && !hasExecError(file)) continue;
1701
- lines.push(formatFileSummary(file, options, styles));
1702
- }
1703
- const execErrors = result.testResults.filter(hasExecError);
1704
- const totalDetailedFailures = result.numFailedTests + execErrors.length;
1705
- if (totalDetailedFailures > 0) {
1706
- lines.push("", formatFailedTestsHeader(totalDetailedFailures, styles));
1707
- const failureCtx = {
1708
- currentIndex: 1,
1709
- totalFailures: totalDetailedFailures
1710
- };
1711
- for (const file of result.testResults) {
1712
- const failures = formatFileFailures(file, options, styles, failureCtx);
1713
- if (failures !== "") lines.push(failures);
1714
- }
1715
- for (const file of execErrors) lines.push(formatExecErrorDetail(file, styles, failureCtx, options.sourceMapper));
1716
- }
1717
- lines.push("", formatTestSummary(result, timing, styles, { typeErrors: options.typeErrors }));
1718
- if (!result.success) {
1719
- const hints = formatLogHints(options, styles);
1720
- if (hints !== "") lines.push("", hints);
1721
- }
1722
- return lines.join("\n");
1723
- }
1724
- function formatTypecheckFailures(result, useColor = true) {
1725
- const styles = createStyles(useColor);
1726
- const lines = [];
1727
- for (const file of result.testResults) for (const test of file.testResults) {
1728
- if (test.status !== "failed") continue;
1729
- const badge = styles.failBadge(" FAIL ");
1730
- lines.push(` ${badge} ${styles.status.fail(test.fullName)}`);
1731
- for (const message of test.failureMessages) lines.push(` ${styles.dim(message)}`);
1732
- }
1733
- return lines.join("\n");
1734
- }
1735
- function formatTypecheckSummary(result, useColor = true) {
1736
- const styles = createStyles(useColor);
1737
- const passed = result.numPassedTests;
1738
- const failed = result.numFailedTests;
1739
- const total = result.numTotalTests;
1740
- const parts = [];
1741
- if (failed > 0) parts.push(formatTypecheckFailures(result, useColor));
1742
- const failedLabel = styles.summary.failed(`${String(failed)} failed`);
1743
- const failedPart = failed > 0 ? `${failedLabel}, ` : "";
1744
- const passedPart = styles.summary.passed(`${String(passed)} passed`);
1745
- const label = styles.dim("Type Tests:");
1746
- parts.push(`\n${label} ${failedPart}${passedPart}, ${String(total)} total\n`);
1747
- return parts.join("\n");
1748
- }
1749
- const PROJECT_BADGE_COLORS = [
1750
- (text) => color.bgYellow(color.black(text)),
1751
- (text) => color.bgCyan(color.black(text)),
1752
- (text) => color.bgGreen(color.black(text)),
1753
- (text) => color.bgMagenta(color.black(text))
1754
- ];
1755
- const NAMED_BADGE_COLORS = {
1756
- blue: (text) => color.bgBlue(color.white(text)),
1757
- cyan: (text) => color.bgCyan(color.black(text)),
1758
- green: (text) => color.bgGreen(color.black(text)),
1759
- magenta: (text) => color.bgMagenta(color.black(text)),
1760
- red: (text) => color.bgRed(color.white(text)),
1761
- white: (text) => color.bgWhite(color.black(text)),
1762
- yellow: (text) => color.bgYellow(color.black(text))
1763
- };
1764
- function formatProjectBadge(displayName, useColor, displayColor) {
1765
- if (!useColor) return `▶ ${displayName}`;
1766
- return `▶ ${resolveBadgeColor(displayName, displayColor)(` ${displayName} `)}`;
1767
- }
1768
- function formatProjectHeader(options) {
1769
- const { displayColor, displayName, result, styles: headerStyles, useColor = true } = options;
1770
- const resolved = headerStyles ?? createStyles(useColor);
1771
- const stats = computeProjectStats(result);
1772
- const parts = [];
1773
- if (stats.passedFiles > 0) parts.push(resolved.summary.passed(`${stats.passedFiles} passed`));
1774
- if (stats.failedFiles > 0) parts.push(resolved.summary.failed(`${stats.failedFiles} failed`));
1775
- if (stats.skippedFiles > 0) parts.push(resolved.summary.pending(`${stats.skippedFiles} skipped`));
1776
- const duration = stats.durationMs > 0 ? ` - ${stats.durationMs}ms` : "";
1777
- const meta = resolved.dim(`(${stats.totalTests} tests${duration})`);
1778
- const fileStats = parts.join(" | ");
1779
- return `${formatProjectBadge(displayName, useColor, displayColor)} ${fileStats} ${meta}`;
1780
- }
1781
- function formatProjectSection(section) {
1782
- const { displayColor, displayName, failureCtx, options, result, styles: sectionStyles } = section;
1783
- const resolved = sectionStyles ?? createStyles(options.color);
1784
- const lines = [formatProjectHeader({
1785
- displayColor,
1786
- displayName,
1787
- result,
1788
- styles: resolved,
1789
- useColor: options.color
1790
- })];
1791
- for (const file of result.testResults) {
1792
- if (options.failuresOnly === true && file.numFailingTests === 0 && !hasExecError(file)) continue;
1793
- lines.push(formatFileSummary(file, options, resolved));
1794
- }
1795
- const execErrors = result.testResults.filter(hasExecError);
1796
- if (result.numFailedTests + execErrors.length > 0) {
1797
- for (const file of result.testResults) {
1798
- const failures = formatFileFailures(file, options, resolved, failureCtx);
1799
- if (failures !== "") lines.push(failures);
1800
- }
1801
- for (const file of execErrors) lines.push(formatExecErrorDetail(file, resolved, failureCtx, options.sourceMapper));
1802
- }
1803
- return lines.join("\n");
1804
- }
1805
- function formatMultiProjectResult(projects, timing, options) {
1806
- const styles = createStyles(options.color);
1807
- let totalFailures = 0;
1808
- for (const { result } of projects) {
1809
- const execErrors = result.testResults.filter(hasExecError).length;
1810
- totalFailures += result.numFailedTests + execErrors;
1811
- }
1812
- const failureCtx = {
1813
- currentIndex: 1,
1814
- totalFailures
1815
- };
1816
- const sections = [];
1817
- for (const { displayColor, displayName, result } of projects) sections.push(formatProjectSection({
1818
- displayColor,
1819
- displayName,
1820
- failureCtx,
1821
- options,
1822
- result,
1823
- styles
1824
- }));
1825
- const lines = [formatRunHeader(options, styles), sections.join("\n\n")];
1826
- const mergedResult = mergeJestResults(projects.map((project) => project.result));
1827
- lines.push("", formatTestSummary(mergedResult, timing, styles, { typeErrors: options.typeErrors }));
1828
- if (!mergedResult.success) {
1829
- const hints = formatLogHints(options, styles);
1830
- if (hints !== "") lines.push("", hints);
1831
- }
1832
- return lines.join("\n");
1833
- }
1834
- function hashProjectName(name) {
1835
- let hash = 0;
1836
- for (let index = 0; index < name.length; index++) hash += name.charCodeAt(index) + index;
1837
- return hash % PROJECT_BADGE_COLORS.length;
1838
- }
1839
- function resolveBadgeColor(displayName, displayColor) {
1840
- if (displayColor !== void 0) {
1841
- const named = NAMED_BADGE_COLORS[displayColor];
1842
- if (named !== void 0) return named;
1843
- }
1844
- const hashed = PROJECT_BADGE_COLORS[hashProjectName(displayName)];
1845
- assert(hashed !== void 0, "hash always returns valid index");
1846
- return hashed;
1847
- }
1848
- function identity(text) {
1849
- return text;
1850
- }
1851
- function createStyles(useColor) {
1852
- if (!useColor) return {
1853
- diff: {
1854
- expected: identity,
1855
- received: identity
1856
- },
1857
- dim: identity,
1858
- failBadge: identity,
1859
- lineNumber: identity,
1860
- location: identity,
1861
- path: {
1862
- dir: identity,
1863
- file: identity
1864
- },
1865
- runBadge: identity,
1866
- status: {
1867
- fail: identity,
1868
- pass: identity,
1869
- pending: identity
1870
- },
1871
- summary: {
1872
- failed: identity,
1873
- passed: identity,
1874
- pending: identity
1875
- }
1876
- };
1877
- return {
1878
- diff: {
1879
- expected: color.green,
1880
- received: color.red
1881
- },
1882
- dim: color.dim,
1883
- failBadge: (text) => color.bgRed(color.white(color.bold(text))),
1884
- lineNumber: color.gray,
1885
- location: color.cyan,
1886
- path: {
1887
- dir: color.dim,
1888
- file: color.bold
1889
- },
1890
- runBadge: (text) => color.bgCyan(color.black(color.bold(text))),
1891
- status: {
1892
- fail: color.red,
1893
- pass: color.green,
1894
- pending: color.yellow
1895
- },
1896
- summary: {
1897
- failed: (text) => color.bold(color.red(text)),
1898
- passed: (text) => color.bold(color.green(text)),
1899
- pending: (text) => color.bold(color.yellow(text))
1900
- }
1901
- };
1902
- }
1903
- function sumFileDuration(file) {
1904
- let total = 0;
1905
- for (const test of file.testResults) if (test.duration !== void 0) total += test.duration;
1906
- return total;
1907
- }
1908
- function computeProjectStats(result) {
1909
- let durationMs = 0;
1910
- let failedFiles = 0;
1911
- let passedFiles = 0;
1912
- let skippedFiles = 0;
1913
- for (const file of result.testResults) {
1914
- if (file.numFailingTests > 0 || hasExecError(file)) failedFiles++;
1915
- else if (file.numPassingTests === 0 && file.numPendingTests > 0) skippedFiles++;
1916
- else passedFiles++;
1917
- durationMs += sumFileDuration(file);
1918
- }
1919
- return {
1920
- durationMs,
1921
- failedFiles,
1922
- passedFiles,
1923
- skippedFiles,
1924
- totalTests: result.numTotalTests
1925
- };
1926
- }
1927
- function formatFileFailures(file, options, styles, failureCtx) {
1928
- const lines = [];
1929
- const displayPath = resolveDisplayPath(file.testFilePath, options.sourceMapper);
1930
- for (const testCase of file.testResults) if (testCase.status === "failed") {
1931
- const index = failureCtx.currentIndex;
1932
- failureCtx.currentIndex++;
1933
- lines.push(formatFailure({
1934
- failureIndex: index,
1935
- filePath: displayPath,
1936
- showLuau: options.showLuau,
1937
- sourceMapper: options.sourceMapper,
1938
- styles,
1939
- test: testCase,
1940
- totalFailures: failureCtx.totalFailures,
1941
- useColor: options.color
1942
- }));
1943
- }
1944
- return lines.join("\n");
1945
- }
1946
- function getTerminalWidth() {
1947
- return "columns" in process.stdout && process.stdout.columns || 80;
1948
- }
1949
- function formatExecErrorDetail(file, styles, failureCtx, sourceMapper) {
1950
- const lines = [];
1951
- const index = failureCtx.currentIndex;
1952
- failureCtx.currentIndex++;
1953
- assert(file.failureMessage !== void 0, "exec error files have failureMessage");
1954
- const displayPath = resolveDisplayPath(file.testFilePath, sourceMapper);
1955
- const errorMessage = cleanExecErrorMessage(file.failureMessage);
1956
- const counter = `[${index}/${failureCtx.totalFailures}]`;
1957
- const termWidth = getTerminalWidth();
1958
- const fillWidth = Math.max(1, termWidth - counter.length - 3);
1959
- const separator = styles.dim(styles.status.fail(`${"⎯".repeat(fillWidth)}${counter}\u23af`));
1960
- lines.push(` ${styles.failBadge(" FAIL ")} ${styles.status.fail(displayPath)}`, ` ${styles.status.fail("Test suite failed to run")}`, "", ` ${styles.status.fail(errorMessage)}`);
1961
- const hint = getExecErrorHint(errorMessage);
1962
- if (hint !== void 0) lines.push("", ` ${styles.dim("Hint:")} ${hint}`);
1963
- lines.push("", ` ${separator}`);
1964
- return lines.join("\n");
1965
- }
1966
- function formatTestInGroup(testCase, styles) {
1967
- const duration = testCase.duration !== void 0 ? styles.lineNumber(` ${testCase.duration}ms`) : "";
1968
- if (testCase.status === "passed") return `${styles.status.pass(" ✓")}${styles.status.fail(` ${testCase.title}`)}${duration}`;
1969
- const failedText = ` × ${testCase.title}`;
1970
- return `${styles.status.fail(failedText)}${duration}`;
1971
- }
1972
- function formatDescribeGroup(describeName, tests, styles) {
1973
- const lines = [];
1974
- const groupHasFailure = tests.some((testCase) => testCase.status === "failed");
1975
- const groupTestCount = tests.length;
1976
- const groupDuration = tests.reduce((sum, testCase) => sum + (testCase.duration ?? 0), 0);
1977
- const groupDurationStr = styles.lineNumber(` ${groupDuration}ms`);
1978
- if (groupHasFailure) {
1979
- const failedCount = tests.filter((testCase) => testCase.status === "failed").length;
1980
- const groupMeta = styles.dim(`(${groupTestCount} tests | `) + styles.summary.failed(`${failedCount} failed`) + styles.dim(")");
1981
- const header = styles.status.fail(` ❯ ${describeName}`);
1982
- lines.push(`${header} ${groupMeta}${groupDurationStr}`);
1983
- for (const testCase of tests) lines.push(formatTestInGroup(testCase, styles));
1984
- } else {
1985
- const groupMeta = styles.dim(`(${groupTestCount} tests)`);
1986
- const marker = styles.status.pass(" ✓");
1987
- const name = styles.status.fail(` ${describeName}`);
1988
- lines.push(`${marker}${name} ${groupMeta}${groupDurationStr}`);
1989
- }
1990
- return lines;
1991
- }
1992
- function groupByDescribe(tests) {
1993
- const groups = /* @__PURE__ */ new Map();
1994
- for (const test of tests) {
1995
- const describeName = test.ancestorTitles[0] ?? "(root)";
1996
- const group = groups.get(describeName);
1997
- if (group !== void 0) group.push(test);
1998
- else groups.set(describeName, [test]);
1999
- }
2000
- return groups;
2001
- }
2002
- function formatFailedFileSummary(file, testCount, styles, displayPath) {
2003
- const lines = [];
2004
- const failedMeta = styles.summary.failed(`${file.numFailingTests} failed`);
2005
- const meta = `${styles.dim(`(${testCount} tests | `)}${failedMeta}${styles.dim(")")}`;
2006
- const header = styles.status.fail(` ❯ ${displayPath}`);
2007
- lines.push(`${header} ${meta}`);
2008
- const groups = groupByDescribe(file.testResults);
2009
- for (const [describeName, tests] of groups) lines.push(...formatDescribeGroup(describeName, tests, styles));
2010
- return lines;
2011
- }
2012
- function formatFilePath(filePath, styles) {
2013
- const directory = path.dirname(filePath);
2014
- const base = path.basename(filePath);
2015
- const directoryWithSlash = styles.path.dir(`${directory}/`);
2016
- const fileName = styles.path.file(base);
2017
- return directory && directory !== "." ? directoryWithSlash + fileName : fileName;
2018
- }
2019
- function formatExecErrorFileSummary(file, formattedPath, styles) {
2020
- const symbol = styles.status.fail("✗");
2021
- assert(file.failureMessage !== void 0, "exec error files have failureMessage");
2022
- const errorMessage = cleanExecErrorMessage(file.failureMessage);
2023
- return [` ${symbol} ${formattedPath}`, ` ${styles.status.fail(errorMessage)}`];
2024
- }
2025
- function formatPass(test, styles) {
2026
- const duration = test.duration !== void 0 ? styles.dim(` ${test.duration}ms`) : "";
2027
- return styles.status.pass(` ✓ ${test.fullName}`) + duration;
2028
- }
2029
- function formatPassedFileSummary(file, ctx) {
2030
- const lines = [];
2031
- const fileMs = sumFileDuration(file);
2032
- const symbol = ctx.styles.status.pass("✓");
2033
- const duration = fileMs > 0 ? ` - ${fileMs}ms` : "";
2034
- const meta = ctx.styles.dim(`(${ctx.testCount} tests${duration})`);
2035
- lines.push(` ${symbol} ${ctx.formattedPath} ${meta}`);
2036
- if (ctx.verbose) {
2037
- for (const testCase of file.testResults) if (testCase.status === "passed") lines.push(formatPass(testCase, ctx.styles));
2038
- }
2039
- return lines;
2040
- }
2041
- function formatFileSummary(file, options, styles) {
2042
- const displayPath = resolveDisplayPath(file.testFilePath, options.sourceMapper);
2043
- const formattedPath = formatFilePath(displayPath, styles);
2044
- const testCount = file.numPassingTests + file.numFailingTests + file.numPendingTests;
2045
- if (file.numFailingTests > 0) return formatFailedFileSummary(file, testCount, styles, displayPath).join("\n");
2046
- if (hasExecError(file)) return formatExecErrorFileSummary(file, formattedPath, styles).join("\n");
2047
- if (file.numPassingTests === 0 && file.numPendingTests > 0) return ` ${styles.status.pending("↓")} ${formattedPath} ${styles.dim(`(${testCount} tests)`)}`;
2048
- return formatPassedFileSummary(file, {
2049
- formattedPath,
2050
- styles,
2051
- testCount,
2052
- verbose: options.verbose
2053
- }).join("\n");
2054
- }
2055
- function formatLogHints(options, styles) {
2056
- const lines = [];
2057
- if (options.outputFile !== void 0) lines.push(styles.dim(` View ${options.outputFile} for full Jest output`));
2058
- if (options.gameOutput !== void 0) lines.push(styles.dim(` View ${options.gameOutput} for Roblox game logs`));
2059
- return lines.join("\n");
2060
- }
2061
- function mergeJestResults(results) {
2062
- let numberFailedTests = 0;
2063
- let numberPassedTests = 0;
2064
- let numberPendingTests = 0;
2065
- let numberTodoTests = 0;
2066
- let numberTotalTests = 0;
2067
- let startTime = Number.POSITIVE_INFINITY;
2068
- let success = true;
2069
- let snapshotAdded = 0;
2070
- let snapshotMatched = 0;
2071
- let snapshotTotal = 0;
2072
- let snapshotUnmatched = 0;
2073
- let snapshotUpdated = 0;
2074
- let hasSnapshot = false;
2075
- const testResults = [];
2076
- for (const result of results) {
2077
- numberFailedTests += result.numFailedTests;
2078
- numberPassedTests += result.numPassedTests;
2079
- numberPendingTests += result.numPendingTests;
2080
- numberTodoTests += result.numTodoTests ?? 0;
2081
- numberTotalTests += result.numTotalTests;
2082
- startTime = Math.min(startTime, result.startTime);
2083
- success &&= result.success;
2084
- testResults.push(...result.testResults);
2085
- if (result.snapshot !== void 0) {
2086
- hasSnapshot = true;
2087
- snapshotAdded += result.snapshot.added;
2088
- snapshotMatched += result.snapshot.matched;
2089
- snapshotTotal += result.snapshot.total;
2090
- snapshotUnmatched += result.snapshot.unmatched;
2091
- snapshotUpdated += result.snapshot.updated;
2092
- }
2093
- }
2094
- return {
2095
- numFailedTests: numberFailedTests,
2096
- numPassedTests: numberPassedTests,
2097
- numPendingTests: numberPendingTests,
2098
- numTodoTests: numberTodoTests > 0 ? numberTodoTests : void 0,
2099
- numTotalTests: numberTotalTests,
2100
- snapshot: hasSnapshot ? {
2101
- added: snapshotAdded,
2102
- matched: snapshotMatched,
2103
- total: snapshotTotal,
2104
- unmatched: snapshotUnmatched,
2105
- updated: snapshotUpdated
2106
- } : void 0,
2107
- startTime,
2108
- success,
2109
- testResults
2110
- };
2111
- }
2112
- function expandTabs(text, tabWidth = 4) {
2113
- let result = "";
2114
- for (const char of text) if (char === " ") {
2115
- const spaces = tabWidth - result.length % tabWidth;
2116
- result += " ".repeat(spaces);
2117
- } else result += char;
2118
- return result;
2119
- }
2120
- function highlightSyntax(filePath, code, useColor) {
2121
- if (!useColor) return code;
2122
- return highlightCode(filePath, code);
2123
- }
2124
- function formatDiffBlock(parsed, styles) {
2125
- if (parsed.snapshotDiff !== void 0) {
2126
- const lines = [""];
2127
- for (const diffLine of parsed.snapshotDiff.split("\n")) if (diffLine.startsWith("- ")) lines.push(styles.diff.expected(diffLine));
2128
- else if (diffLine.startsWith("+ ")) lines.push(styles.diff.received(diffLine));
2129
- else lines.push(styles.dim(diffLine));
2130
- return lines;
2131
- }
2132
- if (parsed.expected !== void 0 && parsed.received !== void 0) return [
2133
- "",
2134
- styles.diff.expected("- Expected"),
2135
- styles.diff.received("+ Received"),
2136
- "",
2137
- styles.diff.expected(`- ${parsed.expected}`),
2138
- styles.diff.received(`+ ${parsed.received}`)
2139
- ];
2140
- return [];
2141
- }
2142
- function formatErrorLine(parsed, styles, useColor) {
2143
- if (useColor && parsed.message.startsWith("Error:")) return styles.status.fail(color.bold("Error:") + parsed.message.slice(6));
2144
- return styles.status.fail(parsed.message);
2145
- }
2146
- function formatFallbackSnippet(message, styles, useColor) {
2147
- const location = parseSourceLocation(message);
2148
- if (location === void 0) return [];
2149
- const snippet = getSourceSnippet({
2150
- column: location.column,
2151
- context: 2,
2152
- filePath: location.path,
2153
- line: location.line
2154
- });
2155
- if (snippet === void 0) return [];
2156
- return ["", formatSourceSnippet(snippet, location.path, {
2157
- styles,
2158
- useColor
2159
- })];
2160
- }
2161
- function formatMappedLocationSnippets(loc, showLuau, styles, useColor) {
2162
- const snippets = [];
2163
- if (loc.tsPath !== void 0 && loc.tsLine !== void 0) {
2164
- const tsSnippet = getSourceSnippet({
2165
- column: loc.tsColumn,
2166
- context: 2,
2167
- filePath: loc.tsPath,
2168
- line: loc.tsLine,
2169
- sourceContent: loc.sourceContent
2170
- });
2171
- if (tsSnippet !== void 0) {
2172
- const label = showLuau ? "TypeScript" : void 0;
2173
- snippets.push("", formatSourceSnippet(tsSnippet, loc.tsPath, {
2174
- language: label,
2175
- styles,
2176
- useColor
2177
- }));
2178
- }
2179
- if (showLuau) {
2180
- const luauSnippet = getSourceSnippet({
2181
- context: 2,
2182
- filePath: loc.luauPath,
2183
- line: loc.luauLine
2184
- });
2185
- if (luauSnippet !== void 0) snippets.push("", formatSourceSnippet(luauSnippet, loc.luauPath, {
2186
- language: "Luau",
2187
- styles,
2188
- useColor
2189
- }));
2190
- }
2191
- } else {
2192
- const luauSnippet = getSourceSnippet({
2193
- context: 2,
2194
- filePath: loc.luauPath,
2195
- line: loc.luauLine
2196
- });
2197
- if (luauSnippet !== void 0) snippets.push("", formatSourceSnippet(luauSnippet, loc.luauPath, {
2198
- styles,
2199
- useColor
2200
- }));
2201
- }
2202
- return snippets;
2203
- }
2204
- function formatSnapshotCallSnippet(filePath, styles, useColor) {
2205
- if (!fs$1.existsSync(filePath)) return [];
2206
- const content = fs$1.readFileSync(filePath, "utf-8");
2207
- const snapshotIndices = content.split("\n").reduce((accumulator, fileLine, index) => {
2208
- if (fileLine.includes("toMatchSnapshot")) accumulator.push(index);
2209
- return accumulator;
2210
- }, []);
2211
- if (snapshotIndices.length !== 1) return [];
2212
- const snippet = getSourceSnippet({
2213
- context: 2,
2214
- filePath,
2215
- line: snapshotIndices[0] + 1,
2216
- sourceContent: content
2217
- });
2218
- if (snippet === void 0) return [];
2219
- return ["", formatSourceSnippet(snippet, filePath, {
2220
- styles,
2221
- useColor
2222
- })];
2223
- }
2224
- function resolveSourceSnippets(options) {
2225
- const { filePath, hasSnapshotDiff, mappedLocations, message, showLuau, sourceMapper, styles, useColor } = options;
2226
- if (mappedLocations.length > 0) return mappedLocations.flatMap((loc) => {
2227
- return formatMappedLocationSnippets(loc, showLuau, styles, useColor);
2228
- });
2229
- const fallback = formatFallbackSnippet(message, styles, useColor);
2230
- if (fallback.length > 0) return fallback;
2231
- if (hasSnapshotDiff && filePath !== void 0) return formatSnapshotCallSnippet(resolveDisplayPath(filePath, sourceMapper), styles, useColor);
2232
- return [];
2233
- }
2234
- function formatFailureMessage(originalMessage, options) {
2235
- const { filePath, showLuau, sourceMapper, styles, useColor } = options;
2236
- let mappedLocations = [];
2237
- let message = originalMessage;
2238
- if (sourceMapper !== void 0) ({locations: mappedLocations, message} = sourceMapper.mapFailureWithLocations(originalMessage));
2239
- const parsed = parseErrorMessage(originalMessage);
2240
- return [
2241
- formatErrorLine(parsed, styles, useColor),
2242
- ...formatDiffBlock(parsed, styles),
2243
- ...resolveSourceSnippets({
2244
- filePath,
2245
- hasSnapshotDiff: parsed.snapshotDiff !== void 0,
2246
- mappedLocations,
2247
- message,
2248
- showLuau,
2249
- sourceMapper,
2250
- styles,
2251
- useColor
2252
- })
2253
- ];
2254
- }
2255
- function formatSnapshotLine(snapshot, styles) {
2256
- if (snapshot === void 0 || snapshot.unmatched === 0) return;
2257
- return `${styles.dim(" Snapshots")} ${styles.summary.failed(`${snapshot.unmatched} failed`)}`;
2258
- }
2259
- //#endregion
2260
- //#region src/formatters/agent.ts
2261
- function formatAgent(result, options) {
2262
- const lines = [];
2263
- const execErrors = result.testResults.filter(hasExecError);
2264
- if (result.numFailedTests > 0 || execErrors.length > 0) {
2265
- lines.push(...formatFileHeaders(result, options), "");
2266
- const totalFailures = result.numFailedTests + execErrors.length;
2267
- lines.push(`${"⎯".repeat(3)} Failed Tests ${totalFailures} ${"⎯".repeat(3)}`, "");
2268
- if (result.numFailedTests > 0) {
2269
- const failureLines = formatFailures(result, result.numFailedTests, options);
2270
- lines.push(...failureLines);
2271
- }
2272
- for (const file of execErrors) lines.push(...formatExecError(file, options));
2273
- const hints = formatAgentLogHints(options);
2274
- if (hints !== "") lines.push(hints);
2275
- }
2276
- lines.push(...formatSummarySection(result, options));
2277
- return lines.join("\n");
2278
- }
2279
- function formatAgentMultiProject(projects, options) {
2280
- const lines = [];
2281
- for (const { displayName, result } of projects) lines.push(...formatAgentProjectHeader(displayName, result, options));
2282
- const stats = collectMultiProjectStats(projects);
2283
- if (stats.totalFailed + stats.allExecErrors.length > 0) lines.push(...formatMultiProjectFailures(projects, stats, options));
2284
- lines.push(...formatMultiProjectSummary(stats, options));
2285
- return lines.join("\n");
2286
- }
2287
- function formatTypeErrorLabel(count) {
2288
- if (count === 0) return "no errors";
2289
- return `${count} error${count === 1 ? "" : "s"}`;
2290
- }
2291
- function formatSummarySection(result, options) {
2292
- const lines = [];
2293
- const failedFiles = result.testResults.filter((file) => file.numFailingTests > 0 || hasExecError(file)).length;
2294
- const passedFiles = result.testResults.filter((file) => file.numFailingTests === 0 && !hasExecError(file)).length;
2295
- const totalFiles = failedFiles + passedFiles;
2296
- const fileParts = [];
2297
- if (failedFiles > 0) fileParts.push(`${failedFiles} failed`);
2298
- if (passedFiles > 0) fileParts.push(`${passedFiles} passed`);
2299
- lines.push(` Test Files ${fileParts.join(" | ")} (${totalFiles})`);
2300
- const testParts = [];
2301
- if (result.numFailedTests > 0) testParts.push(`${result.numFailedTests} failed`);
2302
- if (result.numPassedTests > 0) testParts.push(`${result.numPassedTests} passed`);
2303
- if (result.numPendingTests > 0) testParts.push(`${result.numPendingTests} skipped`);
2304
- const totalTests = result.numTotalTests;
2305
- lines.push(` Tests ${testParts.join(" | ")} (${totalTests})`);
2306
- if (options.typeErrorCount !== void 0) {
2307
- const typeLabel = formatTypeErrorLabel(options.typeErrorCount);
2308
- lines.push(`Type Errors ${typeLabel}`);
2309
- }
2310
- return lines;
2311
- }
2312
- function makeRelative$1(filePath, rootDirectory) {
2313
- const normalizedPath = filePath.replaceAll("\\", "/");
2314
- const normalizedRoot = rootDirectory.replaceAll("\\", "/");
2315
- if (normalizedPath.startsWith(normalizedRoot)) return path.relative(normalizedRoot, normalizedPath).replaceAll("\\", "/");
2316
- return filePath;
2317
- }
2318
- function formatFileHeaderExecError(file, options) {
2319
- return [` ❯ ${makeRelative$1(resolveDisplayPath(file.testFilePath, options.sourceMapper), options.rootDir)} (suite failed to run)`];
2320
- }
2321
- function formatFileHeaderFailures(file, options) {
2322
- const lines = [];
2323
- const relativePath = makeRelative$1(resolveDisplayPath(file.testFilePath, options.sourceMapper), options.rootDir);
2324
- const totalTests = file.numFailingTests + file.numPassingTests + file.numPendingTests;
2325
- const testWord = totalTests === 1 ? "test" : "tests";
2326
- lines.push(` ❯ ${relativePath} (${totalTests} ${testWord} | ${file.numFailingTests} failed)`);
2327
- for (const test of file.testResults) if (test.status === "failed") {
2328
- const duration = test.duration !== void 0 ? ` ${String(test.duration)}ms` : "";
2329
- lines.push(` × ${test.title}${duration}`);
2330
- }
2331
- return lines;
2332
- }
2333
- function formatFileHeaders(result, options) {
2334
- const lines = [];
2335
- for (const file of result.testResults) {
2336
- if (hasExecError(file)) {
2337
- lines.push(...formatFileHeaderExecError(file, options));
2338
- continue;
2339
- }
2340
- if (file.numFailingTests === 0) continue;
2341
- lines.push(...formatFileHeaderFailures(file, options));
2342
- }
2343
- return lines;
2344
- }
2345
- function formatExecError(file, options) {
2346
- const lines = [];
2347
- const relativePath = makeRelative$1(resolveDisplayPath(file.testFilePath, options.sourceMapper), options.rootDir);
2348
- assert(file.failureMessage !== void 0, "exec error files have failureMessage");
2349
- const errorMessage = cleanExecErrorMessage(file.failureMessage);
2350
- lines.push(` FAIL ${relativePath}`, errorMessage);
2351
- const hint = getExecErrorHint(errorMessage);
2352
- if (hint !== void 0) lines.push(`Hint: ${hint}`);
2353
- lines.push("");
2354
- return lines;
2355
- }
2356
- function formatSize(bytes) {
2357
- if (bytes < 1024) return `${bytes}b`;
2358
- return `${Math.round(bytes / 1024)}kb`;
2359
- }
2360
- function formatAgentLogHints(options) {
2361
- const lines = [];
2362
- if (options.outputFile !== void 0) {
2363
- const size = options.outputFileSize !== void 0 ? ` (${formatSize(options.outputFileSize)})` : "";
2364
- lines.push(`View ${options.outputFile} for full Jest output${size}`);
2365
- }
2366
- if (options.gameOutput !== void 0) {
2367
- const size = options.gameOutputSize !== void 0 ? ` (${formatSize(options.gameOutputSize)})` : "";
2368
- lines.push(`View ${options.gameOutput} for Roblox game logs${size}`);
2369
- }
2370
- return lines.join("\n");
2371
- }
2372
- function collectFailedTests(result, sourceMapper) {
2373
- const failures = [];
2374
- for (const file of result.testResults) {
2375
- const displayPath = resolveDisplayPath(file.testFilePath, sourceMapper);
2376
- for (const test of file.testResults) if (test.status === "failed") failures.push({
2377
- filePath: displayPath,
2378
- test
2379
- });
2380
- }
2381
- return failures;
2382
- }
2383
- function getSnippetLevel(totalFailures) {
2384
- if (totalFailures <= 2) return "both";
2385
- if (totalFailures <= 5) return "ts-only";
2386
- return "none";
2387
- }
2388
- function findFailureLocation(mappedLocations, message) {
2389
- if (mappedLocations.length > 0) {
2390
- const loc = mappedLocations[0];
2391
- assert(loc !== void 0, "array with length > 0 has element 0");
2392
- if (loc.tsPath !== void 0 && loc.tsLine !== void 0) return {
2393
- line: loc.tsLine,
2394
- path: loc.tsPath
2395
- };
2396
- return {
2397
- line: loc.luauLine,
2398
- path: loc.luauPath
2399
- };
2400
- }
2401
- return parseSourceLocation(message);
2402
- }
2403
- function formatSnippetBlock(snippetResult) {
2404
- if (snippetResult === void 0) return;
2405
- const lines = [];
2406
- for (const line of snippetResult.lines) {
2407
- const prefix = line.num === snippetResult.failureLine ? ">" : " ";
2408
- lines.push(`${prefix} ${line.num}| ${line.content}`);
2409
- }
2410
- return lines.join("\n");
2411
- }
2412
- function getTsSnippets(loc, snippetLevel, rootDirectory) {
2413
- assert(loc.tsPath !== void 0 && loc.tsLine !== void 0, "caller checked ts fields");
2414
- const result = [];
2415
- const tsSnippet = formatSnippetBlock(getSourceSnippet({
2416
- column: loc.tsColumn,
2417
- context: 1,
2418
- filePath: loc.tsPath,
2419
- line: loc.tsLine,
2420
- sourceContent: loc.sourceContent
2421
- }));
2422
- if (tsSnippet !== void 0) {
2423
- const relativeTsPath = makeRelative$1(loc.tsPath, rootDirectory);
2424
- const label = snippetLevel === "both" ? `TS ${relativeTsPath}:${loc.tsLine}\n` : "";
2425
- result.push(`${label}${tsSnippet}`);
2426
- }
2427
- if (snippetLevel === "both") {
2428
- const luauSnippet = formatSnippetBlock(getSourceSnippet({
2429
- context: 1,
2430
- filePath: loc.luauPath,
2431
- line: loc.luauLine
2432
- }));
2433
- if (luauSnippet !== void 0) {
2434
- const relativeLuauPath = makeRelative$1(loc.luauPath, rootDirectory);
2435
- result.push(`Luau ${relativeLuauPath}:${loc.luauLine}\n${luauSnippet}`);
2436
- }
2437
- }
2438
- return result;
2439
- }
2440
- function getLuauOnlySnippet(loc) {
2441
- const snippet = formatSnippetBlock(getSourceSnippet({
2442
- context: 1,
2443
- filePath: loc.luauPath,
2444
- line: loc.luauLine
2445
- }));
2446
- return snippet !== void 0 ? [snippet] : [];
2447
- }
2448
- function getMappedSnippets(loc, snippetLevel, rootDirectory) {
2449
- if (loc.tsPath !== void 0 && loc.tsLine !== void 0) return getTsSnippets(loc, snippetLevel, rootDirectory);
2450
- return getLuauOnlySnippet(loc);
2451
- }
2452
- function getFallbackSnippet(location) {
2453
- const snippet = formatSnippetBlock(getSourceSnippet({
2454
- context: 1,
2455
- filePath: location.path,
2456
- line: location.line
2457
- }));
2458
- return snippet !== void 0 ? [snippet] : [];
2459
- }
2460
- function getFailureSnippets(mappedLocations, location, snippetLevel, rootDirectory) {
2461
- if (snippetLevel === "none") return [];
2462
- if (mappedLocations.length > 0) {
2463
- const loc = mappedLocations[0];
2464
- assert(loc !== void 0, "array with length > 0 has element 0");
2465
- return getMappedSnippets(loc, snippetLevel, rootDirectory);
2466
- }
2467
- if (location !== void 0) return getFallbackSnippet(location);
2468
- return [];
2469
- }
2470
- function formatAgentFailure(test, filePath, options, snippetLevel) {
2471
- const lines = [];
2472
- for (const originalMessage of test.failureMessages) {
2473
- let mappedLocations = [];
2474
- let message = originalMessage;
2475
- if (options.sourceMapper !== void 0) ({locations: mappedLocations, message} = options.sourceMapper.mapFailureWithLocations(originalMessage));
2476
- const parsed = parseErrorMessage(originalMessage);
2477
- const location = findFailureLocation(mappedLocations, message);
2478
- const relativePath = makeRelative$1(location?.path ?? filePath, options.rootDir);
2479
- const lineInfo = location?.line !== void 0 ? `:${location.line}` : "";
2480
- const ancestors = test.ancestorTitles.length > 0 ? ` > ${test.ancestorTitles.join(" > ")}` : "";
2481
- lines.push(` FAIL ${relativePath}${lineInfo}${ancestors} > ${test.title}`);
2482
- if (parsed.snapshotDiff !== void 0) lines.push(parsed.snapshotDiff);
2483
- else if (parsed.expected !== void 0 && parsed.received !== void 0) lines.push(`Expected: ${parsed.expected}`, `Received: ${parsed.received}`);
2484
- const snippets = getFailureSnippets(mappedLocations, location, snippetLevel, options.rootDir);
2485
- for (const snippet of snippets) lines.push(snippet);
2486
- lines.push("");
2487
- }
2488
- return lines.join("\n");
2489
- }
2490
- function formatFailures(result, totalFailures, options) {
2491
- const lines = [];
2492
- const failures = collectFailedTests(result, options.sourceMapper);
2493
- const snippetLevel = getSnippetLevel(totalFailures);
2494
- for (const [index, { filePath, test }] of failures.entries()) {
2495
- if (index >= options.maxFailures) {
2496
- lines.push(`... ${result.numFailedTests - index} more failures omitted`, "");
2497
- break;
2498
- }
2499
- lines.push(formatAgentFailure(test, filePath, options, snippetLevel));
2500
- }
2501
- return lines;
2502
- }
2503
- function formatAgentProjectHeader(displayName, result, options) {
2504
- const execErrors = result.testResults.filter(hasExecError);
2505
- const hasFailures = result.numFailedTests > 0 || execErrors.length > 0;
2506
- const failedFiles = result.testResults.filter((file) => file.numFailingTests > 0 || hasExecError(file)).length;
2507
- const skippedFiles = result.testResults.filter((file) => file.numFailingTests === 0 && file.numPassingTests === 0 && !hasExecError(file)).length;
2508
- const passedFiles = result.testResults.length - failedFiles - skippedFiles;
2509
- const fileParts = [];
2510
- if (passedFiles > 0) fileParts.push(`${passedFiles} passed`);
2511
- if (failedFiles > 0) fileParts.push(`${failedFiles} failed`);
2512
- if (skippedFiles > 0) fileParts.push(`${skippedFiles} skipped`);
2513
- const lines = [`▶ ${displayName} ${fileParts.join(" | ")} (${result.numTotalTests} tests)`];
2514
- if (hasFailures) lines.push(...formatFileHeaders(result, options));
2515
- return lines;
2516
- }
2517
- function collectMultiProjectStats(projects) {
2518
- const stats = {
2519
- allExecErrors: [],
2520
- totalFailed: 0,
2521
- totalFailedFiles: 0,
2522
- totalPassed: 0,
2523
- totalPassedFiles: 0,
2524
- totalPending: 0,
2525
- totalSkippedFiles: 0,
2526
- totalTests: 0
2527
- };
2528
- for (const { result } of projects) {
2529
- const failedFiles = result.testResults.filter((file) => file.numFailingTests > 0 || hasExecError(file)).length;
2530
- const skippedFiles = result.testResults.filter((file) => file.numFailingTests === 0 && file.numPassingTests === 0 && !hasExecError(file)).length;
2531
- stats.totalFailed += result.numFailedTests;
2532
- stats.totalPassed += result.numPassedTests;
2533
- stats.totalPending += result.numPendingTests;
2534
- stats.totalTests += result.numTotalTests;
2535
- stats.totalFailedFiles += failedFiles;
2536
- stats.totalSkippedFiles += skippedFiles;
2537
- stats.totalPassedFiles += result.testResults.length - failedFiles - skippedFiles;
2538
- stats.allExecErrors.push(...result.testResults.filter(hasExecError));
2539
- }
2540
- return stats;
2541
- }
2542
- function formatMultiProjectFailures(projects, stats, options) {
2543
- const totalFailures = stats.totalFailed + stats.allExecErrors.length;
2544
- const lines = [
2545
- "",
2546
- `${"⎯".repeat(3)} Failed Tests ${totalFailures} ${"⎯".repeat(3)}`,
2547
- ""
2548
- ];
2549
- for (const { result } of projects) if (result.numFailedTests > 0) lines.push(...formatFailures(result, totalFailures, options));
2550
- for (const file of stats.allExecErrors) lines.push(...formatExecError(file, options));
2551
- const hints = formatAgentLogHints(options);
2552
- if (hints !== "") lines.push(hints);
2553
- return lines;
2554
- }
2555
- function formatMultiProjectSummary(stats, options) {
2556
- const lines = [];
2557
- const fileParts = [];
2558
- if (stats.totalFailedFiles > 0) fileParts.push(`${stats.totalFailedFiles} failed`);
2559
- if (stats.totalPassedFiles > 0) fileParts.push(`${stats.totalPassedFiles} passed`);
2560
- if (stats.totalSkippedFiles > 0) fileParts.push(`${stats.totalSkippedFiles} skipped`);
2561
- const totalFiles = stats.totalFailedFiles + stats.totalPassedFiles + stats.totalSkippedFiles;
2562
- lines.push(` Test Files ${fileParts.join(" | ")} (${totalFiles})`);
2563
- const testParts = [];
2564
- if (stats.totalFailed > 0) testParts.push(`${stats.totalFailed} failed`);
2565
- if (stats.totalPassed > 0) testParts.push(`${stats.totalPassed} passed`);
2566
- if (stats.totalPending > 0) testParts.push(`${stats.totalPending} skipped`);
2567
- lines.push(` Tests ${testParts.join(" | ")} (${stats.totalTests})`);
2568
- if (options.typeErrorCount !== void 0) lines.push(`Type Errors ${formatTypeErrorLabel(options.typeErrorCount)}`);
2569
- return lines;
2570
- }
2571
- //#endregion
2572
- //#region src/formatters/json.ts
2573
- function formatJson(result) {
2574
- return JSON.stringify(result, null, 2);
2575
- }
2576
- async function writeJsonFile(result, filePath) {
2577
- const absolutePath = path$1.resolve(filePath);
2578
- const directoryPath = path$1.dirname(absolutePath);
2579
- if (!fs$1.existsSync(directoryPath)) fs$1.mkdirSync(directoryPath, { recursive: true });
2580
- fs$1.writeFileSync(absolutePath, formatJson(result), "utf8");
2581
- }
2582
- //#endregion
2583
- //#region src/formatters/utils.ts
2584
- /**
2585
- * Find the options object for a named formatter in a resolved formatter list.
2586
- * Returns `{}` if the formatter is present without options, or `undefined` if absent.
2587
- */
2588
- function findFormatterOptions(formatters, name) {
2589
- for (const entry of formatters) {
2590
- if (entry === name) return {};
2591
- if (Array.isArray(entry) && entry[0] === name) return entry[1];
2592
- }
2593
- }
2594
- //#endregion
2595
- //#region src/snapshot/path-resolver.ts
2596
- function createSnapshotPathResolver(config) {
2597
- const rojoMappings = buildMappings(config.rojoProject.tree, "");
2598
- const tsconfigMappings = config.mappings ?? [];
2599
- return { resolve(virtualPath) {
2600
- const normalized = virtualPath.replaceAll("\\", "/");
2601
- for (const [prefix, basePath] of rojoMappings) {
2602
- if (!normalized.startsWith(`${prefix}/`) && normalized !== prefix) continue;
2603
- const result = `${basePath}/${normalized.slice(prefix.length + 1)}`;
2604
- const mapping = findMapping(result, tsconfigMappings);
2605
- if (mapping !== void 0) return {
2606
- filePath: replacePrefix(result, mapping.outDir, mapping.rootDir).replace(/^\.\//, ""),
2607
- mapping
2608
- };
2609
- return { filePath: result };
2610
- }
2611
- } };
2612
- }
2613
- function buildMappings(tree, prefix) {
2614
- const mappings = [];
2615
- for (const [key, value] of Object.entries(tree)) {
2616
- if (key.startsWith("$") || typeof value !== "object") continue;
2617
- const dataModelPath = prefix ? `${prefix}/${key}` : key;
2618
- const node = value;
2619
- if (typeof node.$path === "string") mappings.push([dataModelPath, node.$path]);
2620
- mappings.push(...buildMappings(node, dataModelPath));
2621
- }
2622
- mappings.sort((a, b) => b[0].length - a[0].length);
2623
- return mappings;
2624
- }
2625
- //#endregion
2626
- //#region src/types/rojo.ts
2627
- const rojoProjectSchema = type({
2628
- "name": "string",
2629
- "servePort?": "number.integer",
2630
- "tree": "object"
2631
- }).as();
2632
- //#endregion
2633
- //#region src/executor.ts
2634
- function isLuauProject(testFiles, tsconfigMappings) {
2635
- if (tsconfigMappings.length > 0) return false;
2636
- if (testFiles.some((file) => /\.tsx?$/.test(file))) return false;
2637
- return true;
2638
- }
2639
- function resolveAllTsconfigMappings(projectRoot) {
2640
- const resolvedRoot = path$1.resolve(projectRoot);
2641
- let files;
2642
- try {
2643
- files = fs$1.readdirSync(resolvedRoot).filter((file) => /^tsconfig.*\.json$/i.test(file));
2644
- } catch {
2645
- return [];
2646
- }
2647
- const seen = /* @__PURE__ */ new Set();
2648
- const mappings = [];
2649
- for (const file of files) {
2650
- const compilerOptions = getTsconfig(resolvedRoot, file)?.config.compilerOptions;
2651
- if (compilerOptions?.outDir === void 0) continue;
2652
- const parsed = parseTsconfigMappings(compilerOptions);
2653
- for (const entry of parsed) {
2654
- const key = `${entry.outDir}:${entry.rootDir}`;
2655
- if (!seen.has(key)) {
2656
- seen.add(key);
2657
- mappings.push(entry);
2658
- }
2659
- }
2660
- }
2661
- mappings.sort((a, b) => b.outDir.length - a.outDir.length);
2662
- return mappings;
2663
- }
2664
- function resolveTsconfigDirectories(projectRoot) {
2665
- const tsconfig = getTsconfig(projectRoot, "tsconfig.lib.json") ?? getTsconfig(projectRoot);
2666
- const tsconfigDirectory = tsconfig !== null ? path$1.dirname(path$1.resolve(tsconfig.path)) : void 0;
2667
- const resolvedRoot = path$1.resolve(projectRoot);
2668
- if (!(tsconfigDirectory?.startsWith(resolvedRoot) === true) || tsconfig?.config.compilerOptions === void 0) return {
2669
- outDir: void 0,
2670
- rootDir: void 0
2671
- };
2672
- const outDirectory = tsconfig.config.compilerOptions.outDir ?? "out";
2673
- const rootDirectory = tsconfig.config.compilerOptions.rootDir ?? "src";
2674
- return {
2675
- outDir: normalizeDirectoryPath(outDirectory),
2676
- rootDir: normalizeDirectoryPath(rootDirectory)
2677
- };
2678
- }
2679
- function formatExecuteOutput(options) {
2680
- const { config, result, sourceMapper, timing, version } = options;
2681
- if (config.silent) return "";
2682
- const resolvedOutputFile = config.outputFile !== void 0 ? path$1.resolve(config.outputFile) : void 0;
2683
- const resolvedGameOutput = config.gameOutput !== void 0 ? path$1.resolve(config.gameOutput) : void 0;
2684
- const agentOptions = findFormatterOptions(config.formatters ?? [], "agent");
2685
- if (agentOptions !== void 0 && !config.verbose) return formatAgent(result, {
2686
- gameOutput: resolvedGameOutput,
2687
- maxFailures: agentOptions.maxFailures ?? 10,
2688
- outputFile: resolvedOutputFile,
2689
- rootDir: config.rootDir,
2690
- sourceMapper
2691
- });
2692
- if (findFormatterOptions(config.formatters ?? [], "json") !== void 0) return formatJson(result);
2693
- return formatResult(result, timing, {
2694
- collectCoverage: config.collectCoverage,
2695
- color: config.color,
2696
- gameOutput: resolvedGameOutput,
2697
- outputFile: resolvedOutputFile,
2698
- rootDir: config.rootDir,
2699
- showLuau: config.showLuau,
2700
- sourceMapper,
2701
- verbose: config.verbose,
2702
- version
2703
- });
2704
- }
2705
- /**
2706
- * Build a `ProjectJob` with `snapshotFormat` resolved per-project. Each job
2707
- * carries its own config so the Luau runner never re-resolves or shares format
2708
- * state across projects (fixes the spike's snapshot-diff regression — C1).
2709
- */
2710
- function buildProjectJob(parameters) {
2711
- const tsconfigMappings = resolveAllTsconfigMappings(parameters.config.rootDir);
2712
- const luauProject = isLuauProject(parameters.testFiles, tsconfigMappings);
2713
- return {
2714
- config: applySnapshotFormatDefaults(parameters.config, luauProject),
2715
- displayColor: parameters.displayColor,
2716
- displayName: parameters.displayName ?? "",
2717
- testFiles: parameters.testFiles
2718
- };
2719
- }
2720
- /**
2721
- * Thin wrapper over `backend.runTests`. Fires exactly once per CLI invocation
2722
- * with a full `ProjectJob[]` envelope. Returns the raw `BackendResult`.
2723
- */
2724
- async function executeBackend(backend, jobs, parallel) {
2725
- return backend.runTests({
2726
- jobs,
2727
- parallel
2728
- });
2729
- }
2730
- /**
2731
- * Process a single `ProjectBackendResult` into an `ExecuteResult`: writes
2732
- * snapshots, builds the source mapper, resolves test-file paths, and renders
2733
- * formatter output. Called once per job.
2734
- */
2735
- function processProjectResult(entry, options) {
2736
- const { backendTiming, config, deferFormatting, startTime, version } = options;
2737
- const { coverageData, gameOutput, luauTiming, result, setupMs, snapshotWrites } = entry;
2738
- const tsconfigMappings = resolveAllTsconfigMappings(config.rootDir);
2739
- if (snapshotWrites !== void 0) writeSnapshots(snapshotWrites, config, tsconfigMappings);
2740
- const testsMs = calculateTestsMs(result.testResults);
2741
- const sourceMapper = config.sourceMap ? buildSourceMapper(config, tsconfigMappings) : void 0;
2742
- resolveTestFilePaths(result, sourceMapper);
2743
- const totalMs = Date.now() - startTime;
2744
- const timing = {
2745
- executionMs: backendTiming.executionMs,
2746
- setupMs,
2747
- startTime,
2748
- testsMs,
2749
- totalMs,
2750
- uploadCached: backendTiming.uploadCached,
2751
- uploadMs: backendTiming.uploadMs
2752
- };
2753
- const output = deferFormatting !== true ? formatExecuteOutput({
2754
- config,
2755
- result,
2756
- sourceMapper,
2757
- timing,
2758
- version
2759
- }) : "";
2760
- if (luauTiming !== void 0) printLuauTiming(luauTiming);
2761
- return {
2762
- coverageData,
2763
- exitCode: result.success ? 0 : 1,
2764
- gameOutput,
2765
- output,
2766
- result,
2767
- sourceMapper,
2768
- timing
2769
- };
2770
- }
2771
- /**
2772
- * Single-project convenience wrapper: builds a length-1 jobs array, fires
2773
- * `executeBackend` once, and maps the single entry through
2774
- * `processProjectResult`. Multi-project callers drive `executeBackend` +
2775
- * `processProjectResult` directly from `cli.ts`.
2776
- */
2777
- async function execute(options) {
2778
- const startTime = Date.now();
2779
- const job = buildProjectJob({
2780
- config: options.config,
2781
- testFiles: options.testFiles
2782
- });
2783
- const { results, timing: backendTiming } = await executeBackend(options.backend, [job]);
2784
- const first = results[0];
2785
- return processProjectResult(first, {
2786
- backendTiming,
2787
- config: job.config,
2788
- deferFormatting: options.deferFormatting,
2789
- startTime,
2790
- version: options.version
2791
- });
2792
- }
2793
- function normalizeDirectoryPath(directory) {
2794
- return path$1.normalize(directory).replaceAll("\\", "/");
2795
- }
2796
- function parseTsconfigMappings(options) {
2797
- const outDirectory = normalizeDirectoryPath(options.outDir ?? "out");
2798
- if (options.rootDirs !== void 0 && options.rootDirs.length > 0) return [{
2799
- outDir: outDirectory,
2800
- rootDir: options.rootDirs.map((directory) => normalizeDirectoryPath(directory)).reduce((ancestor, directory) => {
2801
- const parts = ancestor.split("/");
2802
- const directoryParts = directory.split("/");
2803
- let common = 0;
2804
- while (common < parts.length && common < directoryParts.length && parts[common] === directoryParts[common]) common++;
2805
- return parts.slice(0, common).join("/");
2806
- }) || "."
2807
- }];
2808
- if (options.rootDir === null) return [];
2809
- return [{
2810
- outDir: outDirectory,
2811
- rootDir: normalizeDirectoryPath(options.rootDir ?? "src")
2812
- }];
2813
- }
2814
- function findRojoProject(rootDirectory) {
2815
- const defaultPath = path$1.join(rootDirectory, "default.project.json");
2816
- if (fs$1.existsSync(defaultPath)) return defaultPath;
2817
- const projectFile = fs$1.readdirSync(rootDirectory).find((file) => file.endsWith(".project.json"));
2818
- return projectFile !== void 0 ? path$1.join(rootDirectory, projectFile) : void 0;
2819
- }
2820
- function buildSourceMapper(config, tsconfigMappings) {
2821
- const rojoProjectPath = config.rojoProject ?? findRojoProject(config.rootDir);
2822
- if (rojoProjectPath === void 0 || !fs$1.existsSync(rojoProjectPath)) return;
2823
- try {
2824
- const rojoResult = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
2825
- if (rojoResult instanceof type.errors) return;
2826
- const resolvedTree = resolveNestedProjects(rojoResult.tree, path$1.dirname(rojoProjectPath));
2827
- return createSourceMapper({
2828
- mappings: tsconfigMappings,
2829
- rojoProject: {
2830
- ...rojoResult,
2831
- tree: resolvedTree
2832
- }
2833
- });
2834
- } catch {
2835
- return;
2836
- }
2837
- }
2838
- function resolveTestFilePaths(result, sourceMapper) {
2839
- if (sourceMapper === void 0) return;
2840
- for (const file of result.testResults) file.testFilePath = sourceMapper.resolveTestFilePath(file.testFilePath) ?? file.testFilePath;
2841
- }
2842
- function calculateTestsMs(testResults) {
2843
- let total = 0;
2844
- for (const file of testResults) for (const test of file.testResults) if (test.duration !== void 0) total += test.duration;
2845
- return total;
2846
- }
2847
- function printLuauTiming(timing) {
2848
- let total = 0;
2849
- for (const [phase, seconds] of Object.entries(timing)) {
2850
- const ms = Math.round(seconds * 1e3);
2851
- total += ms;
2852
- process.stderr.write(`[TIMING] ${phase}: ${String(ms)}ms\n`);
2853
- }
2854
- process.stderr.write(`[TIMING] total: ${String(total)}ms\n`);
2855
- }
2856
- const instrumentedFileRecordSchema = type({
2857
- "key": "string",
2858
- "branchCount?": "number",
2859
- "coverageMapPath": "string",
2860
- "functionCount?": "number",
2861
- "instrumentedLuauPath": "string",
2862
- "originalLuauPath": "string",
2863
- "sourceMapPath": "string",
2864
- "statementCount": "number"
2865
- });
2866
- const coverageManifestSchema = type({
2867
- files: type("Record<string, unknown>").pipe((files) => {
2868
- const validated = {};
2869
- const skipped = [];
2870
- for (const [key, value] of Object.entries(files)) {
2871
- const parsed = instrumentedFileRecordSchema(value);
2872
- if (parsed instanceof type.errors) skipped.push(key);
2873
- else validated[key] = parsed;
2874
- }
2875
- if (skipped.length > 0) process.stderr.write(`Warning: ${skipped.length} file record(s) in coverage manifest failed validation and were skipped: ${skipped.join(", ")}\n`);
2876
- return validated;
2877
- }),
2878
- generatedAt: "string",
2879
- luauRoots: "string[]",
2880
- shadowDir: "string",
2881
- version: type.unit(1)
2882
- });
2883
- function loadCoverageManifest(rootDirectory) {
2884
- const manifestPath = path$1.join(rootDirectory, ".jest-roblox-coverage", "manifest.json");
2885
- try {
2886
- const raw = fs$1.readFileSync(manifestPath, "utf-8");
2887
- const parsed = coverageManifestSchema(JSON.parse(raw));
2888
- if (parsed instanceof type.errors) {
2889
- process.stderr.write(`Warning: Coverage manifest is invalid (re-run \`jest-roblox instrument\`): ${parsed.summary}\n`);
2890
- return;
2891
- }
2892
- return parsed;
2893
- } catch (err) {
2894
- if (err instanceof SyntaxError) process.stderr.write("Warning: Coverage manifest is malformed JSON (re-run `jest-roblox instrument`)\n");
2895
- return;
2896
- }
2897
- }
2898
- function writeSnapshots(snapshotWrites, config, tsconfigMappings) {
2899
- const rojoProjectPath = config.rojoProject ?? findRojoProject(config.rootDir);
2900
- if (rojoProjectPath === void 0 || !fs$1.existsSync(rojoProjectPath)) {
2901
- process.stderr.write("Warning: Cannot write snapshots - no rojo project found\n");
2902
- return;
2903
- }
2904
- try {
2905
- const rojoResult = rojoProjectSchema(JSON.parse(fs$1.readFileSync(rojoProjectPath, "utf-8")));
2906
- if (rojoResult instanceof type.errors) {
2907
- process.stderr.write("Warning: Cannot write snapshots - invalid rojo project\n");
2908
- return;
2909
- }
2910
- const resolvedTree = resolveNestedProjects(rojoResult.tree, path$1.dirname(rojoProjectPath));
2911
- const resolver = createSnapshotPathResolver({
2912
- mappings: tsconfigMappings,
2913
- rojoProject: {
2914
- ...rojoResult,
2915
- tree: resolvedTree
2916
- }
2917
- });
2918
- let written = 0;
2919
- for (const [virtualPath, content] of Object.entries(snapshotWrites)) {
2920
- const resolved = resolver.resolve(virtualPath);
2921
- if (resolved === void 0) {
2922
- process.stderr.write(`Warning: Cannot resolve snapshot path: ${virtualPath}\n`);
2923
- continue;
2924
- }
2925
- const absolutePath = path$1.resolve(config.rootDir, resolved.filePath);
2926
- fs$1.mkdirSync(path$1.dirname(absolutePath), { recursive: true });
2927
- fs$1.writeFileSync(absolutePath, content);
2928
- const { filePath, mapping } = resolved;
2929
- if (mapping !== void 0) {
2930
- const outPath = mapping.outDir + filePath.slice(mapping.rootDir.length);
2931
- const absoluteOutPath = path$1.resolve(config.rootDir, outPath);
2932
- fs$1.mkdirSync(path$1.dirname(absoluteOutPath), { recursive: true });
2933
- fs$1.writeFileSync(absoluteOutPath, content);
2934
- }
2935
- written++;
2936
- }
2937
- if (written > 0) process.stderr.write(`Wrote ${String(written)} snapshot file${written === 1 ? "" : "s"}\n`);
2938
- } catch (err) {
2939
- if (err instanceof SyntaxError) process.stderr.write(formatBanner({
2940
- body: [color.red(`Failed to parse rojo project: ${err.message}`), ` ${color.dim("File:")} ${rojoProjectPath}`],
2941
- level: "warn",
2942
- title: "Snapshot Warning"
2943
- }));
2944
- else process.stderr.write(`Warning: Failed to write snapshot files: ${String(err)}\n`);
2945
- }
2946
- }
2947
- //#endregion
2948
- //#region packages/luau-ast/dist/index.mjs
2949
- function visitExpression(expression, visitor) {
2950
- if (visitor.visitExpr?.(expression) === false) return;
2951
- const { tag } = expression;
2952
- switch (tag) {
2953
- case "binary":
2954
- visitExprBinary(expression, visitor);
2955
- break;
2956
- case "boolean":
2957
- visitor.visitExprConstantBool?.(expression);
2958
- break;
2959
- case "call":
2960
- visitExprCall(expression, visitor);
2961
- break;
2962
- case "cast":
2963
- visitExprTypeAssertion(expression, visitor);
2964
- break;
2965
- case "conditional":
2966
- visitExprIfElse(expression, visitor);
2967
- break;
2968
- case "function":
2969
- visitExprFunction(expression, visitor);
2970
- break;
2971
- case "global":
2972
- visitor.visitExprGlobal?.(expression);
2973
- break;
2974
- case "group":
2975
- visitExprGroup(expression, visitor);
2976
- break;
2977
- case "index":
2978
- visitExprIndexExpr(expression, visitor);
2979
- break;
2980
- case "indexname":
2981
- visitExprIndexName(expression, visitor);
2982
- break;
2983
- case "instantiate":
2984
- visitExprInstantiate(expression, visitor);
2985
- break;
2986
- case "interpolatedstring":
2987
- visitExprInterpString(expression, visitor);
2988
- break;
2989
- case "local":
2990
- visitor.visitExprLocal?.(expression);
2991
- break;
2992
- case "nil":
2993
- visitor.visitExprConstantNil?.(expression);
2994
- break;
2995
- case "number":
2996
- visitor.visitExprConstantNumber?.(expression);
2997
- break;
2998
- case "string":
2999
- visitor.visitExprConstantString?.(expression);
3000
- break;
3001
- case "table":
3002
- visitExprTable(expression, visitor);
3003
- break;
3004
- case "unary":
3005
- visitExprUnary(expression, visitor);
3006
- break;
3007
- case "vararg":
3008
- visitor.visitExprVarargs?.(expression);
3009
- break;
3010
- default: break;
3011
- }
3012
- visitor.visitExprEnd?.(expression);
3013
- }
3014
- function visitStatement(statement, visitor) {
3015
- const { tag } = statement;
3016
- switch (tag) {
3017
- case "assign":
3018
- visitStatAssign(statement, visitor);
3019
- break;
3020
- case "block":
3021
- visitStatBlock(statement, visitor);
3022
- break;
3023
- case "break":
3024
- visitor.visitStatBreak?.(statement);
3025
- break;
3026
- case "compoundassign":
3027
- visitStatCompoundAssign(statement, visitor);
3028
- break;
3029
- case "conditional":
3030
- visitStatIf(statement, visitor);
3031
- break;
3032
- case "continue":
3033
- visitor.visitStatContinue?.(statement);
3034
- break;
3035
- case "do":
3036
- visitStatDo(statement, visitor);
3037
- break;
3038
- case "expression":
3039
- visitStatExpr(statement, visitor);
3040
- break;
3041
- case "for":
3042
- visitStatFor(statement, visitor);
3043
- break;
3044
- case "forin":
3045
- visitStatForIn(statement, visitor);
3046
- break;
3047
- case "function":
3048
- visitStatFunction(statement, visitor);
3049
- break;
3050
- case "local":
3051
- visitStatLocal(statement, visitor);
3052
- break;
3053
- case "localfunction":
3054
- visitStatLocalFunction(statement, visitor);
3055
- break;
3056
- case "repeat":
3057
- visitStatRepeat(statement, visitor);
3058
- break;
3059
- case "return":
3060
- visitStatReturn(statement, visitor);
3061
- break;
3062
- case "typealias":
3063
- visitor.visitStatTypeAlias?.(statement);
3064
- break;
3065
- case "typefunction":
3066
- visitor.visitStatTypeFunction?.(statement);
3067
- break;
3068
- case "while":
3069
- visitStatWhile(statement, visitor);
3070
- break;
3071
- default: break;
3072
- }
3073
- }
3074
- function visitBlock(block, visitor) {
3075
- visitStatBlock(block, visitor);
3076
- }
3077
- function visitPunctuated(list, visitor, apply) {
3078
- for (const item of list) apply(item.node, visitor);
3079
- }
3080
- function visitStatBlock(block, visitor) {
3081
- if (visitor.visitStatBlock?.(block) === false) return;
3082
- for (const statement of block.statements) visitStatement(statement, visitor);
3083
- visitor.visitStatBlockEnd?.(block);
3084
- }
3085
- function visitStatDo(node, visitor) {
3086
- if (visitor.visitStatDo?.(node) === false) return;
3087
- visitStatBlock(node.body, visitor);
3088
- }
3089
- function visitStatIf(node, visitor) {
3090
- if (visitor.visitStatIf?.(node) === false) return;
3091
- visitExpression(node.condition, visitor);
3092
- visitStatBlock(node.thenBlock, visitor);
3093
- for (const elseif of node.elseifs) visitElseIfStat(elseif, visitor);
3094
- if (node.elseBlock) visitStatBlock(node.elseBlock, visitor);
3095
- }
3096
- function visitElseIfStat(node, visitor) {
3097
- visitExpression(node.condition, visitor);
3098
- visitStatBlock(node.thenBlock, visitor);
3099
- }
3100
- function visitStatWhile(node, visitor) {
3101
- if (visitor.visitStatWhile?.(node) === false) return;
3102
- visitExpression(node.condition, visitor);
3103
- visitStatBlock(node.body, visitor);
3104
- }
3105
- function visitStatRepeat(node, visitor) {
3106
- if (visitor.visitStatRepeat?.(node) === false) return;
3107
- visitStatBlock(node.body, visitor);
3108
- visitExpression(node.condition, visitor);
3109
- }
3110
- function visitStatReturn(node, visitor) {
3111
- if (visitor.visitStatReturn?.(node) === false) return;
3112
- visitPunctuated(node.expressions, visitor, visitExpression);
3113
- }
3114
- function visitStatLocal(node, visitor) {
3115
- if (visitor.visitStatLocal?.(node) === false) return;
3116
- visitPunctuated(node.values, visitor, visitExpression);
3117
- }
3118
- function visitStatFor(node, visitor) {
3119
- if (visitor.visitStatFor?.(node) === false) return;
3120
- visitExpression(node.from, visitor);
3121
- visitExpression(node.to, visitor);
3122
- if (node.step) visitExpression(node.step, visitor);
3123
- visitStatBlock(node.body, visitor);
3124
- }
3125
- function visitStatForIn(node, visitor) {
3126
- if (visitor.visitStatForIn?.(node) === false) return;
3127
- visitPunctuated(node.values, visitor, visitExpression);
3128
- visitStatBlock(node.body, visitor);
3129
- }
3130
- function visitStatAssign(node, visitor) {
3131
- if (visitor.visitStatAssign?.(node) === false) return;
3132
- visitPunctuated(node.variables, visitor, visitExpression);
3133
- visitPunctuated(node.values, visitor, visitExpression);
3134
- }
3135
- function visitStatCompoundAssign(node, visitor) {
3136
- if (visitor.visitStatCompoundAssign?.(node) === false) return;
3137
- visitExpression(node.variable, visitor);
3138
- visitExpression(node.value, visitor);
3139
- }
3140
- function visitStatExpr(node, visitor) {
3141
- if (visitor.visitStatExpr?.(node) === false) return;
3142
- visitExpression(node.expression, visitor);
3143
- }
3144
- function visitStatFunction(node, visitor) {
3145
- if (visitor.visitStatFunction?.(node) === false) return;
3146
- visitExpression(node.name, visitor);
3147
- visitExprFunction(node.func, visitor);
3148
- }
3149
- function visitStatLocalFunction(node, visitor) {
3150
- if (visitor.visitStatLocalFunction?.(node) === false) return;
3151
- visitExprFunction(node.func, visitor);
3152
- }
3153
- function visitExprFunction(node, visitor) {
3154
- if (visitor.visitExprFunction?.(node) === false) return;
3155
- visitStatBlock(node.body, visitor);
3156
- visitor.visitExprFunctionEnd?.(node);
3157
- }
3158
- function visitExprCall(node, visitor) {
3159
- if (visitor.visitExprCall?.(node) === false) return;
3160
- visitExpression(node.func, visitor);
3161
- visitPunctuated(node.arguments, visitor, visitExpression);
3162
- }
3163
- function visitExprUnary(node, visitor) {
3164
- if (visitor.visitExprUnary?.(node) === false) return;
3165
- visitExpression(node.operand, visitor);
3166
- }
3167
- function visitExprBinary(node, visitor) {
3168
- if (visitor.visitExprBinary?.(node) === false) return;
3169
- visitExpression(node.lhsOperand, visitor);
3170
- visitExpression(node.rhsOperand, visitor);
3171
- }
3172
- function visitExprTable(node, visitor) {
3173
- if (visitor.visitExprTable?.(node) === false) return;
3174
- for (const item of node.entries) visitTableExprItem(item, visitor);
3175
- }
3176
- function visitTableExprItem(node, visitor) {
3177
- if (visitor.visitTableExprItem?.(node) === false) return;
3178
- visitExpression(node.value, visitor);
3179
- if (node.kind === "general") visitExpression(node.key, visitor);
3180
- }
3181
- function visitExprIndexName(node, visitor) {
3182
- if (visitor.visitExprIndexName?.(node) === false) return;
3183
- visitExpression(node.expression, visitor);
3184
- }
3185
- function visitExprIndexExpr(node, visitor) {
3186
- if (visitor.visitExprIndexExpr?.(node) === false) return;
3187
- visitExpression(node.expression, visitor);
3188
- visitExpression(node.index, visitor);
3189
- }
3190
- function visitExprGroup(node, visitor) {
3191
- if (visitor.visitExprGroup?.(node) === false) return;
3192
- visitExpression(node.expression, visitor);
3193
- }
3194
- function visitExprInterpString(node, visitor) {
3195
- if (visitor.visitExprInterpString?.(node) === false) return;
3196
- for (const expr of node.expressions) visitExpression(expr, visitor);
3197
- }
3198
- function visitExprTypeAssertion(node, visitor) {
3199
- if (visitor.visitExprTypeAssertion?.(node) === false) return;
3200
- visitExpression(node.operand, visitor);
3201
- }
3202
- function visitExprIfElse(node, visitor) {
3203
- if (visitor.visitExprIfElse?.(node) === false) return;
3204
- visitExpression(node.condition, visitor);
3205
- visitExpression(node.thenExpr, visitor);
3206
- for (const elseif of node.elseifs) visitElseIfExpr(elseif, visitor);
3207
- visitExpression(node.elseExpr, visitor);
3208
- }
3209
- function visitElseIfExpr(node, visitor) {
3210
- visitExpression(node.condition, visitor);
3211
- visitExpression(node.thenExpr, visitor);
3212
- }
3213
- function visitExprInstantiate(node, visitor) {
3214
- if (visitor.visitExprInstantiate?.(node) === false) return;
3215
- visitExpression(node.expr, visitor);
3216
- }
3217
- //#endregion
3218
- //#region src/formatters/github-actions.ts
3219
- const SEPARATOR = " · ";
3220
- function escapeData(value) {
3221
- return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
3222
- }
3223
- function escapeProperty(value) {
3224
- return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
3225
- }
3226
- function formatAnnotation(annotation) {
3227
- const properties = [`file=${escapeProperty(annotation.file)}`];
3228
- if (annotation.line !== void 0) properties.push(`line=${String(annotation.line)}`);
3229
- if (annotation.col !== void 0) properties.push(`col=${String(annotation.col)}`);
3230
- if (annotation.title !== void 0) properties.push(`title=${escapeProperty(annotation.title)}`);
3231
- return `::error ${properties.join(",")}::${escapeData(annotation.message)}`;
3232
- }
3233
- function collectAnnotations(result, options) {
3234
- const annotations = [];
3235
- for (const file of result.testResults) {
3236
- if (hasExecError(file)) {
3237
- collectExecErrorAnnotation(annotations, file, options);
3238
- continue;
3239
- }
3240
- collectTestFailureAnnotations(annotations, file, options);
3241
- }
3242
- return annotations;
3243
- }
3244
- function formatAnnotations(result, options) {
3245
- const annotations = collectAnnotations(result, options);
3246
- if (annotations.length === 0) return "";
3247
- return annotations.map(formatAnnotation).join("\n");
3248
- }
3249
- function formatJobSummary(result, options) {
3250
- const fileLink = createFileLink(options);
3251
- const lines = ["## Test Results\n", renderStats(result)];
3252
- const failures = [];
3253
- for (const file of result.testResults) {
3254
- if (hasExecError(file)) {
3255
- failures.push({
3256
- file: makeRelative(file.testFilePath, options.workspace),
3257
- title: "Test suite failed to run"
3258
- });
3259
- continue;
3260
- }
3261
- for (const test of file.testResults) {
3262
- if (test.status !== "failed") continue;
3263
- failures.push({
3264
- file: makeRelative(file.testFilePath, options.workspace),
3265
- title: test.fullName
3266
- });
3267
- }
3268
- }
3269
- if (failures.length > 0) {
3270
- lines.push("### Failures\n");
3271
- for (const failure of failures) {
3272
- const link = fileLink(failure.file);
3273
- const fileRef = link !== void 0 ? `[${failure.file}](${link})` : failure.file;
3274
- lines.push(`- **${failure.title}** in ${fileRef}`);
3275
- }
3276
- lines.push("");
3277
- }
3278
- return lines.join("\n");
3279
- }
3280
- function resolveGitHubActionsOptions(userOptions, sourceMapper, environment = process.env) {
3281
- return {
3282
- repository: userOptions.jobSummary?.fileLinks?.repository ?? environment["GITHUB_REPOSITORY"],
3283
- serverUrl: environment["GITHUB_SERVER_URL"],
3284
- sha: userOptions.jobSummary?.fileLinks?.commitHash ?? environment["GITHUB_SHA"],
3285
- sourceMapper,
3286
- workspace: userOptions.jobSummary?.fileLinks?.workspacePath ?? environment["GITHUB_WORKSPACE"]
3287
- };
3288
- }
3289
- function makeRelative(filePath, workspace) {
3290
- if (workspace === void 0) return filePath;
3291
- const normalized = filePath.replace(/\\/g, "/");
3292
- const normalizedWorkspace = workspace.replace(/\\/g, "/").replace(/\/$/, "");
3293
- if (normalized.startsWith(`${normalizedWorkspace}/`)) return normalized.slice(normalizedWorkspace.length + 1);
3294
- return filePath;
3295
- }
3296
- function collectExecErrorAnnotation(annotations, file, options) {
3297
- annotations.push({
3298
- file: makeRelative(file.testFilePath, options.workspace),
3299
- message: file.failureMessage,
3300
- title: "Test suite failed to run"
3301
- });
3302
- }
3303
- function collectTestFailureAnnotations(annotations, file, options) {
3304
- for (const test of file.testResults) {
3305
- if (test.status !== "failed") continue;
3306
- const firstFailure = test.failureMessages[0] ?? "";
3307
- let annotationFile = file.testFilePath;
3308
- let line;
3309
- let column;
3310
- if (options.sourceMapper !== void 0 && firstFailure !== "") {
3311
- const location = options.sourceMapper.mapFailureWithLocations(firstFailure).locations[0];
3312
- if (location?.tsPath !== void 0) {
3313
- annotationFile = location.tsPath;
3314
- line = location.tsLine;
3315
- column = location.tsColumn;
3316
- } else if (location !== void 0) {
3317
- annotationFile = location.luauPath;
3318
- line = location.luauLine;
3319
- }
3320
- }
3321
- annotations.push({
3322
- col: column,
3323
- file: makeRelative(annotationFile, options.workspace),
3324
- line,
3325
- message: firstFailure,
3326
- title: test.fullName
3327
- });
3328
- }
3329
- }
3330
- function noun(count, singular, plural) {
3331
- return count === 1 ? singular : plural;
3332
- }
3333
- function renderStats(result) {
3334
- const failedFiles = result.testResults.filter((file) => file.numFailingTests > 0 || hasExecError(file)).length;
3335
- const passedFiles = result.testResults.filter((file) => file.numFailingTests === 0 && !hasExecError(file)).length;
3336
- const totalFiles = failedFiles + passedFiles;
3337
- const fileInfo = [];
3338
- if (failedFiles > 0) fileInfo.push(`❌ **${String(failedFiles)} ${noun(failedFiles, "failure", "failures")}**`);
3339
- if (passedFiles > 0) fileInfo.push(`✅ **${String(passedFiles)} ${noun(passedFiles, "pass", "passes")}**`);
3340
- fileInfo.push(`${String(totalFiles)} total`);
3341
- const testInfo = [];
3342
- if (result.numFailedTests > 0) testInfo.push(`❌ **${String(result.numFailedTests)} ${noun(result.numFailedTests, "failure", "failures")}**`);
3343
- if (result.numPassedTests > 0) testInfo.push(`✅ **${String(result.numPassedTests)} ${noun(result.numPassedTests, "pass", "passes")}**`);
3344
- const primaryTotal = result.numFailedTests + result.numPassedTests;
3345
- testInfo.push(`${String(primaryTotal)} total`);
3346
- let output = "### Summary\n\n";
3347
- output += `- **Test Files**: ${fileInfo.join(SEPARATOR)}\n`;
3348
- output += `- **Test Results**: ${testInfo.join(SEPARATOR)}\n`;
3349
- const otherInfo = [];
3350
- if (result.numPendingTests > 0) otherInfo.push(`${String(result.numPendingTests)} ${noun(result.numPendingTests, "skip", "skips")}`);
3351
- if (result.numTodoTests !== void 0 && result.numTodoTests > 0) otherInfo.push(`${String(result.numTodoTests)} ${noun(result.numTodoTests, "todo", "todos")}`);
3352
- if (otherInfo.length > 0) {
3353
- const otherTotal = result.numPendingTests + (result.numTodoTests ?? 0);
3354
- otherInfo.push(`${String(otherTotal)} total`);
3355
- output += `- **Other**: ${otherInfo.join(SEPARATOR)}\n`;
3356
- }
3357
- return output;
3358
- }
3359
- function createFileLink(options) {
3360
- const { repository, serverUrl, sha } = options;
3361
- if (serverUrl === void 0 || repository === void 0 || sha === void 0) return (_filePath) => {};
3362
- return (filePath) => `${serverUrl}/${repository}/blob/${sha}/${filePath}`;
3363
- }
3364
- //#endregion
3365
- //#region src/typecheck/collect.ts
3366
- const TEST_FUNCTIONS = new Set(["it", "test"]);
3367
- const ALL_FUNCTIONS = new Set([...new Set(["describe", "suite"]), ...TEST_FUNCTIONS]);
3368
- function collectTestDefinitions(source) {
3369
- const result = parseSync("test.ts", source);
3370
- const raw = [];
3371
- new Visitor({ CallExpression(node) {
3372
- const definition = extractDefinition(node, source);
3373
- if (definition) raw.push(definition);
3374
- } }).visit(result.program);
3375
- raw.sort((a, b) => a.start - b.start);
3376
- return buildAncestorChain(raw);
3377
- }
3378
- function buildAncestorChain(sorted) {
3379
- const result = [];
3380
- const suiteStack = [];
3381
- for (const definition of sorted) {
3382
- while (suiteStack.length > 0 && suiteStack.at(-1).end <= definition.start) suiteStack.pop();
3383
- result.push({
3384
- ...definition,
3385
- ancestorNames: suiteStack.map((suite) => suite.name)
3386
- });
3387
- if (definition.type === "suite") suiteStack.push({
3388
- name: definition.name,
3389
- end: definition.end
3390
- });
3391
- }
3392
- return result;
3393
- }
3394
- function isStringLiteral(node) {
3395
- return node.type === "Literal" && typeof node.value === "string";
3396
- }
3397
- function isTemplateLiteral(node) {
3398
- return node.type === "TemplateLiteral";
3399
- }
3400
- function extractStringArgument(node, source) {
3401
- if (isStringLiteral(node)) return node.value;
3402
- if (isTemplateLiteral(node)) {
3403
- if (node.quasis.length === 1 && node.quasis[0] !== void 0) return node.quasis[0].value.raw;
3404
- return source.slice(node.start + 1, node.end - 1);
3405
- }
3406
- return source.slice(node.start, node.end);
3407
- }
3408
- function isIdentifier(node) {
3409
- return node.type === "Identifier";
3410
- }
3411
- function isStaticMemberExpression(node) {
3412
- return node.type === "MemberExpression" && "computed" in node && !node.computed;
3413
- }
3414
- function getCalleeName(callee) {
3415
- if (isIdentifier(callee)) return callee.name;
3416
- if (isStaticMemberExpression(callee) && isIdentifier(callee.object) && ALL_FUNCTIONS.has(callee.object.name)) return callee.object.name;
3417
- }
3418
- function extractDefinition(node, source) {
3419
- const name = getCalleeName(node.callee);
3420
- if (name === void 0 || !ALL_FUNCTIONS.has(name)) return;
3421
- const firstArgument = node.arguments[0];
3422
- if (firstArgument === void 0 || firstArgument.type === "SpreadElement") return;
3423
- return {
3424
- name: extractStringArgument(firstArgument, source),
3425
- end: node.end,
3426
- start: node.start,
3427
- type: TEST_FUNCTIONS.has(name) ? "test" : "suite"
3428
- };
3429
- }
3430
- //#endregion
3431
- //#region src/typecheck/parse.ts
3432
- const errorCodeRegExp = /error TS(?<errorCode>\d+)/;
3433
- function parseTscErrorLine(line) {
3434
- const parenIndex = line.lastIndexOf("(", line.indexOf("): error TS"));
3435
- if (parenIndex === -1) return ["", null];
3436
- const filePath = line.slice(0, parenIndex);
3437
- const rest = line.slice(parenIndex);
3438
- const closeParenIndex = rest.indexOf(")");
3439
- const [lineString, columnString] = rest.slice(1, closeParenIndex).split(",");
3440
- if (lineString === void 0 || lineString === "" || columnString === void 0 || columnString === "") return [filePath, null];
3441
- const afterParen = rest.slice(closeParenIndex + 1);
3442
- const errorCodeString = errorCodeRegExp.exec(afterParen)?.groups?.["errorCode"];
3443
- if (errorCodeString === void 0) return [filePath, null];
3444
- const errorCode = Number(errorCodeString);
3445
- const marker = `error TS${String(errorCode)}: `;
3446
- const markerIndex = afterParen.indexOf(marker);
3447
- const errorMessage = afterParen.slice(markerIndex + marker.length).trim();
3448
- return [filePath, {
3449
- column: Number(columnString),
3450
- errorCode,
3451
- errorMessage,
3452
- filePath,
3453
- line: Number(lineString)
3454
- }];
3455
- }
3456
- function parseTscOutput(stdout) {
3457
- const map = /* @__PURE__ */ new Map();
3458
- const merged = stdout.split(/\r?\n/).reduce((lines, next) => {
3459
- if (!next) return lines;
3460
- if (next[0] !== " ") lines.push(next);
3461
- else if (lines.length > 0) lines[lines.length - 1] += `\n${next}`;
3462
- return lines;
3463
- }, []);
3464
- for (const line of merged) {
3465
- const [filePath, info] = parseTscErrorLine(line);
3466
- if (!info) continue;
3467
- const existing = map.get(filePath);
3468
- if (existing) existing.push(info);
3469
- else map.set(filePath, [info]);
3470
- }
3471
- return map;
3472
- }
3473
- //#endregion
3474
- //#region src/typecheck/runner.ts
3475
- function createLocationsIndexMap(source) {
3476
- const map = /* @__PURE__ */ new Map();
3477
- let index = 0;
3478
- let line = 1;
3479
- let column = 1;
3480
- for (const char of source) {
3481
- map.set(`${String(line)}:${String(column)}`, index);
3482
- index++;
3483
- if (char === "\n") {
3484
- line++;
3485
- column = 1;
3486
- } else column++;
3487
- }
3488
- return map;
3489
- }
3490
- function mapErrorsToTests(errors, files, startTime) {
3491
- const testResults = [];
3492
- let numberFailed = 0;
3493
- let numberPassed = 0;
3494
- for (const [filePath, fileInfo] of files) {
3495
- const fileResult = buildFileResult(filePath, fileInfo, errors.get(filePath) ?? []);
3496
- testResults.push(fileResult);
3497
- numberFailed += fileResult.numFailingTests;
3498
- numberPassed += fileResult.numPassingTests;
3499
- }
3500
- return {
3501
- numFailedTests: numberFailed,
3502
- numPassedTests: numberPassed,
3503
- numPendingTests: 0,
3504
- numTotalTests: numberFailed + numberPassed,
3505
- startTime,
3506
- success: numberFailed === 0,
3507
- testResults
3508
- };
3509
- }
3510
- function isCompositeProject(rootDirectory, tsconfig) {
3511
- const tsconfigPath = tsconfig !== void 0 ? path$1.resolve(rootDirectory, tsconfig) : path$1.join(rootDirectory, "tsconfig.json");
3512
- try {
3513
- return parseJSONC(fs$1.readFileSync(tsconfigPath, "utf-8"))["compilerOptions"]?.["composite"] === true;
3514
- } catch (err) {
3515
- if (tsconfig !== void 0) {
3516
- const message = err instanceof Error ? err.message : String(err);
3517
- process.stderr.write(`Warning: could not read tsconfig "${tsconfigPath}": ${message}\n`);
3518
- }
3519
- return false;
3520
- }
3521
- }
3522
- function runTypecheck(options) {
3523
- const startTime = Date.now();
3524
- const errors = parseTscOutput(spawnTsgo(options));
3525
- const files = /* @__PURE__ */ new Map();
3526
- for (const filePath of options.files) {
3527
- const source = fs$1.readFileSync(filePath, "utf-8");
3528
- const definitions = collectTestDefinitions(source);
3529
- const resolvedPath = path$1.resolve(filePath);
3530
- const key = normalizeWindowsPath(path$1.relative(options.rootDir, resolvedPath));
3531
- files.set(key, {
3532
- definitions,
3533
- source
3534
- });
3535
- }
3536
- const resolvedErrors = /* @__PURE__ */ new Map();
3537
- for (const [errorPath, errorList] of errors) {
3538
- const resolved = path$1.resolve(options.rootDir, errorPath);
3539
- const key = normalizeWindowsPath(path$1.relative(options.rootDir, resolved));
3540
- resolvedErrors.set(key, errorList);
3541
- }
3542
- return mapErrorsToTests(resolvedErrors, files, startTime);
3543
- }
3544
- function buildFileResult(filePath, fileInfo, errors) {
3545
- const indexMap = createLocationsIndexMap(fileInfo.source);
3546
- const testDefinitions = fileInfo.definitions.filter((definition) => definition.type === "test");
3547
- const sortedDefinitions = [...testDefinitions].sort((a, b) => b.start - a.start);
3548
- const errorsByTest = /* @__PURE__ */ new Map();
3549
- const fileErrors = [];
3550
- for (const error of errors) {
3551
- const charIndex = indexMap.get(`${String(error.line)}:${String(error.column)}`);
3552
- const definition = charIndex !== void 0 ? sortedDefinitions.find((td) => td.start <= charIndex && td.end >= charIndex) : void 0;
3553
- const message = `TS${String(error.errorCode)}: ${error.errorMessage}`;
3554
- if (definition) {
3555
- const existing = errorsByTest.get(definition.name) ?? [];
3556
- existing.push(message);
3557
- errorsByTest.set(definition.name, existing);
3558
- } else fileErrors.push(message);
3559
- }
3560
- const testCases = testDefinitions.map((definition) => {
3561
- const failures = errorsByTest.get(definition.name) ?? [];
3562
- return {
3563
- ancestorTitles: definition.ancestorNames,
3564
- failureMessages: failures,
3565
- fullName: [...definition.ancestorNames, definition.name].join(" > "),
3566
- status: failures.length > 0 ? "failed" : "passed",
3567
- title: definition.name
3568
- };
3569
- });
3570
- if (fileErrors.length > 0) testCases.unshift({
3571
- ancestorTitles: [],
3572
- failureMessages: fileErrors,
3573
- fullName: "<file-level type error>",
3574
- status: "failed",
3575
- title: "<file-level type error>"
3576
- });
3577
- const numberFailing = testCases.filter((testCase) => testCase.status === "failed").length;
3578
- return {
3579
- numFailingTests: numberFailing,
3580
- numPassingTests: testCases.length - numberFailing,
3581
- numPendingTests: 0,
3582
- testFilePath: filePath,
3583
- testResults: testCases
3584
- };
3585
- }
3586
- function isExecSyncError(err) {
3587
- return typeof err === "object" && err !== null && ("stdout" in err || "stderr" in err);
3588
- }
3589
- function resolveTsgoScript() {
3590
- const packageJsonPath = createRequire(import.meta.url).resolve("@typescript/native-preview/package.json");
3591
- return path$1.join(path$1.dirname(packageJsonPath), "bin", "tsgo.js");
3592
- }
3593
- function spawnTsgo(options) {
3594
- const composite = isCompositeProject(options.rootDir, options.tsconfig);
3595
- const args = [];
3596
- if (composite) args.push("--build", "--emitDeclarationOnly");
3597
- else args.push("--noEmit");
3598
- args.push("--pretty", "false");
3599
- if (options.tsconfig !== void 0) {
3600
- const resolvedTsconfig = path$1.resolve(options.rootDir, options.tsconfig);
3601
- if (composite) args.push(resolvedTsconfig);
3602
- else args.push("-p", resolvedTsconfig);
3603
- }
3604
- const tsgoScript = resolveTsgoScript();
3605
- try {
3606
- return execFileSync(process.execPath, [tsgoScript, ...args], {
3607
- cwd: options.rootDir,
3608
- encoding: "utf-8",
3609
- stdio: [
3610
- "ignore",
3611
- "pipe",
3612
- "pipe"
3613
- ]
3614
- });
3615
- } catch (err) {
3616
- if (!isExecSyncError(err)) throw err;
3617
- return err.stdout ?? err.stderr ?? "";
3618
- }
3619
- }
3620
- //#endregion
3621
- //#region src/utils/game-output.ts
3622
- function formatGameOutputNotice(filePath, entryCount) {
3623
- if (entryCount === 0) return "";
3624
- return `Game output (${String(entryCount)} entries) written to ${filePath}`;
3625
- }
3626
- function parseGameOutput(raw) {
3627
- if (raw === void 0) return [];
3628
- try {
3629
- const parsed = JSON.parse(raw);
3630
- if (!Array.isArray(parsed) || parsed.length === 0) return [];
3631
- return parsed;
3632
- } catch {
3633
- return [];
3634
- }
3635
- }
3636
- function writeGameOutput(filePath, entries) {
3637
- const absolutePath = path$1.resolve(filePath);
3638
- const directoryPath = path$1.dirname(absolutePath);
3639
- if (!fs$1.existsSync(directoryPath)) fs$1.mkdirSync(directoryPath, { recursive: true });
3640
- fs$1.writeFileSync(absolutePath, JSON.stringify(entries, null, 2));
3641
- }
3642
- //#endregion
3643
- export { collectMounts as A, createOpenCloudBackend as B, formatFailure as C, formatTypecheckSummary as D, formatTestSummary as E, loadConfig$1 as F, VALID_BACKENDS as G, generateTestScript as H, resolveConfig as I, isValidBackend as J, defineConfig as K, StudioBackend as L, findInTree as M, pruneAncestors as N, formatBanner as O, resolveNestedProjects as P, parseJestOutput as Q, createStudioBackend as R, formatAgentMultiProject as S, formatResult as T, DEFAULT_CONFIG as U, buildJestArgv as V, ROOT_ONLY_KEYS as W, LuauScriptError as X, hashBuffer as Y, extractJsonFromOutput as Z, resolveTsconfigDirectories as _, formatAnnotations as a, formatJson as b, visitBlock as c, buildProjectJob as d, execute as f, processProjectResult as g, loadCoverageManifest as h, runTypecheck as i, collectPaths as j, combineSourceMappers as k, visitExpression as l, formatExecuteOutput as m, parseGameOutput as n, formatJobSummary as o, executeBackend as p, defineProject as q, writeGameOutput as r, resolveGitHubActionsOptions as s, formatGameOutputNotice as t, visitStatement as u, rojoProjectSchema as v, formatMultiProjectResult as w, writeJsonFile as x, findFormatterOptions as y, OpenCloudBackend as z };