@isentinel/jest-roblox 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.mts +2 -2
- package/dist/cli.mjs +107 -75
- package/dist/{game-output--EpqHGEr.mjs → game-output-DO5fOpW3.mjs} +800 -316
- package/dist/index.d.mts +74 -30
- package/dist/index.mjs +2 -2
- package/dist/{schema-hhbEFVXy.d.mts → schema-ua9HqdbX.d.mts} +23 -3
- package/package.json +36 -6
- package/plugin/JestRobloxRunner.rbxm +0 -0
- package/plugin/out/shared/entry.luau +8 -0
- package/plugin/out/shared/instance-resolver.luau +93 -0
- package/plugin/out/shared/mock/CoreScriptSyncService.luau +19 -0
- package/plugin/out/shared/mock/FileSystemService.luau +30 -0
- package/plugin/out/shared/runner.luau +172 -0
- package/plugin/out/shared/snapshot-patch.luau +99 -0
- package/plugin/plugin.project.json +4 -1
- package/plugin/src/test-in-run-mode.server.luau +2 -15
- package/plugin/sourcemap.json +0 -1
- package/plugin/src/patch/CoreScriptSyncService.luau +0 -19
- package/plugin/src/patch/FileSystemService.luau +0 -27
- package/plugin/src/patch/getDataModelService.luau +0 -19
- package/plugin/src/test-runner.luau +0 -226
|
@@ -10,9 +10,13 @@ import * as crypto from "node:crypto";
|
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import buffer from "node:buffer";
|
|
12
12
|
import { createJiti } from "jiti";
|
|
13
|
+
import { getTsconfig } from "get-tsconfig";
|
|
14
|
+
import { TraceMap, originalPositionFor, sourceContentFor } from "@jridgewell/trace-mapping";
|
|
13
15
|
import color from "tinyrainbow";
|
|
14
16
|
import hljs from "highlight.js/lib/core";
|
|
15
17
|
import typescript from "highlight.js/lib/languages/typescript";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { Visitor, parseSync } from "oxc-parser";
|
|
16
20
|
|
|
17
21
|
//#region src/reporter/parser.ts
|
|
18
22
|
const TASK_SCRIPT_PREFIX = /^TaskScript:\d+:\s*/;
|
|
@@ -86,6 +90,14 @@ function countBraces(line) {
|
|
|
86
90
|
}
|
|
87
91
|
return count;
|
|
88
92
|
}
|
|
93
|
+
function isValidJson(text) {
|
|
94
|
+
try {
|
|
95
|
+
JSON.parse(text);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
89
101
|
function extractExecutionError(object) {
|
|
90
102
|
let current = object;
|
|
91
103
|
while (current["parent"] !== void 0 && typeof current["parent"] === "object") current = current["parent"];
|
|
@@ -105,16 +117,13 @@ function extractSnapshotWrites(parsed) {
|
|
|
105
117
|
for (const [key, value] of Object.entries(writes)) if (typeof value === "string") record[key] = value;
|
|
106
118
|
return Object.keys(record).length > 0 ? record : void 0;
|
|
107
119
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
120
|
+
function stringifyError(err) {
|
|
121
|
+
if (typeof err === "string") return err;
|
|
122
|
+
if (typeof err === "object" && err !== null && "message" in err && typeof err.message === "string") return err.message;
|
|
123
|
+
return JSON.stringify(err) ?? String(err);
|
|
115
124
|
}
|
|
116
125
|
function unwrapResult(parsed) {
|
|
117
|
-
if ("err" in parsed && parsed["success"] === false) throw new LuauScriptError(
|
|
126
|
+
if ("err" in parsed && parsed["success"] === false) throw new LuauScriptError(stringifyError(parsed["err"]));
|
|
118
127
|
if ("value" in parsed && parsed["success"] === true) return parsed["value"];
|
|
119
128
|
return parsed;
|
|
120
129
|
}
|
|
@@ -125,8 +134,8 @@ function validateJestResult(value) {
|
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
//#endregion
|
|
128
|
-
//#region src/test-runner.luau
|
|
129
|
-
var test_runner_default = "local HttpService = game:GetService(\"HttpService\")\nlocal LogService = game:GetService(\"LogService\")\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n\nlocal t0 = os.clock()\nlocal baseConfig = HttpService:JSONDecode([[__CONFIG_JSON__]])\nlocal t_configDecode = os.clock()\n\ntype Config = {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _timing: boolean?,\n}\n\nlocal function fail(err: string)\n return {\n success = false,\n err = err,\n }\nend\n\nlocal config = table.clone(baseConfig) :: Config\nlocal timingEnabled = config._timing\n\nlocal function findInstance(path: string, class: string?): Instance\n local parts = string.split(path, \"/\")\n\n local result, current: Instance? = pcall(function()\n return game:FindService(parts[1])\n end)\n assert(result, `Failed to find service {parts[1]}: {current}`)\n\n for i = 2, #parts do\n assert(current, `Failed to find instance at {parts[i]}`)\n current = current:FindFirstChild(parts[i])\n end\n\n assert(current, `Failed to find instance at path {path}: {current}`)\n\n return current\nend\n\nlocal function getJest(): ModuleScript\n local jestPath = config.jestPath\n if jestPath then\n local instance = 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\n end\n\n local jestInstance = ReplicatedStorage:FindFirstChild(\"Jest\", true)\n assert(jestInstance, \"Failed to find Jest instance in ReplicatedStorage\")\n assert(\n jestInstance:IsA(\"ModuleScript\"),\n \"Jest instance in ReplicatedStorage is not a ModuleScript\"\n )\n return jestInstance\nend\n\nlocal t_findJest0 = os.clock()\nlocal findSuccess, findValue = pcall(getJest)\nlocal t_findJest = os.clock()\n\nif 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 \"[]\"\nend\n\nLogService:ClearOutput()\n\n-- Snapshot support: patch jest-roblox-shared before requiring Jest\nlocal snapshotWrites: { [string]: string } = {}\n\nlocal function getInstancePath(instance: Instance): string\n local parts = {} :: { string }\n local curr: Instance? = instance\n while curr and curr ~= game do\n table.insert(parts, 1, curr.Name)\n curr = curr.Parent\n end\n\n return table.concat(parts, \"/\")\nend\n\nlocal mockFileSystemService = {\n WriteFile = function(self: any, path: string, contents: string)\n -- Rewrite .snap.lua → .snap.luau\n snapshotWrites[string.gsub(path, \"%.snap%.lua$\", \".snap.luau\")] = contents\n end,\n CreateDirectories = function(self: any, _path: string) end,\n Exists = function(self: any, path: string): boolean\n return snapshotWrites[string.gsub(path, \"%.snap%.lua$\", \".snap.luau\")] ~= nil\n end,\n Remove = function(self: any, path: string)\n snapshotWrites[string.gsub(path, \"%.snap%.lua$\", \".snap.luau\")] = nil\n end,\n IsRegularFile = function(self: any, path: string): boolean\n return snapshotWrites[string.gsub(path, \"%.snap%.lua$\", \".snap.luau\")] ~= nil\n end,\n}\n\nlocal mockCoreScriptSyncService = {}\nfunction mockCoreScriptSyncService:GetScriptFilePath(script_: Instance): string\n return getInstancePath(script_)\nend\n\n-- Use _G so the mock survives across Jest's separate module loader context.\n-- Requires getDataModelService.lua to be patched (jest-roblox patch).\n_G.__mockGetDataModelService = function(service: string): any\n if service == \"FileSystemService\" then\n return mockFileSystemService\n elseif service == \"CoreScriptSyncService\" then\n return mockCoreScriptSyncService\n end\n\n local success, result = pcall(function()\n local svc = game:GetService(service)\n local _ = svc.Name\n return svc\n end)\n\n return if success then result else nil\nend\n\nlocal t_requireJest0 = os.clock()\nlocal Jest = (require :: any)(findValue)\nlocal t_requireJest = os.clock()\n\nlocal 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, 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, 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, findInstance(setupPath))\n end\n\n config.setupFilesAfterEnv = resolved :: any\n end\n local t_resolveSetupFiles = os.clock()\n\n config._timing = nil :: any\n\n local t_jestRunCLI0 = os.clock()\n local jestResult = Jest.runCLI(script, config, projects):expect()\n local t_jestRunCLI = os.clock()\n\n local result: { [string]: any } = {\n success = true,\n value = jestResult,\n }\n\n if timingEnabled then\n result._timing = {\n configDecode = t_configDecode - t0,\n findJest = t_findJest - t_findJest0,\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 }\n end\n\n if next(snapshotWrites) then\n result._snapshotWrites = snapshotWrites\n end\n\n return result\nend\n\nlocal runSuccess, runValue = pcall(runTests)\n\n_G.__mockGetDataModelService = nil\n\nlocal jestResult\nif not runSuccess then\n jestResult = HttpService:JSONEncode(fail(runValue :: any))\nelse\n jestResult = HttpService:JSONEncode(runValue)\nend\n\nlocal logSuccess, logHistory = pcall(function()\n return HttpService:JSONEncode(LogService:GetLogHistory())\nend)\n\nreturn jestResult, if logSuccess then logHistory else \"[]\"\n";
|
|
137
|
+
//#region src/test-runner.bundled.luau
|
|
138
|
+
var test_runner_bundled_default = "type PatchState__DARKLUA_TYPE_a = {\n robloxSharedExports: any,\n originalGetDataModelService: any,\n Runtime: any,\n originalRequireInternalModule: any,\n}\n\ntype Config__DARKLUA_TYPE_b = {\n jestPath: string?,\n projects: { string }?,\n setupFiles: { string }?,\n setupFilesAfterEnv: { string }?,\n _timing: boolean?,\n}\nlocal __JEST_MODULES __JEST_MODULES={cache={}, load=function(m)if not __JEST_MODULES.cache[m]then __JEST_MODULES.cache[m]={c=__JEST_MODULES[m]()}end return __JEST_MODULES.cache[m].c end}do function __JEST_MODULES.a()--!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(\n jestInstance:IsA(\"ModuleScript\"),\n \"Jest instance in ReplicatedStorage is not a ModuleScript\"\n )\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\")\n 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\")\n 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.b()--!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()--!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()--!strict\n\nlocal CoreScriptSyncService = __JEST_MODULES.load('b')\nlocal InstanceResolver = __JEST_MODULES.load('a')\n\nlocal module = {}\n\nfunction module.createMockGetDataModelService(snapshotWrites: { [string]: string })\n local FileSystemService = __JEST_MODULES.load('c')(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_a?\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 =\n 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\n getDataModelServiceChild\n and typeof(target) == \"Instance\"\n and target == getDataModelServiceChild\n 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_a?)\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()--!strict\n\nlocal HttpService = game:GetService(\"HttpService\")\nlocal LogService = game:GetService(\"LogService\")\n\nlocal InstanceResolver = __JEST_MODULES.load('a')\nlocal SnapshotPatch = __JEST_MODULES.load('d')\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\nlocal module = {}\n\nfunction module.run(callingScript: LuaSourceContainer, config: Config__DARKLUA_TYPE_b): (string, string)\n local t0 = os.clock()\n local timingEnabled = config._timing\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)),\n 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 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 config._timing = nil :: any\n\n local t_jestRunCLI0 = os.clock()\n local jestResult = Jest.runCLI(callingScript, config, projects):expect()\n local t_jestRunCLI = os.clock()\n\n local result: { [string]: any } = {\n success = true,\n value = jestResult,\n }\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 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(\n 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\n while not jestDone and not infiniteYieldMessage do\n task.wait(0.1)\n end\n\n watchdogConnection:Disconnect()\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(LogService:GetLogHistory())\n end)\n\n return jestResult, if logSuccess then logHistory else \"[]\"\nend\n\nreturn module\nend end--!strict\n\nlocal HttpService = game:GetService(\"HttpService\")\n\nlocal Runner = __JEST_MODULES.load('e')\n\nlocal config = HttpService:JSONDecode([=[__CONFIG_JSON__]=])\n\nreturn Runner.run(script, config)\n";
|
|
130
139
|
|
|
131
140
|
//#endregion
|
|
132
141
|
//#region src/test-script.ts
|
|
@@ -158,7 +167,7 @@ function buildJestArgv(options) {
|
|
|
158
167
|
}
|
|
159
168
|
function generateTestScript(options) {
|
|
160
169
|
const config = buildJestArgv(options);
|
|
161
|
-
return
|
|
170
|
+
return test_runner_bundled_default.replace("__CONFIG_JSON__", () => JSON.stringify(config));
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
//#endregion
|
|
@@ -247,7 +256,11 @@ var OpenCloudBackend = class {
|
|
|
247
256
|
this.credentials = credentials;
|
|
248
257
|
this.http = options?.http ?? createFetchClient({ "x-api-key": credentials.apiKey });
|
|
249
258
|
this.readFile = options?.readFile ?? ((fp) => fs.readFileSync(fp));
|
|
250
|
-
this.sleepFn = options?.sleep ?? (async (ms) =>
|
|
259
|
+
this.sleepFn = options?.sleep ?? (async (ms) => {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
setTimeout(resolve, ms);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
251
264
|
}
|
|
252
265
|
async runTests(options) {
|
|
253
266
|
const placeFilePath = path$1.resolve(options.config.rootDir, options.config.placeFile);
|
|
@@ -368,24 +381,28 @@ var StudioBackend = class {
|
|
|
368
381
|
createServer;
|
|
369
382
|
port;
|
|
370
383
|
timeout;
|
|
384
|
+
preConnected;
|
|
371
385
|
constructor(options) {
|
|
372
386
|
this.port = options.port;
|
|
373
387
|
this.timeout = options.timeout ?? 3e5;
|
|
374
388
|
this.createServer = options.createServer ?? ((port) => new WebSocketServer({ port }));
|
|
389
|
+
this.preConnected = options.preConnected;
|
|
375
390
|
}
|
|
376
391
|
async runTests(options) {
|
|
377
|
-
const
|
|
392
|
+
const pre = this.preConnected;
|
|
393
|
+
this.preConnected = void 0;
|
|
394
|
+
const wss = pre?.server ?? this.createServer(this.port);
|
|
378
395
|
try {
|
|
379
|
-
return await this.executeViaPlugin(wss, options);
|
|
396
|
+
return await this.executeViaPlugin(wss, options, pre?.socket);
|
|
380
397
|
} finally {
|
|
381
398
|
wss.close();
|
|
382
399
|
}
|
|
383
400
|
}
|
|
384
|
-
async executeViaPlugin(wss, options) {
|
|
401
|
+
async executeViaPlugin(wss, options, existingSocket) {
|
|
385
402
|
const requestId = randomUUID();
|
|
386
403
|
const config = buildJestArgv(options);
|
|
387
404
|
const executionStart = Date.now();
|
|
388
|
-
const message = await this.waitForResult(wss, requestId, config);
|
|
405
|
+
const message = await this.waitForResult(wss, requestId, config, existingSocket);
|
|
389
406
|
const executionMs = Date.now() - executionStart;
|
|
390
407
|
const parsed = parseJestOutput(message.jestOutput);
|
|
391
408
|
return {
|
|
@@ -396,12 +413,12 @@ var StudioBackend = class {
|
|
|
396
413
|
timing: { executionMs }
|
|
397
414
|
};
|
|
398
415
|
}
|
|
399
|
-
async waitForResult(wss, requestId, config) {
|
|
416
|
+
async waitForResult(wss, requestId, config, existingSocket) {
|
|
400
417
|
return new Promise((resolve, reject) => {
|
|
401
418
|
const timer = setTimeout(() => {
|
|
402
419
|
reject(/* @__PURE__ */ new Error("Timed out waiting for Studio plugin connection"));
|
|
403
420
|
}, this.timeout);
|
|
404
|
-
|
|
421
|
+
function attachSocket(ws) {
|
|
405
422
|
ws.send(JSON.stringify({
|
|
406
423
|
action: "run_tests",
|
|
407
424
|
config,
|
|
@@ -424,6 +441,10 @@ var StudioBackend = class {
|
|
|
424
441
|
clearTimeout(timer);
|
|
425
442
|
reject(err);
|
|
426
443
|
});
|
|
444
|
+
}
|
|
445
|
+
if (existingSocket) attachSocket(existingSocket);
|
|
446
|
+
wss.on("connection", (ws) => {
|
|
447
|
+
attachSocket(ws);
|
|
427
448
|
});
|
|
428
449
|
wss.on("error", (err) => {
|
|
429
450
|
clearTimeout(timer);
|
|
@@ -438,6 +459,14 @@ function createStudioBackend(options) {
|
|
|
438
459
|
|
|
439
460
|
//#endregion
|
|
440
461
|
//#region src/config/schema.ts
|
|
462
|
+
const VALID_BACKENDS = new Set([
|
|
463
|
+
"auto",
|
|
464
|
+
"open-cloud",
|
|
465
|
+
"studio"
|
|
466
|
+
]);
|
|
467
|
+
function isValidBackend(value) {
|
|
468
|
+
return VALID_BACKENDS.has(value);
|
|
469
|
+
}
|
|
441
470
|
const DEFAULT_CONFIG = {
|
|
442
471
|
backend: "auto",
|
|
443
472
|
cache: true,
|
|
@@ -456,10 +485,14 @@ const DEFAULT_CONFIG = {
|
|
|
456
485
|
"**/*.spec.ts",
|
|
457
486
|
"**/*.spec.tsx",
|
|
458
487
|
"**/*.test.ts",
|
|
459
|
-
"**/*.test.tsx"
|
|
488
|
+
"**/*.test.tsx",
|
|
489
|
+
"**/*.spec-d.ts",
|
|
490
|
+
"**/*.test-d.ts"
|
|
460
491
|
],
|
|
461
492
|
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
|
|
462
493
|
timeout: 3e5,
|
|
494
|
+
typecheck: false,
|
|
495
|
+
typecheckOnly: false,
|
|
463
496
|
verbose: false
|
|
464
497
|
};
|
|
465
498
|
|
|
@@ -475,76 +508,54 @@ async function findConfigFile(cwd) {
|
|
|
475
508
|
if (fs.existsSync(configPath)) return configPath;
|
|
476
509
|
}
|
|
477
510
|
}
|
|
478
|
-
async function loadConfig(configPath, cwd = process.cwd()) {
|
|
479
|
-
let config = {};
|
|
480
|
-
const resolvedPath = configPath ?? await findConfigFile(cwd);
|
|
481
|
-
if (resolvedPath !== void 0) config = await loadConfigFile(resolvedPath);
|
|
482
|
-
config.rootDir ??= cwd;
|
|
483
|
-
return resolveConfig(config);
|
|
484
|
-
}
|
|
485
511
|
async function loadConfigFile(configPath) {
|
|
486
512
|
const absolutePath = path$1.resolve(configPath);
|
|
487
513
|
if (!fs.existsSync(absolutePath)) throw new Error(`Config file not found: ${absolutePath}`);
|
|
488
514
|
return createJiti(import.meta.url).import(absolutePath, { default: true });
|
|
489
515
|
}
|
|
490
516
|
function resolveConfig(config) {
|
|
517
|
+
if (config.backend !== void 0 && !isValidBackend(config.backend)) {
|
|
518
|
+
const valid = [...VALID_BACKENDS].join(", ");
|
|
519
|
+
throw new Error(`Invalid backend "${config.backend}" in config file. Must be one of: ${valid}`);
|
|
520
|
+
}
|
|
491
521
|
const defined = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
492
522
|
return Object.assign({}, DEFAULT_CONFIG, defined);
|
|
493
523
|
}
|
|
524
|
+
async function loadConfig(configPath, cwd = process.cwd()) {
|
|
525
|
+
let config = {};
|
|
526
|
+
const resolvedPath = configPath ?? await findConfigFile(cwd);
|
|
527
|
+
if (resolvedPath !== void 0) config = await loadConfigFile(resolvedPath);
|
|
528
|
+
config.rootDir ??= cwd;
|
|
529
|
+
return resolveConfig(config);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/utils/normalize-windows-path.ts
|
|
534
|
+
const DRIVE_LETTER_START_REGEX = /^[A-Za-z]:\//;
|
|
535
|
+
function normalizeWindowsPath(input = "") {
|
|
536
|
+
if (!input) return input;
|
|
537
|
+
return input.replace(/\\/g, "/").replace(DRIVE_LETTER_START_REGEX, (driveLetterMatch) => driveLetterMatch.toUpperCase());
|
|
538
|
+
}
|
|
494
539
|
|
|
495
540
|
//#endregion
|
|
496
541
|
//#region src/source-mapper/column-finder.ts
|
|
497
542
|
/**
|
|
498
543
|
* Finds the column position of the failing matcher in an expect() call. Returns
|
|
499
|
-
* 1-indexed column position.
|
|
544
|
+
* 1-indexed column position, or undefined if no expect is found.
|
|
500
545
|
*/
|
|
501
546
|
function findExpectationColumn(lineText) {
|
|
502
|
-
if (!lineText) return
|
|
503
|
-
const expectIndex = lineText.search(/\bexpect\s
|
|
504
|
-
if (expectIndex === -1) return
|
|
547
|
+
if (!lineText) return;
|
|
548
|
+
const expectIndex = lineText.search(/\bexpect\s*[.(]/);
|
|
549
|
+
if (expectIndex === -1) return;
|
|
505
550
|
const afterExpect = lineText.slice(expectIndex);
|
|
506
551
|
const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
|
|
507
552
|
let lastMatcher = null;
|
|
508
553
|
for (const match of afterExpect.matchAll(matcherRegex)) lastMatcher = match;
|
|
509
554
|
const matcherName = lastMatcher?.[1];
|
|
510
|
-
if (lastMatcher === null || matcherName === void 0) return
|
|
555
|
+
if (lastMatcher === null || matcherName === void 0) return;
|
|
511
556
|
return expectIndex + (lastMatcher.index + lastMatcher[0].indexOf(matcherName)) + 1;
|
|
512
557
|
}
|
|
513
558
|
|
|
514
|
-
//#endregion
|
|
515
|
-
//#region src/source-mapper/line-mapper.ts
|
|
516
|
-
function mapLine(luauLineNumber, luauLines, tsLines) {
|
|
517
|
-
const luauLine = normalize(luauLines[luauLineNumber - 1]);
|
|
518
|
-
if (luauLine === void 0 || luauLine === "") return;
|
|
519
|
-
for (const [index, tsLine] of tsLines.entries()) if (normalize(tsLine) === luauLine) return index + 1;
|
|
520
|
-
const stripped = luauLine.replace(/\s/g, "");
|
|
521
|
-
for (const [index, tsLine] of tsLines.entries()) {
|
|
522
|
-
const tsNormalized = normalize(tsLine);
|
|
523
|
-
if (tsNormalized === void 0 || tsNormalized === "") continue;
|
|
524
|
-
const tsStripped = tsNormalized.replace(/\s/g, "");
|
|
525
|
-
if (tsStripped.includes(stripped) || stripped.includes(tsStripped)) return index + 1;
|
|
526
|
-
}
|
|
527
|
-
const temporaryPattern = buildTemporaryVariablePattern(luauLine);
|
|
528
|
-
if (temporaryPattern !== void 0) for (const [index, tsLine] of tsLines.entries()) {
|
|
529
|
-
const normalized = normalize(tsLine);
|
|
530
|
-
if (normalized !== void 0 && temporaryPattern.test(normalized)) return index + 1;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
const TEMP_VAR_RE = /_[a-z]\w*/g;
|
|
534
|
-
function buildTemporaryVariablePattern(line) {
|
|
535
|
-
const parts = line.split(TEMP_VAR_RE);
|
|
536
|
-
if (parts.length < 2) return;
|
|
537
|
-
const pattern = parts.map(escapeRegExp).join(".+?");
|
|
538
|
-
return new RegExp(`^${pattern}$`);
|
|
539
|
-
}
|
|
540
|
-
function escapeRegExp(text) {
|
|
541
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
542
|
-
}
|
|
543
|
-
function normalize(line) {
|
|
544
|
-
if (line === void 0) return;
|
|
545
|
-
return line.trim().replace(/;$/, "").replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").replace(/\.(\w+)\(/g, ":$1(");
|
|
546
|
-
}
|
|
547
|
-
|
|
548
559
|
//#endregion
|
|
549
560
|
//#region src/source-mapper/path-resolver.ts
|
|
550
561
|
function createPathResolver(rojoProject, config) {
|
|
@@ -611,6 +622,34 @@ function parseStack(input) {
|
|
|
611
622
|
};
|
|
612
623
|
}
|
|
613
624
|
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/source-mapper/v3-mapper.ts
|
|
627
|
+
const mapCache = /* @__PURE__ */ new Map();
|
|
628
|
+
function getSourceContent(luauPath, source) {
|
|
629
|
+
const traced = getTraceMap(luauPath);
|
|
630
|
+
if (traced === void 0) return;
|
|
631
|
+
return sourceContentFor(traced, source);
|
|
632
|
+
}
|
|
633
|
+
function mapFromSourceMap(luauPath, luauLine, luauColumn = 0) {
|
|
634
|
+
const traced = getTraceMap(luauPath);
|
|
635
|
+
if (traced === void 0) return;
|
|
636
|
+
const result = originalPositionFor(traced, {
|
|
637
|
+
column: luauColumn,
|
|
638
|
+
line: luauLine
|
|
639
|
+
});
|
|
640
|
+
if (result.line === null) return;
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
function getTraceMap(luauPath) {
|
|
644
|
+
let traced = mapCache.get(luauPath);
|
|
645
|
+
if (traced !== void 0) return traced;
|
|
646
|
+
const mapPath = `${luauPath}.map`;
|
|
647
|
+
if (!fs.existsSync(mapPath)) return;
|
|
648
|
+
traced = new TraceMap(fs.readFileSync(mapPath, "utf-8"));
|
|
649
|
+
mapCache.set(luauPath, traced);
|
|
650
|
+
return traced;
|
|
651
|
+
}
|
|
652
|
+
|
|
614
653
|
//#endregion
|
|
615
654
|
//#region src/source-mapper/index.ts
|
|
616
655
|
function createSourceMapper(config) {
|
|
@@ -622,17 +661,26 @@ function createSourceMapper(config) {
|
|
|
622
661
|
const tsPath = pathResolver.resolve(frame.dataModelPath);
|
|
623
662
|
if (tsPath === void 0) return;
|
|
624
663
|
const luauPath = tsPath.replace(config.rootDir, config.outDir).replace(/\.ts$/, ".luau");
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
664
|
+
const v3Result = mapFromSourceMap(luauPath, frame.line, frame.column);
|
|
665
|
+
if (v3Result !== void 0 && v3Result.source !== null && v3Result.line !== null) {
|
|
666
|
+
const mapDirectory = path$1.dirname(luauPath);
|
|
667
|
+
const resolvedTsPath = normalizeWindowsPath(path$1.resolve(mapDirectory, v3Result.source));
|
|
668
|
+
const tsLine = v3Result.line;
|
|
669
|
+
const embeddedContent = getSourceContent(luauPath, v3Result.source) ?? void 0;
|
|
670
|
+
const tsContent = embeddedContent ?? (fs.existsSync(resolvedTsPath) ? fs.readFileSync(resolvedTsPath, "utf-8") : void 0);
|
|
671
|
+
return {
|
|
672
|
+
luauPath,
|
|
673
|
+
mapped: {
|
|
674
|
+
column: tsContent !== void 0 ? findExpectationColumn(tsContent.split("\n")[tsLine - 1] ?? "") : void 0,
|
|
675
|
+
line: tsLine,
|
|
676
|
+
path: resolvedTsPath,
|
|
677
|
+
sourceContent: embeddedContent
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
}
|
|
629
681
|
return {
|
|
630
682
|
luauPath,
|
|
631
|
-
mapped:
|
|
632
|
-
column: findExpectationColumn(tsLines[tsLine - 1] ?? ""),
|
|
633
|
-
line: tsLine,
|
|
634
|
-
path: tsPath
|
|
635
|
-
}
|
|
683
|
+
mapped: void 0
|
|
636
684
|
};
|
|
637
685
|
}
|
|
638
686
|
return {
|
|
@@ -661,6 +709,7 @@ function createSourceMapper(config) {
|
|
|
661
709
|
locations.push({
|
|
662
710
|
luauLine: frame.line,
|
|
663
711
|
luauPath: frameResult.luauPath,
|
|
712
|
+
sourceContent: frameResult.mapped.sourceContent,
|
|
664
713
|
tsColumn: frameResult.mapped.column,
|
|
665
714
|
tsLine: frameResult.mapped.line,
|
|
666
715
|
tsPath: frameResult.mapped.path
|
|
@@ -670,12 +719,17 @@ function createSourceMapper(config) {
|
|
|
670
719
|
locations,
|
|
671
720
|
message: mappedMessage
|
|
672
721
|
};
|
|
722
|
+
},
|
|
723
|
+
resolveTestFilePath(testFilePath) {
|
|
724
|
+
const dataModelPath = testFilePath.replace(/^\//, "").replaceAll("/", ".");
|
|
725
|
+
return pathResolver.resolve(dataModelPath);
|
|
673
726
|
}
|
|
674
727
|
};
|
|
675
728
|
}
|
|
676
|
-
function getSourceSnippet(
|
|
677
|
-
|
|
678
|
-
|
|
729
|
+
function getSourceSnippet({ column, context = 2, filePath, line, sourceContent }) {
|
|
730
|
+
const content = sourceContent ?? (fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : void 0);
|
|
731
|
+
if (content === void 0) return;
|
|
732
|
+
const allLines = content.split("\n");
|
|
679
733
|
const startLine = Math.max(1, line - context);
|
|
680
734
|
const endLine = Math.min(allLines.length, line + context);
|
|
681
735
|
const lines = [];
|
|
@@ -691,6 +745,12 @@ function getSourceSnippet(filePath, line, context = 2, column) {
|
|
|
691
745
|
};
|
|
692
746
|
}
|
|
693
747
|
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/types/jest-result.ts
|
|
750
|
+
function hasExecError(file) {
|
|
751
|
+
return file.failureMessage !== void 0 && file.failureMessage !== "" && file.testResults.length === 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
694
754
|
//#endregion
|
|
695
755
|
//#region src/highlighter/luau-grammar.ts
|
|
696
756
|
const OPENING_LONG_BRACKET = "\\[=*\\[";
|
|
@@ -756,14 +816,6 @@ function luauGrammar(hljs) {
|
|
|
756
816
|
};
|
|
757
817
|
}
|
|
758
818
|
|
|
759
|
-
//#endregion
|
|
760
|
-
//#region src/utils/normalize-windows-path.ts
|
|
761
|
-
const DRIVE_LETTER_START_REGEX = /^[A-Za-z]:\//;
|
|
762
|
-
function normalizeWindowsPath(input = "") {
|
|
763
|
-
if (!input) return input;
|
|
764
|
-
return input.replace(/\\/g, "/").replace(DRIVE_LETTER_START_REGEX, (driveLetterMatch) => driveLetterMatch.toUpperCase());
|
|
765
|
-
}
|
|
766
|
-
|
|
767
819
|
//#endregion
|
|
768
820
|
//#region src/utils/colors.ts
|
|
769
821
|
hljs.registerLanguage("luau", luauGrammar);
|
|
@@ -832,51 +884,59 @@ function highlightTypeScript(source) {
|
|
|
832
884
|
//#region src/formatters/formatter.ts
|
|
833
885
|
function formatFailedTestsHeader(failCount, styles) {
|
|
834
886
|
const st = styles ?? createStyles(true);
|
|
835
|
-
const
|
|
836
|
-
const badge = st.failBadge(
|
|
837
|
-
|
|
887
|
+
const badgeText = ` Failed Tests ${failCount} `;
|
|
888
|
+
const badge = st.failBadge(badgeText);
|
|
889
|
+
const remaining = getTerminalWidth() - badgeText.length;
|
|
890
|
+
const leftWidth = Math.max(1, Math.floor(remaining / 2));
|
|
891
|
+
const rightWidth = Math.max(1, remaining - leftWidth);
|
|
892
|
+
return `${st.status.fail("⎯".repeat(leftWidth))}${badge}${st.status.fail("⎯".repeat(rightWidth))}`;
|
|
838
893
|
}
|
|
839
|
-
function
|
|
840
|
-
const
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const separatorLine = "⎯".repeat(24);
|
|
854
|
-
const counter = `[${failureIndex}/${totalFailures}]`;
|
|
855
|
-
lines.push("", st.status.fail(`${separatorLine}${counter}⎯`));
|
|
894
|
+
function parseErrorMessage(message) {
|
|
895
|
+
const lines = message.split("\n");
|
|
896
|
+
const snapshotHeaderIndex = lines.findIndex((line) => /^- Snapshot\s+- \d+/.test(line));
|
|
897
|
+
if (snapshotHeaderIndex !== -1) {
|
|
898
|
+
const diffLines = [];
|
|
899
|
+
for (let index = snapshotHeaderIndex; index < lines.length; index++) {
|
|
900
|
+
const line = lines[index];
|
|
901
|
+
if (line.startsWith("[string ")) break;
|
|
902
|
+
diffLines.push(line);
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
message: lines[0] ?? message,
|
|
906
|
+
snapshotDiff: diffLines.join("\n").trimEnd()
|
|
907
|
+
};
|
|
856
908
|
}
|
|
857
|
-
|
|
909
|
+
const expectedMatch = message.match(/Expected\b.*?:\s*(.+)/);
|
|
910
|
+
const receivedMatch = message.match(/Received\b.*?:\s*(.+)/);
|
|
911
|
+
return {
|
|
912
|
+
expected: expectedMatch?.[1],
|
|
913
|
+
message: lines[0] ?? message,
|
|
914
|
+
received: receivedMatch?.[1]
|
|
915
|
+
};
|
|
858
916
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
917
|
+
/**
|
|
918
|
+
* Extracts the meaningful error message from a Jest `failureMessage` string.
|
|
919
|
+
* Strips the "● Test suite failed to run" header, Roblox DataModel path chains,
|
|
920
|
+
* and stack trace lines.
|
|
921
|
+
*/
|
|
922
|
+
function cleanExecErrorMessage(raw) {
|
|
923
|
+
if (raw === "") return "";
|
|
924
|
+
const lines = raw.split("\n");
|
|
925
|
+
let contentLine;
|
|
926
|
+
let pastHeader = false;
|
|
927
|
+
for (const line of lines) {
|
|
928
|
+
const trimmed = line.trim();
|
|
929
|
+
if (trimmed.startsWith("●")) {
|
|
930
|
+
pastHeader = true;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (pastHeader && trimmed !== "") {
|
|
934
|
+
contentLine = trimmed;
|
|
935
|
+
break;
|
|
872
936
|
}
|
|
873
937
|
}
|
|
874
|
-
|
|
875
|
-
return
|
|
876
|
-
}
|
|
877
|
-
function formatRunHeader(options, styles) {
|
|
878
|
-
const st = styles ?? createStyles(options.color);
|
|
879
|
-
return `\n${st.runBadge(" RUN ")} ${st.location(`v${options.version}`)} ${st.lineNumber(options.rootDir)}\n`;
|
|
938
|
+
if (contentLine === void 0) return raw.trim();
|
|
939
|
+
return contentLine.replace(/^(?:[A-Za-z][\w.@-]*:\d+:\s*)+/, "");
|
|
880
940
|
}
|
|
881
941
|
function formatSourceSnippet(snippet, filePath, options) {
|
|
882
942
|
const useColor = options?.useColor ?? true;
|
|
@@ -891,45 +951,78 @@ function formatSourceSnippet(snippet, filePath, options) {
|
|
|
891
951
|
const padding = String(maxLineNumber).length;
|
|
892
952
|
for (const line of snippet.lines) {
|
|
893
953
|
const prefix = `${String(line.num).padStart(padding)}|`;
|
|
894
|
-
const highlighted = highlightSyntax(filePath, line.content, useColor);
|
|
954
|
+
const highlighted = highlightSyntax(filePath, expandTabs(line.content), useColor);
|
|
895
955
|
if (line.num === snippet.failureLine) {
|
|
896
956
|
lines.push(`${indent}${st.lineNumber(prefix)} ${highlighted}`);
|
|
897
957
|
if (snippet.column !== void 0) {
|
|
898
|
-
const
|
|
899
|
-
const
|
|
900
|
-
|
|
958
|
+
const beforeColumn = expandTabs(line.content.slice(0, snippet.column - 1));
|
|
959
|
+
const caretGutter = `${" ".repeat(padding)}|`;
|
|
960
|
+
const gutterPrefix = st.lineNumber(caretGutter);
|
|
961
|
+
lines.push(`${indent}${gutterPrefix} ${" ".repeat(beforeColumn.length)}${st.status.fail("^")}`);
|
|
901
962
|
}
|
|
902
963
|
} else lines.push(`${indent}${st.lineNumber(prefix)} ${highlighted}`);
|
|
903
964
|
}
|
|
904
965
|
return lines.join("\n");
|
|
905
966
|
}
|
|
967
|
+
function parseSourceLocation(message) {
|
|
968
|
+
const match = message.match(/([^\s:]+\.tsx?):(\d+)(?::(\d+))?/);
|
|
969
|
+
if (match === null) return;
|
|
970
|
+
return {
|
|
971
|
+
column: match[3] !== void 0 ? Number.parseInt(match[3], 10) : void 0,
|
|
972
|
+
line: Number.parseInt(match[2] ?? "", 10),
|
|
973
|
+
path: match[1] ?? ""
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
function formatFailure({ failureIndex, filePath, showLuau = false, sourceMapper, styles, test, totalFailures, useColor = true }) {
|
|
977
|
+
const st = styles ?? createStyles(useColor);
|
|
978
|
+
const lines = [];
|
|
979
|
+
const pathParts = filePath !== void 0 ? [filePath] : [];
|
|
980
|
+
pathParts.push(...test.ancestorTitles, test.title);
|
|
981
|
+
const testPath = pathParts.join(" > ");
|
|
982
|
+
lines.push("", `${st.failBadge(" FAIL ")} ${st.status.fail(testPath)}`);
|
|
983
|
+
for (const originalMessage of test.failureMessages) lines.push(...formatFailureMessage(originalMessage, {
|
|
984
|
+
filePath,
|
|
985
|
+
showLuau,
|
|
986
|
+
sourceMapper,
|
|
987
|
+
styles: st,
|
|
988
|
+
useColor
|
|
989
|
+
}));
|
|
990
|
+
if (failureIndex !== void 0 && totalFailures !== void 0) {
|
|
991
|
+
const counter = `[${failureIndex}/${totalFailures}]`;
|
|
992
|
+
const termWidth = getTerminalWidth();
|
|
993
|
+
const fillWidth = Math.max(1, termWidth - counter.length - 3);
|
|
994
|
+
lines.push("", st.dim(st.status.fail(`${"⎯".repeat(fillWidth)}${counter}⎯`)));
|
|
995
|
+
}
|
|
996
|
+
return lines.map((line) => ` ${line}`).join("\n");
|
|
997
|
+
}
|
|
998
|
+
function formatRunHeader(options, styles) {
|
|
999
|
+
const st = styles ?? createStyles(options.color);
|
|
1000
|
+
return `\n${st.runBadge(" RUN ")} ${st.location(`v${options.version}`)} ${st.lineNumber(options.rootDir)}\n`;
|
|
1001
|
+
}
|
|
906
1002
|
function formatTestSummary(result, timing, styles) {
|
|
907
1003
|
const st = styles ?? createStyles(true);
|
|
908
1004
|
const lines = [];
|
|
909
|
-
const
|
|
910
|
-
const labelStyle = hasFailed ? st.status.fail : st.dim;
|
|
1005
|
+
const execErrorFiles = result.testResults.filter(hasExecError).length;
|
|
911
1006
|
const totalFiles = result.testResults.length;
|
|
912
|
-
const failedFiles = result.testResults.filter((fr) => fr.numFailingTests > 0).length;
|
|
913
|
-
const skippedFiles = result.testResults.filter((fr) => fr.numFailingTests === 0 && fr.numPassingTests === 0).length;
|
|
1007
|
+
const failedFiles = result.testResults.filter((fr) => fr.numFailingTests > 0).length + execErrorFiles;
|
|
1008
|
+
const skippedFiles = result.testResults.filter((fr) => fr.numFailingTests === 0 && fr.numPassingTests === 0 && !hasExecError(fr)).length;
|
|
914
1009
|
const passedFiles = totalFiles - failedFiles - skippedFiles;
|
|
915
1010
|
const fileParts = [];
|
|
916
1011
|
if (passedFiles > 0) fileParts.push(st.summary.passed(`${passedFiles} passed`));
|
|
917
1012
|
if (failedFiles > 0) fileParts.push(st.summary.failed(`${failedFiles} failed`));
|
|
918
1013
|
if (skippedFiles > 0) fileParts.push(st.summary.pending(`${skippedFiles} skipped`));
|
|
1014
|
+
const snapshotLine = formatSnapshotLine(result.snapshot, st);
|
|
1015
|
+
if (snapshotLine !== void 0) lines.push(snapshotLine);
|
|
919
1016
|
const fileTotalLabel = st.dim(`(${totalFiles})`);
|
|
920
|
-
|
|
921
|
-
lines.push(`${fileLabel} ${fileParts.join(" | ")} ${fileTotalLabel}`);
|
|
1017
|
+
lines.push(`${st.dim(" Test Files")} ${fileParts.join(" | ")} ${fileTotalLabel}`);
|
|
922
1018
|
const testParts = [];
|
|
923
1019
|
if (result.numPassedTests > 0) testParts.push(st.summary.passed(`${result.numPassedTests} passed`));
|
|
924
1020
|
if (result.numFailedTests > 0) testParts.push(st.summary.failed(`${result.numFailedTests} failed`));
|
|
925
1021
|
if (result.numPendingTests > 0) testParts.push(st.summary.pending(`${result.numPendingTests} skipped`));
|
|
926
1022
|
const testTotalLabel = st.dim(`(${result.numTotalTests})`);
|
|
927
|
-
|
|
928
|
-
lines.push(`${testsLabel} ${testParts.join(" | ")} ${testTotalLabel}`);
|
|
1023
|
+
lines.push(`${st.dim(" Tests")} ${testParts.join(" | ")} ${testTotalLabel}`);
|
|
929
1024
|
const startAtStr = new Date(timing.startTime).toLocaleTimeString("en-GB", { hour12: false });
|
|
930
|
-
|
|
931
|
-
const startAtValue = hasFailed ? st.status.fail(startAtStr) : startAtStr;
|
|
932
|
-
lines.push(`${startAtLabel} ${startAtValue}`);
|
|
1025
|
+
lines.push(`${st.dim(" Start at")} ${startAtStr}`);
|
|
933
1026
|
const environmentMs = timing.executionMs - timing.testsMs;
|
|
934
1027
|
const uploadMs = timing.uploadMs ?? 0;
|
|
935
1028
|
const cliMs = timing.totalMs - uploadMs - timing.executionMs;
|
|
@@ -937,29 +1030,39 @@ function formatTestSummary(result, timing, styles) {
|
|
|
937
1030
|
if (timing.uploadMs !== void 0) breakdownParts.push(timing.uploadCached === true ? `upload ${timing.uploadMs}ms (cached)` : `upload ${timing.uploadMs}ms`);
|
|
938
1031
|
breakdownParts.push(`environment ${environmentMs}ms`, `tests ${timing.testsMs}ms`, `cli ${cliMs}ms`);
|
|
939
1032
|
const breakdown = st.dim(`(${breakdownParts.join(", ")})`);
|
|
940
|
-
|
|
941
|
-
const durationValue = hasFailed ? st.status.fail(`${timing.totalMs}ms`) : `${timing.totalMs}ms`;
|
|
942
|
-
lines.push(`${durationLabel} ${durationValue} ${breakdown}`);
|
|
1033
|
+
lines.push(`${st.dim(" Duration")} ${timing.totalMs}ms ${breakdown}`);
|
|
943
1034
|
return lines.join("\n");
|
|
944
1035
|
}
|
|
945
|
-
function
|
|
946
|
-
const
|
|
947
|
-
const
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1036
|
+
function formatResult(result, timing, options) {
|
|
1037
|
+
const styles = createStyles(options.color);
|
|
1038
|
+
const lines = [formatRunHeader(options, styles)];
|
|
1039
|
+
for (const file of result.testResults) lines.push(formatFileSummary(file, options, styles));
|
|
1040
|
+
const execErrors = result.testResults.filter(hasExecError);
|
|
1041
|
+
const totalDetailedFailures = result.numFailedTests + execErrors.length;
|
|
1042
|
+
if (totalDetailedFailures > 0) {
|
|
1043
|
+
lines.push("", formatFailedTestsHeader(totalDetailedFailures, styles));
|
|
1044
|
+
const failureCtx = {
|
|
1045
|
+
currentIndex: 1,
|
|
1046
|
+
totalFailures: totalDetailedFailures
|
|
1047
|
+
};
|
|
1048
|
+
for (const file of result.testResults) {
|
|
1049
|
+
const failures = formatFileFailures(file, options, styles, failureCtx);
|
|
1050
|
+
if (failures !== "") lines.push(failures);
|
|
1051
|
+
}
|
|
1052
|
+
for (const file of execErrors) lines.push(formatExecErrorDetail(file, styles, failureCtx));
|
|
1053
|
+
}
|
|
1054
|
+
lines.push("", formatTestSummary(result, timing, styles));
|
|
1055
|
+
if (!result.success) {
|
|
1056
|
+
const hints = formatLogHints(options, styles);
|
|
1057
|
+
if (hints !== "") lines.push("", hints);
|
|
1058
|
+
}
|
|
1059
|
+
return lines.join("\n");
|
|
954
1060
|
}
|
|
955
|
-
function
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
line: Number.parseInt(match[2] ?? "", 10),
|
|
961
|
-
path: match[1] ?? ""
|
|
962
|
-
};
|
|
1061
|
+
function getTerminalWidth() {
|
|
1062
|
+
return "columns" in process.stdout && process.stdout.columns || 80;
|
|
1063
|
+
}
|
|
1064
|
+
function identity(text) {
|
|
1065
|
+
return text;
|
|
963
1066
|
}
|
|
964
1067
|
function createStyles(useColor) {
|
|
965
1068
|
if (!useColor) return {
|
|
@@ -1013,59 +1116,139 @@ function createStyles(useColor) {
|
|
|
1013
1116
|
}
|
|
1014
1117
|
};
|
|
1015
1118
|
}
|
|
1016
|
-
function
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const failedCount = tests.filter((tc) => tc.status === "failed").length;
|
|
1024
|
-
const groupMeta = styles.dim(`(${groupTestCount} tests | `) + styles.summary.failed(`${failedCount} failed`) + styles.dim(")");
|
|
1025
|
-
const header = styles.status.fail(` ❯ ${describeName}`);
|
|
1026
|
-
lines.push(`${header} ${groupMeta}${groupDurationStr}`);
|
|
1027
|
-
for (const tc of tests) lines.push(formatTestInGroup(tc, styles));
|
|
1028
|
-
} else {
|
|
1029
|
-
const groupMeta = styles.dim(`(${groupTestCount} tests)`);
|
|
1030
|
-
const marker = styles.status.pass(" ✓");
|
|
1031
|
-
const name = styles.status.fail(` ${describeName}`);
|
|
1032
|
-
lines.push(`${marker}${name} ${groupMeta}${groupDurationStr}`);
|
|
1033
|
-
}
|
|
1034
|
-
return lines;
|
|
1119
|
+
function expandTabs(text, tabWidth = 4) {
|
|
1120
|
+
let result = "";
|
|
1121
|
+
for (const char of text) if (char === " ") {
|
|
1122
|
+
const spaces = tabWidth - result.length % tabWidth;
|
|
1123
|
+
result += " ".repeat(spaces);
|
|
1124
|
+
} else result += char;
|
|
1125
|
+
return result;
|
|
1035
1126
|
}
|
|
1036
|
-
function
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const meta = `${styles.dim(`(${testCount} tests | `)}${failedMeta}${styles.dim(")")}`;
|
|
1040
|
-
const header = styles.status.fail(` ❯ ${file.testFilePath}`);
|
|
1041
|
-
lines.push(`${header} ${meta}`);
|
|
1042
|
-
const groups = groupByDescribe(file.testResults);
|
|
1043
|
-
for (const [describeName, tests] of groups) lines.push(...formatDescribeGroup(describeName, tests, styles));
|
|
1044
|
-
return lines;
|
|
1127
|
+
function highlightSyntax(filePath, code, useColor) {
|
|
1128
|
+
if (!useColor) return code;
|
|
1129
|
+
return highlightCode(filePath, code);
|
|
1045
1130
|
}
|
|
1046
|
-
function
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
if (parsed.expected !== void 0 && parsed.received !== void 0)
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1131
|
+
function formatDiffBlock(parsed, st) {
|
|
1132
|
+
if (parsed.snapshotDiff !== void 0) {
|
|
1133
|
+
const lines = [""];
|
|
1134
|
+
for (const diffLine of parsed.snapshotDiff.split("\n")) if (diffLine.startsWith("- ")) lines.push(st.diff.expected(diffLine));
|
|
1135
|
+
else if (diffLine.startsWith("+ ")) lines.push(st.diff.received(diffLine));
|
|
1136
|
+
else lines.push(st.dim(diffLine));
|
|
1137
|
+
return lines;
|
|
1138
|
+
}
|
|
1139
|
+
if (parsed.expected !== void 0 && parsed.received !== void 0) return [
|
|
1140
|
+
"",
|
|
1141
|
+
st.diff.expected("- Expected"),
|
|
1142
|
+
st.diff.received("+ Received"),
|
|
1143
|
+
"",
|
|
1144
|
+
st.diff.expected(`- ${parsed.expected}`),
|
|
1145
|
+
st.diff.received(`+ ${parsed.received}`)
|
|
1146
|
+
];
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
function formatErrorLine(parsed, st, useColor) {
|
|
1150
|
+
if (useColor && parsed.message.startsWith("Error:")) return st.status.fail(color.bold("Error:") + parsed.message.slice(6));
|
|
1151
|
+
return st.status.fail(parsed.message);
|
|
1058
1152
|
}
|
|
1059
1153
|
function formatFallbackSnippet(message, styles, useColor) {
|
|
1060
1154
|
const location = parseSourceLocation(message);
|
|
1061
1155
|
if (location === void 0) return [];
|
|
1062
|
-
const snippet = getSourceSnippet(
|
|
1156
|
+
const snippet = getSourceSnippet({
|
|
1157
|
+
column: location.column,
|
|
1158
|
+
context: 2,
|
|
1159
|
+
filePath: location.path,
|
|
1160
|
+
line: location.line
|
|
1161
|
+
});
|
|
1063
1162
|
if (snippet === void 0) return [];
|
|
1064
1163
|
return ["", formatSourceSnippet(snippet, location.path, {
|
|
1065
1164
|
styles,
|
|
1066
1165
|
useColor
|
|
1067
1166
|
})];
|
|
1068
1167
|
}
|
|
1168
|
+
function formatMappedLocationSnippets(loc, showLuau, st, useColor) {
|
|
1169
|
+
const snippets = [];
|
|
1170
|
+
const tsSnippet = getSourceSnippet({
|
|
1171
|
+
column: loc.tsColumn,
|
|
1172
|
+
context: 2,
|
|
1173
|
+
filePath: loc.tsPath,
|
|
1174
|
+
line: loc.tsLine,
|
|
1175
|
+
sourceContent: loc.sourceContent
|
|
1176
|
+
});
|
|
1177
|
+
if (tsSnippet !== void 0) {
|
|
1178
|
+
const label = showLuau ? "TypeScript" : void 0;
|
|
1179
|
+
snippets.push("", formatSourceSnippet(tsSnippet, loc.tsPath, {
|
|
1180
|
+
language: label,
|
|
1181
|
+
styles: st,
|
|
1182
|
+
useColor
|
|
1183
|
+
}));
|
|
1184
|
+
}
|
|
1185
|
+
if (showLuau) {
|
|
1186
|
+
const luauSnippet = getSourceSnippet({
|
|
1187
|
+
context: 2,
|
|
1188
|
+
filePath: loc.luauPath,
|
|
1189
|
+
line: loc.luauLine
|
|
1190
|
+
});
|
|
1191
|
+
if (luauSnippet !== void 0) snippets.push("", formatSourceSnippet(luauSnippet, loc.luauPath, {
|
|
1192
|
+
language: "Luau",
|
|
1193
|
+
styles: st,
|
|
1194
|
+
useColor
|
|
1195
|
+
}));
|
|
1196
|
+
}
|
|
1197
|
+
return snippets;
|
|
1198
|
+
}
|
|
1199
|
+
function formatSnapshotCallSnippet(filePath, styles, useColor) {
|
|
1200
|
+
if (!fs.existsSync(filePath)) return [];
|
|
1201
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1202
|
+
const snapshotIndices = content.split("\n").reduce((acc, fileLine, index) => {
|
|
1203
|
+
if (fileLine.includes("toMatchSnapshot")) acc.push(index);
|
|
1204
|
+
return acc;
|
|
1205
|
+
}, []);
|
|
1206
|
+
if (snapshotIndices.length !== 1) return [];
|
|
1207
|
+
const snippet = getSourceSnippet({
|
|
1208
|
+
context: 2,
|
|
1209
|
+
filePath,
|
|
1210
|
+
line: snapshotIndices[0] + 1,
|
|
1211
|
+
sourceContent: content
|
|
1212
|
+
});
|
|
1213
|
+
if (snippet === void 0) return [];
|
|
1214
|
+
return ["", formatSourceSnippet(snippet, filePath, {
|
|
1215
|
+
styles,
|
|
1216
|
+
useColor
|
|
1217
|
+
})];
|
|
1218
|
+
}
|
|
1219
|
+
function resolveSourceSnippets(options) {
|
|
1220
|
+
const { filePath, hasSnapshotDiff, mappedLocations, message, showLuau, sourceMapper, styles: st, useColor } = options;
|
|
1221
|
+
if (mappedLocations.length > 0) return mappedLocations.flatMap((loc) => formatMappedLocationSnippets(loc, showLuau, st, useColor));
|
|
1222
|
+
const fallback = formatFallbackSnippet(message, st, useColor);
|
|
1223
|
+
if (fallback.length > 0) return fallback;
|
|
1224
|
+
if (hasSnapshotDiff && filePath !== void 0) return formatSnapshotCallSnippet(sourceMapper?.resolveTestFilePath(filePath) ?? filePath, st, useColor);
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
function formatFailureMessage(originalMessage, options) {
|
|
1228
|
+
const { filePath, showLuau, sourceMapper, styles: st, useColor } = options;
|
|
1229
|
+
let mappedLocations = [];
|
|
1230
|
+
let message = originalMessage;
|
|
1231
|
+
if (sourceMapper !== void 0) ({locations: mappedLocations, message} = sourceMapper.mapFailureWithLocations(originalMessage));
|
|
1232
|
+
const parsed = parseErrorMessage(originalMessage);
|
|
1233
|
+
return [
|
|
1234
|
+
formatErrorLine(parsed, st, useColor),
|
|
1235
|
+
...formatDiffBlock(parsed, st),
|
|
1236
|
+
...resolveSourceSnippets({
|
|
1237
|
+
filePath,
|
|
1238
|
+
hasSnapshotDiff: parsed.snapshotDiff !== void 0,
|
|
1239
|
+
mappedLocations,
|
|
1240
|
+
message,
|
|
1241
|
+
showLuau,
|
|
1242
|
+
sourceMapper,
|
|
1243
|
+
styles: st,
|
|
1244
|
+
useColor
|
|
1245
|
+
})
|
|
1246
|
+
];
|
|
1247
|
+
}
|
|
1248
|
+
function formatSnapshotLine(snapshot, st) {
|
|
1249
|
+
if (snapshot === void 0 || snapshot.unmatched === 0) return;
|
|
1250
|
+
return `${st.dim(" Snapshots")} ${st.summary.failed(`${snapshot.unmatched} failed`)}`;
|
|
1251
|
+
}
|
|
1069
1252
|
function formatFileFailures(file, options, styles, failureCtx) {
|
|
1070
1253
|
const lines = [];
|
|
1071
1254
|
for (const tc of file.testResults) if (tc.status === "failed") {
|
|
@@ -1084,58 +1267,23 @@ function formatFileFailures(file, options, styles, failureCtx) {
|
|
|
1084
1267
|
}
|
|
1085
1268
|
return lines.join("\n");
|
|
1086
1269
|
}
|
|
1087
|
-
function
|
|
1088
|
-
const directory = path.dirname(filePath);
|
|
1089
|
-
const base = path.basename(filePath);
|
|
1090
|
-
const directoryWithSlash = styles.path.dir(`${directory}/`);
|
|
1091
|
-
const fileName = styles.path.file(base);
|
|
1092
|
-
return directory && directory !== "." ? directoryWithSlash + fileName : fileName;
|
|
1093
|
-
}
|
|
1094
|
-
function formatFileSummary(file, options, styles) {
|
|
1270
|
+
function formatExecErrorDetail(file, styles, failureCtx) {
|
|
1095
1271
|
const lines = [];
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
const fileMs = sumFileDuration(file);
|
|
1105
|
-
const symbol = styles.status.pass("✓");
|
|
1106
|
-
const duration = fileMs > 0 ? ` - ${fileMs}ms` : "";
|
|
1107
|
-
const meta = styles.dim(`(${testCount} tests${duration})`);
|
|
1108
|
-
lines.push(` ${symbol} ${formattedPath} ${meta}`);
|
|
1109
|
-
if (options.verbose) {
|
|
1110
|
-
for (const tc of file.testResults) if (tc.status === "passed") lines.push(formatPass(tc, styles));
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1272
|
+
const index = failureCtx.currentIndex;
|
|
1273
|
+
failureCtx.currentIndex++;
|
|
1274
|
+
const errorMessage = cleanExecErrorMessage(file.failureMessage ?? "");
|
|
1275
|
+
const counter = `[${index}/${failureCtx.totalFailures}]`;
|
|
1276
|
+
const termWidth = getTerminalWidth();
|
|
1277
|
+
const fillWidth = Math.max(1, termWidth - counter.length - 3);
|
|
1278
|
+
const separator = styles.dim(styles.status.fail(`${"⎯".repeat(fillWidth)}${counter}\u23af`));
|
|
1279
|
+
lines.push(` ${styles.failBadge(" FAIL ")} ${styles.status.fail(file.testFilePath)}`, ` ${styles.status.fail("Test suite failed to run")}`, "", ` ${styles.status.fail(errorMessage)}`, "", ` ${separator}`);
|
|
1113
1280
|
return lines.join("\n");
|
|
1114
1281
|
}
|
|
1115
|
-
function
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
if (
|
|
1119
|
-
|
|
1120
|
-
snippets.push("", formatSourceSnippet(tsSnippet, loc.tsPath, {
|
|
1121
|
-
language: label,
|
|
1122
|
-
styles: st,
|
|
1123
|
-
useColor
|
|
1124
|
-
}));
|
|
1125
|
-
}
|
|
1126
|
-
if (showLuau) {
|
|
1127
|
-
const luauSnippet = getSourceSnippet(loc.luauPath, loc.luauLine, 2);
|
|
1128
|
-
if (luauSnippet !== void 0) snippets.push("", formatSourceSnippet(luauSnippet, loc.luauPath, {
|
|
1129
|
-
language: "Luau",
|
|
1130
|
-
styles: st,
|
|
1131
|
-
useColor
|
|
1132
|
-
}));
|
|
1133
|
-
}
|
|
1134
|
-
return snippets;
|
|
1135
|
-
}
|
|
1136
|
-
function formatPass(test, styles) {
|
|
1137
|
-
const duration = test.duration !== void 0 ? styles.dim(` ${test.duration}ms`) : "";
|
|
1138
|
-
return styles.status.pass(` ✓ ${test.fullName}`) + duration;
|
|
1282
|
+
function formatLogHints(options, styles) {
|
|
1283
|
+
const lines = [];
|
|
1284
|
+
if (options.outputFile !== void 0) lines.push(styles.dim(` View ${options.outputFile} for full Jest output`));
|
|
1285
|
+
if (options.gameOutput !== void 0) lines.push(styles.dim(` View ${options.gameOutput} for Roblox game logs`));
|
|
1286
|
+
return lines.join("\n");
|
|
1139
1287
|
}
|
|
1140
1288
|
function formatTestInGroup(tc, styles) {
|
|
1141
1289
|
const duration = tc.duration !== void 0 ? styles.lineNumber(` ${tc.duration}ms`) : "";
|
|
@@ -1143,6 +1291,26 @@ function formatTestInGroup(tc, styles) {
|
|
|
1143
1291
|
const failedText = ` × ${tc.title}`;
|
|
1144
1292
|
return `${styles.status.fail(failedText)}${duration}`;
|
|
1145
1293
|
}
|
|
1294
|
+
function formatDescribeGroup(describeName, tests, styles) {
|
|
1295
|
+
const lines = [];
|
|
1296
|
+
const groupHasFailure = tests.some((tc) => tc.status === "failed");
|
|
1297
|
+
const groupTestCount = tests.length;
|
|
1298
|
+
const groupDuration = tests.reduce((sum, tc) => sum + (tc.duration ?? 0), 0);
|
|
1299
|
+
const groupDurationStr = styles.lineNumber(` ${groupDuration}ms`);
|
|
1300
|
+
if (groupHasFailure) {
|
|
1301
|
+
const failedCount = tests.filter((tc) => tc.status === "failed").length;
|
|
1302
|
+
const groupMeta = styles.dim(`(${groupTestCount} tests | `) + styles.summary.failed(`${failedCount} failed`) + styles.dim(")");
|
|
1303
|
+
const header = styles.status.fail(` ❯ ${describeName}`);
|
|
1304
|
+
lines.push(`${header} ${groupMeta}${groupDurationStr}`);
|
|
1305
|
+
for (const tc of tests) lines.push(formatTestInGroup(tc, styles));
|
|
1306
|
+
} else {
|
|
1307
|
+
const groupMeta = styles.dim(`(${groupTestCount} tests)`);
|
|
1308
|
+
const marker = styles.status.pass(" ✓");
|
|
1309
|
+
const name = styles.status.fail(` ${describeName}`);
|
|
1310
|
+
lines.push(`${marker}${name} ${groupMeta}${groupDurationStr}`);
|
|
1311
|
+
}
|
|
1312
|
+
return lines;
|
|
1313
|
+
}
|
|
1146
1314
|
function groupByDescribe(tests) {
|
|
1147
1315
|
const groups = /* @__PURE__ */ new Map();
|
|
1148
1316
|
for (const test of tests) {
|
|
@@ -1153,36 +1321,102 @@ function groupByDescribe(tests) {
|
|
|
1153
1321
|
}
|
|
1154
1322
|
return groups;
|
|
1155
1323
|
}
|
|
1156
|
-
function
|
|
1157
|
-
|
|
1158
|
-
|
|
1324
|
+
function formatFailedFileSummary(file, testCount, styles) {
|
|
1325
|
+
const lines = [];
|
|
1326
|
+
const failedMeta = styles.summary.failed(`${file.numFailingTests} failed`);
|
|
1327
|
+
const meta = `${styles.dim(`(${testCount} tests | `)}${failedMeta}${styles.dim(")")}`;
|
|
1328
|
+
const header = styles.status.fail(` ❯ ${file.testFilePath}`);
|
|
1329
|
+
lines.push(`${header} ${meta}`);
|
|
1330
|
+
const groups = groupByDescribe(file.testResults);
|
|
1331
|
+
for (const [describeName, tests] of groups) lines.push(...formatDescribeGroup(describeName, tests, styles));
|
|
1332
|
+
return lines;
|
|
1159
1333
|
}
|
|
1160
|
-
function
|
|
1161
|
-
|
|
1334
|
+
function formatFilePath(filePath, styles) {
|
|
1335
|
+
const directory = path.dirname(filePath);
|
|
1336
|
+
const base = path.basename(filePath);
|
|
1337
|
+
const directoryWithSlash = styles.path.dir(`${directory}/`);
|
|
1338
|
+
const fileName = styles.path.file(base);
|
|
1339
|
+
return directory && directory !== "." ? directoryWithSlash + fileName : fileName;
|
|
1340
|
+
}
|
|
1341
|
+
function formatExecErrorFileSummary(file, formattedPath, styles) {
|
|
1342
|
+
const symbol = styles.status.fail("✗");
|
|
1343
|
+
const errorMessage = cleanExecErrorMessage(file.failureMessage ?? "");
|
|
1344
|
+
return [` ${symbol} ${formattedPath}`, ` ${styles.status.fail(errorMessage)}`];
|
|
1345
|
+
}
|
|
1346
|
+
function formatPass(test, styles) {
|
|
1347
|
+
const duration = test.duration !== void 0 ? styles.dim(` ${test.duration}ms`) : "";
|
|
1348
|
+
return styles.status.pass(` ✓ ${test.fullName}`) + duration;
|
|
1162
1349
|
}
|
|
1163
1350
|
function sumFileDuration(file) {
|
|
1164
1351
|
let total = 0;
|
|
1165
1352
|
for (const test of file.testResults) if (test.duration !== void 0) total += test.duration;
|
|
1166
1353
|
return total;
|
|
1167
1354
|
}
|
|
1355
|
+
function formatPassedFileSummary(file, ctx) {
|
|
1356
|
+
const lines = [];
|
|
1357
|
+
const fileMs = sumFileDuration(file);
|
|
1358
|
+
const symbol = ctx.styles.status.pass("✓");
|
|
1359
|
+
const duration = fileMs > 0 ? ` - ${fileMs}ms` : "";
|
|
1360
|
+
const meta = ctx.styles.dim(`(${ctx.testCount} tests${duration})`);
|
|
1361
|
+
lines.push(` ${symbol} ${ctx.formattedPath} ${meta}`);
|
|
1362
|
+
if (ctx.verbose) {
|
|
1363
|
+
for (const tc of file.testResults) if (tc.status === "passed") lines.push(formatPass(tc, ctx.styles));
|
|
1364
|
+
}
|
|
1365
|
+
return lines;
|
|
1366
|
+
}
|
|
1367
|
+
function formatFileSummary(file, options, styles) {
|
|
1368
|
+
const formattedPath = formatFilePath(file.testFilePath, styles);
|
|
1369
|
+
const testCount = file.numPassingTests + file.numFailingTests + file.numPendingTests;
|
|
1370
|
+
if (file.numFailingTests > 0) return formatFailedFileSummary(file, testCount, styles).join("\n");
|
|
1371
|
+
if (hasExecError(file)) return formatExecErrorFileSummary(file, formattedPath, styles).join("\n");
|
|
1372
|
+
if (file.numPassingTests === 0 && file.numPendingTests > 0) return ` ${styles.status.pending("↓")} ${formattedPath} ${styles.dim(`(${testCount} tests)`)}`;
|
|
1373
|
+
return formatPassedFileSummary(file, {
|
|
1374
|
+
formattedPath,
|
|
1375
|
+
styles,
|
|
1376
|
+
testCount,
|
|
1377
|
+
verbose: options.verbose
|
|
1378
|
+
}).join("\n");
|
|
1379
|
+
}
|
|
1168
1380
|
|
|
1169
1381
|
//#endregion
|
|
1170
1382
|
//#region src/formatters/compact.ts
|
|
1383
|
+
function formatCompactSummary(result) {
|
|
1384
|
+
const parts = [];
|
|
1385
|
+
const execErrorCount = result.testResults.filter(hasExecError).length;
|
|
1386
|
+
const failCount = result.numFailedTests + execErrorCount;
|
|
1387
|
+
if (result.numPassedTests > 0) parts.push(`PASS ${result.numPassedTests}`);
|
|
1388
|
+
if (failCount > 0) parts.push(`FAIL ${failCount}`);
|
|
1389
|
+
if (result.numPendingTests > 0) parts.push(`SKIP ${result.numPendingTests}`);
|
|
1390
|
+
return parts.join(" | ");
|
|
1391
|
+
}
|
|
1171
1392
|
function formatCompact(result, options) {
|
|
1172
1393
|
const lines = [formatCompactSummary(result)];
|
|
1173
|
-
|
|
1394
|
+
const execErrors = result.testResults.filter(hasExecError);
|
|
1395
|
+
if (result.numFailedTests > 0 || execErrors.length > 0) {
|
|
1174
1396
|
lines.push("");
|
|
1175
|
-
|
|
1176
|
-
|
|
1397
|
+
if (result.numFailedTests > 0) {
|
|
1398
|
+
const failureLines = formatFailures(result, options);
|
|
1399
|
+
lines.push(...failureLines);
|
|
1400
|
+
}
|
|
1401
|
+
for (const file of execErrors) {
|
|
1402
|
+
const relativePath = makeRelative(file.testFilePath, options.rootDir);
|
|
1403
|
+
const errorMessage = cleanExecErrorMessage(file.failureMessage ?? "");
|
|
1404
|
+
lines.push(`[FAIL] ${relativePath} - suite failed to run`, errorMessage, "");
|
|
1405
|
+
}
|
|
1406
|
+
const hints = formatCompactLogHints(options);
|
|
1407
|
+
if (hints !== "") lines.push(hints);
|
|
1177
1408
|
}
|
|
1178
1409
|
return lines.join("\n");
|
|
1179
1410
|
}
|
|
1180
|
-
function
|
|
1181
|
-
const
|
|
1182
|
-
if (
|
|
1183
|
-
if (
|
|
1184
|
-
|
|
1185
|
-
|
|
1411
|
+
function formatCompactLogHints(options) {
|
|
1412
|
+
const lines = [];
|
|
1413
|
+
if (options.outputFile !== void 0) lines.push(`View ${options.outputFile} for full Jest output`);
|
|
1414
|
+
if (options.gameOutput !== void 0) lines.push(`View ${options.gameOutput} for Roblox game logs`);
|
|
1415
|
+
return lines.join("\n");
|
|
1416
|
+
}
|
|
1417
|
+
function makeRelative(filePath, rootDirectory) {
|
|
1418
|
+
if (filePath.startsWith(rootDirectory)) return path.relative(rootDirectory, filePath);
|
|
1419
|
+
return filePath;
|
|
1186
1420
|
}
|
|
1187
1421
|
function collectFailedTests(result) {
|
|
1188
1422
|
const failures = [];
|
|
@@ -1202,18 +1436,43 @@ function findFailureLocation(mappedLocations, message) {
|
|
|
1202
1436
|
}
|
|
1203
1437
|
return parseSourceLocation(message);
|
|
1204
1438
|
}
|
|
1439
|
+
function getFailureSnippet(mappedLocations, location) {
|
|
1440
|
+
let snippet;
|
|
1441
|
+
if (mappedLocations.length > 0) {
|
|
1442
|
+
const loc = mappedLocations[0];
|
|
1443
|
+
if (loc !== void 0) snippet = getSourceSnippet({
|
|
1444
|
+
column: loc.tsColumn,
|
|
1445
|
+
context: 1,
|
|
1446
|
+
filePath: loc.tsPath,
|
|
1447
|
+
line: loc.tsLine,
|
|
1448
|
+
sourceContent: loc.sourceContent
|
|
1449
|
+
});
|
|
1450
|
+
} else if (location !== void 0) snippet = getSourceSnippet({
|
|
1451
|
+
context: 1,
|
|
1452
|
+
filePath: location.path,
|
|
1453
|
+
line: location.line
|
|
1454
|
+
});
|
|
1455
|
+
if (snippet === void 0) return;
|
|
1456
|
+
const snippetLines = [];
|
|
1457
|
+
for (const line of snippet.lines) {
|
|
1458
|
+
const prefix = line.num === snippet.failureLine ? ">" : " ";
|
|
1459
|
+
snippetLines.push(`${prefix} ${line.num}| ${line.content}`);
|
|
1460
|
+
}
|
|
1461
|
+
return snippetLines.join("\n");
|
|
1462
|
+
}
|
|
1205
1463
|
function formatCompactFailure(test, filePath, options) {
|
|
1206
1464
|
const lines = [];
|
|
1207
1465
|
for (const originalMessage of test.failureMessages) {
|
|
1208
1466
|
let mappedLocations = [];
|
|
1209
1467
|
let message = originalMessage;
|
|
1210
1468
|
if (options.sourceMapper !== void 0) ({locations: mappedLocations, message} = options.sourceMapper.mapFailureWithLocations(originalMessage));
|
|
1211
|
-
const parsed = parseErrorMessage(
|
|
1469
|
+
const parsed = parseErrorMessage(originalMessage);
|
|
1212
1470
|
const location = findFailureLocation(mappedLocations, message);
|
|
1213
1471
|
const relativePath = makeRelative(location?.path ?? filePath, options.rootDir);
|
|
1214
1472
|
const lineInfo = location?.line !== void 0 ? `:${location.line}` : "";
|
|
1215
1473
|
lines.push(`[FAIL] ${relativePath}${lineInfo} - ${test.title}`);
|
|
1216
|
-
if (parsed.
|
|
1474
|
+
if (parsed.snapshotDiff !== void 0) lines.push(parsed.snapshotDiff);
|
|
1475
|
+
else if (parsed.expected !== void 0 && parsed.received !== void 0) lines.push(`expect: ${parsed.expected}`, `actual: ${parsed.received}`);
|
|
1217
1476
|
const snippet = getFailureSnippet(mappedLocations, location);
|
|
1218
1477
|
if (snippet !== void 0) lines.push(snippet);
|
|
1219
1478
|
lines.push("");
|
|
@@ -1232,24 +1491,6 @@ function formatFailures(result, options) {
|
|
|
1232
1491
|
}
|
|
1233
1492
|
return lines;
|
|
1234
1493
|
}
|
|
1235
|
-
function getFailureSnippet(mappedLocations, location) {
|
|
1236
|
-
let snippet;
|
|
1237
|
-
if (mappedLocations.length > 0) {
|
|
1238
|
-
const loc = mappedLocations[0];
|
|
1239
|
-
if (loc !== void 0) snippet = getSourceSnippet(loc.tsPath, loc.tsLine, 1, loc.tsColumn);
|
|
1240
|
-
} else if (location !== void 0) snippet = getSourceSnippet(location.path, location.line, 1);
|
|
1241
|
-
if (snippet === void 0) return;
|
|
1242
|
-
const snippetLines = [];
|
|
1243
|
-
for (const line of snippet.lines) {
|
|
1244
|
-
const prefix = line.num === snippet.failureLine ? ">" : " ";
|
|
1245
|
-
snippetLines.push(`${prefix} ${line.num}| ${line.content}`);
|
|
1246
|
-
}
|
|
1247
|
-
return snippetLines.join("\n");
|
|
1248
|
-
}
|
|
1249
|
-
function makeRelative(filePath, rootDirectory) {
|
|
1250
|
-
if (filePath.startsWith(rootDirectory)) return path.relative(rootDirectory, filePath);
|
|
1251
|
-
return filePath;
|
|
1252
|
-
}
|
|
1253
1494
|
|
|
1254
1495
|
//#endregion
|
|
1255
1496
|
//#region src/formatters/json.ts
|
|
@@ -1292,19 +1533,16 @@ function buildMappings(tree, prefix) {
|
|
|
1292
1533
|
//#endregion
|
|
1293
1534
|
//#region src/executor.ts
|
|
1294
1535
|
const rojoProjectSchema = type({ tree: "object" });
|
|
1295
|
-
const tsconfigSchema = type({ "compilerOptions?": {
|
|
1296
|
-
"outDir?": "string",
|
|
1297
|
-
"rootDir?": "string"
|
|
1298
|
-
} });
|
|
1299
1536
|
async function execute(options) {
|
|
1300
1537
|
const startTime = Date.now();
|
|
1301
1538
|
const { gameOutput, luauTiming, result, snapshotWrites, timing: backendTiming } = await options.backend.runTests({
|
|
1302
1539
|
config: options.config,
|
|
1303
1540
|
testFiles: options.testFiles
|
|
1304
1541
|
});
|
|
1305
|
-
|
|
1542
|
+
const tsconfigDirectories = resolveTsconfigDirectories(options.config.rootDir);
|
|
1543
|
+
if (snapshotWrites !== void 0) writeSnapshots(snapshotWrites, options.config, tsconfigDirectories);
|
|
1306
1544
|
const testsMs = calculateTestsMs(result.testResults);
|
|
1307
|
-
const sourceMapper = options.config.sourceMap ? buildSourceMapper(options.config) : void 0;
|
|
1545
|
+
const sourceMapper = options.config.sourceMap ? buildSourceMapper(options.config, tsconfigDirectories) : void 0;
|
|
1308
1546
|
const totalMs = Date.now() - startTime;
|
|
1309
1547
|
const timing = {
|
|
1310
1548
|
executionMs: backendTiming.executionMs,
|
|
@@ -1314,15 +1552,21 @@ async function execute(options) {
|
|
|
1314
1552
|
uploadCached: backendTiming.uploadCached,
|
|
1315
1553
|
uploadMs: backendTiming.uploadMs
|
|
1316
1554
|
};
|
|
1555
|
+
const resolvedOutputFile = options.config.outputFile !== void 0 ? path$1.resolve(options.config.outputFile) : void 0;
|
|
1556
|
+
const resolvedGameOutput = options.config.gameOutput !== void 0 ? path$1.resolve(options.config.gameOutput) : void 0;
|
|
1317
1557
|
let output = "";
|
|
1318
|
-
if (!options.config.silent) if (options.config.compact) output = formatCompact(result, {
|
|
1558
|
+
if (!options.config.silent) if (options.config.compact && !options.config.verbose) output = formatCompact(result, {
|
|
1559
|
+
gameOutput: resolvedGameOutput,
|
|
1319
1560
|
maxFailures: options.config.compactMaxFailures,
|
|
1561
|
+
outputFile: resolvedOutputFile,
|
|
1320
1562
|
rootDir: options.config.rootDir,
|
|
1321
1563
|
sourceMapper
|
|
1322
1564
|
});
|
|
1323
1565
|
else if (options.config.json) output = formatJson(result);
|
|
1324
1566
|
else output = formatResult(result, timing, {
|
|
1325
1567
|
color: options.config.color,
|
|
1568
|
+
gameOutput: resolvedGameOutput,
|
|
1569
|
+
outputFile: resolvedOutputFile,
|
|
1326
1570
|
rootDir: options.config.rootDir,
|
|
1327
1571
|
showLuau: options.config.showLuau,
|
|
1328
1572
|
sourceMapper,
|
|
@@ -1337,19 +1581,34 @@ async function execute(options) {
|
|
|
1337
1581
|
result
|
|
1338
1582
|
};
|
|
1339
1583
|
}
|
|
1340
|
-
function
|
|
1584
|
+
function normalizeDirectoryPath(directory) {
|
|
1585
|
+
return path$1.normalize(directory).replaceAll("\\", "/");
|
|
1586
|
+
}
|
|
1587
|
+
function resolveTsconfigDirectories(projectRoot) {
|
|
1588
|
+
const tsconfig = getTsconfig(projectRoot);
|
|
1589
|
+
const outDirectory = tsconfig?.config.compilerOptions?.outDir ?? "out";
|
|
1590
|
+
const rootDirectory = tsconfig?.config.compilerOptions?.rootDir ?? "src";
|
|
1591
|
+
return {
|
|
1592
|
+
outDir: normalizeDirectoryPath(outDirectory),
|
|
1593
|
+
rootDir: normalizeDirectoryPath(rootDirectory)
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function findRojoProject(rootDirectory) {
|
|
1597
|
+
const defaultPath = path$1.join(rootDirectory, "default.project.json");
|
|
1598
|
+
if (fs.existsSync(defaultPath)) return defaultPath;
|
|
1599
|
+
const projectFile = fs.readdirSync(rootDirectory).find((file) => file.endsWith(".project.json"));
|
|
1600
|
+
return projectFile !== void 0 ? path$1.join(rootDirectory, projectFile) : void 0;
|
|
1601
|
+
}
|
|
1602
|
+
function buildSourceMapper(config, tsconfigDirectories) {
|
|
1341
1603
|
const rojoProjectPath = config.rojoProject ?? findRojoProject(config.rootDir);
|
|
1342
|
-
if (rojoProjectPath === void 0) return;
|
|
1343
|
-
const tsconfigPath = path$1.join(config.rootDir, "tsconfig.json");
|
|
1344
|
-
if (!fs.existsSync(rojoProjectPath) || !fs.existsSync(tsconfigPath)) return;
|
|
1604
|
+
if (rojoProjectPath === void 0 || !fs.existsSync(rojoProjectPath)) return;
|
|
1345
1605
|
try {
|
|
1346
1606
|
const rojoResult = rojoProjectSchema(JSON.parse(fs.readFileSync(rojoProjectPath, "utf-8")));
|
|
1347
1607
|
if (rojoResult instanceof type.errors) return;
|
|
1348
|
-
const tsconfigResult = tsconfigSchema(JSON.parse(fs.readFileSync(tsconfigPath, "utf-8")));
|
|
1349
1608
|
return createSourceMapper({
|
|
1350
|
-
outDir:
|
|
1609
|
+
outDir: tsconfigDirectories.outDir,
|
|
1351
1610
|
rojoProject: rojoResult,
|
|
1352
|
-
rootDir:
|
|
1611
|
+
rootDir: tsconfigDirectories.rootDir
|
|
1353
1612
|
});
|
|
1354
1613
|
} catch {
|
|
1355
1614
|
return;
|
|
@@ -1360,12 +1619,6 @@ function calculateTestsMs(testResults) {
|
|
|
1360
1619
|
for (const file of testResults) for (const test of file.testResults) if (test.duration !== void 0) total += test.duration;
|
|
1361
1620
|
return total;
|
|
1362
1621
|
}
|
|
1363
|
-
function findRojoProject(rootDirectory) {
|
|
1364
|
-
const defaultPath = path$1.join(rootDirectory, "default.project.json");
|
|
1365
|
-
if (fs.existsSync(defaultPath)) return defaultPath;
|
|
1366
|
-
const projectFile = fs.readdirSync(rootDirectory).find((file) => file.endsWith(".project.json"));
|
|
1367
|
-
return projectFile !== void 0 ? path$1.join(rootDirectory, projectFile) : void 0;
|
|
1368
|
-
}
|
|
1369
1622
|
function printLuauTiming(timing) {
|
|
1370
1623
|
let total = 0;
|
|
1371
1624
|
for (const [phase, seconds] of Object.entries(timing)) {
|
|
@@ -1375,7 +1628,7 @@ function printLuauTiming(timing) {
|
|
|
1375
1628
|
}
|
|
1376
1629
|
process.stderr.write(`[TIMING] total: ${String(total)}ms\n`);
|
|
1377
1630
|
}
|
|
1378
|
-
function writeSnapshots(snapshotWrites, config) {
|
|
1631
|
+
function writeSnapshots(snapshotWrites, config, tsconfigDirectories) {
|
|
1379
1632
|
const rojoProjectPath = config.rojoProject ?? findRojoProject(config.rootDir);
|
|
1380
1633
|
if (rojoProjectPath === void 0 || !fs.existsSync(rojoProjectPath)) {
|
|
1381
1634
|
process.stderr.write("Warning: Cannot write snapshots - no rojo project found\n");
|
|
@@ -1387,16 +1640,7 @@ function writeSnapshots(snapshotWrites, config) {
|
|
|
1387
1640
|
process.stderr.write("Warning: Cannot write snapshots - invalid rojo project\n");
|
|
1388
1641
|
return;
|
|
1389
1642
|
}
|
|
1390
|
-
const
|
|
1391
|
-
let outDirectory;
|
|
1392
|
-
let rootDirectory;
|
|
1393
|
-
if (fs.existsSync(tsconfigPath)) {
|
|
1394
|
-
const tsconfigResult = tsconfigSchema(JSON.parse(fs.readFileSync(tsconfigPath, "utf-8")));
|
|
1395
|
-
if (!(tsconfigResult instanceof type.errors)) {
|
|
1396
|
-
outDirectory = tsconfigResult.compilerOptions?.outDir ?? "out";
|
|
1397
|
-
rootDirectory = tsconfigResult.compilerOptions?.rootDir ?? "src";
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1643
|
+
const { outDir: outDirectory, rootDir: rootDirectory } = tsconfigDirectories;
|
|
1400
1644
|
const resolver = createSnapshotPathResolver({
|
|
1401
1645
|
outDir: outDirectory,
|
|
1402
1646
|
rojoProject: rojoResult,
|
|
@@ -1412,6 +1656,12 @@ function writeSnapshots(snapshotWrites, config) {
|
|
|
1412
1656
|
const absolutePath = path$1.resolve(config.rootDir, fsPath);
|
|
1413
1657
|
fs.mkdirSync(path$1.dirname(absolutePath), { recursive: true });
|
|
1414
1658
|
fs.writeFileSync(absolutePath, content);
|
|
1659
|
+
if (fsPath.startsWith(`${rootDirectory}/`)) {
|
|
1660
|
+
const outPath = outDirectory + fsPath.slice(rootDirectory.length);
|
|
1661
|
+
const absoluteOutPath = path$1.resolve(config.rootDir, outPath);
|
|
1662
|
+
fs.mkdirSync(path$1.dirname(absoluteOutPath), { recursive: true });
|
|
1663
|
+
fs.writeFileSync(absoluteOutPath, content);
|
|
1664
|
+
}
|
|
1415
1665
|
written++;
|
|
1416
1666
|
}
|
|
1417
1667
|
if (written > 0) process.stderr.write(`Wrote ${String(written)} snapshot file${written === 1 ? "" : "s"}\n`);
|
|
@@ -1420,6 +1670,240 @@ function writeSnapshots(snapshotWrites, config) {
|
|
|
1420
1670
|
}
|
|
1421
1671
|
}
|
|
1422
1672
|
|
|
1673
|
+
//#endregion
|
|
1674
|
+
//#region src/typecheck/collect.ts
|
|
1675
|
+
const TEST_FUNCTIONS = new Set(["it", "test"]);
|
|
1676
|
+
const SUITE_FUNCTIONS = new Set(["describe", "suite"]);
|
|
1677
|
+
const ALL_FUNCTIONS = new Set([...SUITE_FUNCTIONS, ...TEST_FUNCTIONS]);
|
|
1678
|
+
function collectTestDefinitions(source) {
|
|
1679
|
+
const result = parseSync("test.ts", source);
|
|
1680
|
+
const raw = [];
|
|
1681
|
+
new Visitor({ CallExpression(node) {
|
|
1682
|
+
const definition = extractDefinition(node, source);
|
|
1683
|
+
if (definition) raw.push(definition);
|
|
1684
|
+
} }).visit(result.program);
|
|
1685
|
+
raw.sort((a, b) => a.start - b.start);
|
|
1686
|
+
return buildAncestorChain(raw);
|
|
1687
|
+
}
|
|
1688
|
+
function buildAncestorChain(sorted) {
|
|
1689
|
+
const result = [];
|
|
1690
|
+
const suiteStack = [];
|
|
1691
|
+
for (const definition of sorted) {
|
|
1692
|
+
while (suiteStack.length > 0 && suiteStack.at(-1).end <= definition.start) suiteStack.pop();
|
|
1693
|
+
result.push({
|
|
1694
|
+
...definition,
|
|
1695
|
+
ancestorNames: suiteStack.map((suite) => suite.name)
|
|
1696
|
+
});
|
|
1697
|
+
if (definition.type === "suite") suiteStack.push({
|
|
1698
|
+
name: definition.name,
|
|
1699
|
+
end: definition.end
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
return result;
|
|
1703
|
+
}
|
|
1704
|
+
function isStringLiteral(node) {
|
|
1705
|
+
return node.type === "Literal" && typeof node.value === "string";
|
|
1706
|
+
}
|
|
1707
|
+
function isTemplateLiteral(node) {
|
|
1708
|
+
return node.type === "TemplateLiteral";
|
|
1709
|
+
}
|
|
1710
|
+
function extractStringArgument(node, source) {
|
|
1711
|
+
if (isStringLiteral(node)) return node.value;
|
|
1712
|
+
if (isTemplateLiteral(node)) {
|
|
1713
|
+
if (node.quasis.length === 1 && node.quasis[0] !== void 0) return node.quasis[0].value.raw;
|
|
1714
|
+
return source.slice(node.start + 1, node.end - 1);
|
|
1715
|
+
}
|
|
1716
|
+
return source.slice(node.start, node.end);
|
|
1717
|
+
}
|
|
1718
|
+
function isIdentifier(node) {
|
|
1719
|
+
return node.type === "Identifier";
|
|
1720
|
+
}
|
|
1721
|
+
function isStaticMemberExpression(node) {
|
|
1722
|
+
return node.type === "MemberExpression" && "computed" in node && !node.computed;
|
|
1723
|
+
}
|
|
1724
|
+
function getCalleeName(callee) {
|
|
1725
|
+
if (isIdentifier(callee)) return callee.name;
|
|
1726
|
+
if (isStaticMemberExpression(callee) && isIdentifier(callee.object) && ALL_FUNCTIONS.has(callee.object.name)) return callee.object.name;
|
|
1727
|
+
}
|
|
1728
|
+
function extractDefinition(node, source) {
|
|
1729
|
+
const name = getCalleeName(node.callee);
|
|
1730
|
+
if (name === void 0 || !ALL_FUNCTIONS.has(name)) return;
|
|
1731
|
+
const firstArgument = node.arguments[0];
|
|
1732
|
+
if (firstArgument === void 0 || firstArgument.type === "SpreadElement") return;
|
|
1733
|
+
return {
|
|
1734
|
+
name: extractStringArgument(firstArgument, source),
|
|
1735
|
+
end: node.end,
|
|
1736
|
+
start: node.start,
|
|
1737
|
+
type: TEST_FUNCTIONS.has(name) ? "test" : "suite"
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
//#endregion
|
|
1742
|
+
//#region src/typecheck/parse.ts
|
|
1743
|
+
const errCodeRegExp = /error TS(?<errCode>\d+)/;
|
|
1744
|
+
function parseTscErrorLine(line) {
|
|
1745
|
+
const parenIndex = line.lastIndexOf("(", line.indexOf("): error TS"));
|
|
1746
|
+
if (parenIndex === -1) return ["", null];
|
|
1747
|
+
const filePath = line.slice(0, parenIndex);
|
|
1748
|
+
const rest = line.slice(parenIndex);
|
|
1749
|
+
const closeParenIndex = rest.indexOf(")");
|
|
1750
|
+
const [lineStr, colStr] = rest.slice(1, closeParenIndex).split(",");
|
|
1751
|
+
if (lineStr === void 0 || lineStr === "" || colStr === void 0 || colStr === "") return [filePath, null];
|
|
1752
|
+
const afterParen = rest.slice(closeParenIndex + 1);
|
|
1753
|
+
const errCodeStr = errCodeRegExp.exec(afterParen)?.groups?.["errCode"];
|
|
1754
|
+
if (errCodeStr === void 0) return [filePath, null];
|
|
1755
|
+
const errCode = Number(errCodeStr);
|
|
1756
|
+
const marker = `error TS${String(errCode)}: `;
|
|
1757
|
+
const markerIndex = afterParen.indexOf(marker);
|
|
1758
|
+
const errMessage = afterParen.slice(markerIndex + marker.length).trim();
|
|
1759
|
+
return [filePath, {
|
|
1760
|
+
column: Number(colStr),
|
|
1761
|
+
errCode,
|
|
1762
|
+
errMsg: errMessage,
|
|
1763
|
+
filePath,
|
|
1764
|
+
line: Number(lineStr)
|
|
1765
|
+
}];
|
|
1766
|
+
}
|
|
1767
|
+
function parseTscOutput(stdout) {
|
|
1768
|
+
const map = /* @__PURE__ */ new Map();
|
|
1769
|
+
const merged = stdout.split(/\r?\n/).reduce((lines, next) => {
|
|
1770
|
+
if (!next) return lines;
|
|
1771
|
+
if (next[0] !== " ") lines.push(next);
|
|
1772
|
+
else if (lines.length > 0) lines[lines.length - 1] += `\n${next}`;
|
|
1773
|
+
return lines;
|
|
1774
|
+
}, []);
|
|
1775
|
+
for (const line of merged) {
|
|
1776
|
+
const [filePath, info] = parseTscErrorLine(line);
|
|
1777
|
+
if (!info) continue;
|
|
1778
|
+
const existing = map.get(filePath);
|
|
1779
|
+
if (existing) existing.push(info);
|
|
1780
|
+
else map.set(filePath, [info]);
|
|
1781
|
+
}
|
|
1782
|
+
return map;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/typecheck/runner.ts
|
|
1787
|
+
function createLocationsIndexMap(source) {
|
|
1788
|
+
const map = /* @__PURE__ */ new Map();
|
|
1789
|
+
let index = 0;
|
|
1790
|
+
let line = 1;
|
|
1791
|
+
let column = 1;
|
|
1792
|
+
for (const char of source) {
|
|
1793
|
+
map.set(`${String(line)}:${String(column)}`, index);
|
|
1794
|
+
index++;
|
|
1795
|
+
if (char === "\n") {
|
|
1796
|
+
line++;
|
|
1797
|
+
column = 1;
|
|
1798
|
+
} else column++;
|
|
1799
|
+
}
|
|
1800
|
+
return map;
|
|
1801
|
+
}
|
|
1802
|
+
function mapErrorsToTests(errors, files) {
|
|
1803
|
+
const testResults = [];
|
|
1804
|
+
let numberFailed = 0;
|
|
1805
|
+
let numberPassed = 0;
|
|
1806
|
+
for (const [filePath, fileInfo] of files) {
|
|
1807
|
+
const fileResult = buildFileResult(filePath, fileInfo, errors.get(filePath) ?? []);
|
|
1808
|
+
testResults.push(fileResult);
|
|
1809
|
+
numberFailed += fileResult.numFailingTests;
|
|
1810
|
+
numberPassed += fileResult.numPassingTests;
|
|
1811
|
+
}
|
|
1812
|
+
return {
|
|
1813
|
+
numFailedTests: numberFailed,
|
|
1814
|
+
numPassedTests: numberPassed,
|
|
1815
|
+
numPendingTests: 0,
|
|
1816
|
+
numTotalTests: numberFailed + numberPassed,
|
|
1817
|
+
startTime: Date.now(),
|
|
1818
|
+
success: numberFailed === 0,
|
|
1819
|
+
testResults
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
function runTypecheck(options) {
|
|
1823
|
+
const errors = parseTscOutput(spawnTsgo(options));
|
|
1824
|
+
const files = /* @__PURE__ */ new Map();
|
|
1825
|
+
for (const filePath of options.files) {
|
|
1826
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
1827
|
+
const definitions = collectTestDefinitions(source);
|
|
1828
|
+
const resolvedPath = path$1.resolve(filePath);
|
|
1829
|
+
files.set(resolvedPath, {
|
|
1830
|
+
definitions,
|
|
1831
|
+
source
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
const resolvedErrors = /* @__PURE__ */ new Map();
|
|
1835
|
+
for (const [errorPath, errorList] of errors) {
|
|
1836
|
+
const resolved = path$1.resolve(options.rootDir, errorPath);
|
|
1837
|
+
resolvedErrors.set(resolved, errorList);
|
|
1838
|
+
}
|
|
1839
|
+
return mapErrorsToTests(resolvedErrors, files);
|
|
1840
|
+
}
|
|
1841
|
+
function buildFileResult(filePath, fileInfo, errors) {
|
|
1842
|
+
const indexMap = createLocationsIndexMap(fileInfo.source);
|
|
1843
|
+
const testDefinitions = fileInfo.definitions.filter((definition) => definition.type === "test");
|
|
1844
|
+
const sortedDefinitions = [...testDefinitions].sort((a, b) => b.start - a.start);
|
|
1845
|
+
const errorsByTest = /* @__PURE__ */ new Map();
|
|
1846
|
+
const fileErrors = [];
|
|
1847
|
+
for (const error of errors) {
|
|
1848
|
+
const charIndex = indexMap.get(`${String(error.line)}:${String(error.column)}`);
|
|
1849
|
+
const definition = charIndex !== void 0 ? sortedDefinitions.find((td) => td.start <= charIndex && td.end >= charIndex) : void 0;
|
|
1850
|
+
const message = `TS${String(error.errCode)}: ${error.errMsg}`;
|
|
1851
|
+
if (definition) {
|
|
1852
|
+
const existing = errorsByTest.get(definition.name) ?? [];
|
|
1853
|
+
existing.push(message);
|
|
1854
|
+
errorsByTest.set(definition.name, existing);
|
|
1855
|
+
} else fileErrors.push(message);
|
|
1856
|
+
}
|
|
1857
|
+
const testCases = testDefinitions.map((definition) => {
|
|
1858
|
+
const failures = errorsByTest.get(definition.name) ?? [];
|
|
1859
|
+
return {
|
|
1860
|
+
ancestorTitles: definition.ancestorNames,
|
|
1861
|
+
failureMessages: failures,
|
|
1862
|
+
fullName: [...definition.ancestorNames, definition.name].join(" > "),
|
|
1863
|
+
status: failures.length > 0 ? "failed" : "passed",
|
|
1864
|
+
title: definition.name
|
|
1865
|
+
};
|
|
1866
|
+
});
|
|
1867
|
+
if (fileErrors.length > 0) testCases.unshift({
|
|
1868
|
+
ancestorTitles: [],
|
|
1869
|
+
failureMessages: fileErrors,
|
|
1870
|
+
fullName: "<file-level type error>",
|
|
1871
|
+
status: "failed",
|
|
1872
|
+
title: "<file-level type error>"
|
|
1873
|
+
});
|
|
1874
|
+
const numberFailing = testCases.filter((tc) => tc.status === "failed").length;
|
|
1875
|
+
return {
|
|
1876
|
+
numFailingTests: numberFailing,
|
|
1877
|
+
numPassingTests: testCases.length - numberFailing,
|
|
1878
|
+
numPendingTests: 0,
|
|
1879
|
+
testFilePath: filePath,
|
|
1880
|
+
testResults: testCases
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
function spawnTsgo(options) {
|
|
1884
|
+
const args = [
|
|
1885
|
+
"tsgo",
|
|
1886
|
+
"--noEmit",
|
|
1887
|
+
"--pretty",
|
|
1888
|
+
"false"
|
|
1889
|
+
];
|
|
1890
|
+
if (options.tsconfig !== void 0) args.push("-p", path$1.resolve(options.rootDir, options.tsconfig));
|
|
1891
|
+
try {
|
|
1892
|
+
return execSync(args.join(" "), {
|
|
1893
|
+
cwd: options.rootDir,
|
|
1894
|
+
encoding: "utf-8",
|
|
1895
|
+
stdio: [
|
|
1896
|
+
"ignore",
|
|
1897
|
+
"pipe",
|
|
1898
|
+
"pipe"
|
|
1899
|
+
]
|
|
1900
|
+
});
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
const execError = err;
|
|
1903
|
+
return execError.stdout ?? execError.stderr ?? "";
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1423
1907
|
//#endregion
|
|
1424
1908
|
//#region src/utils/game-output.ts
|
|
1425
1909
|
function formatGameOutputNotice(filePath, entryCount) {
|
|
@@ -1442,4 +1926,4 @@ function writeGameOutput(filePath, entries) {
|
|
|
1442
1926
|
}
|
|
1443
1927
|
|
|
1444
1928
|
//#endregion
|
|
1445
|
-
export {
|
|
1929
|
+
export { extractJsonFromOutput as C, LuauScriptError as S, createStudioBackend as _, execute as a, buildJestArgv as b, formatFailure as c, loadConfig as d, resolveConfig as f, StudioBackend as g, isValidBackend as h, runTypecheck as i, formatResult as l, VALID_BACKENDS as m, parseGameOutput as n, formatJson as o, DEFAULT_CONFIG as p, writeGameOutput as r, writeJsonFile as s, formatGameOutputNotice as t, formatTestSummary as u, OpenCloudBackend as v, parseJestOutput as w, generateTestScript as x, createOpenCloudBackend as y };
|