@isentinel/jest-roblox 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +132 -7
- package/dist/{game-output-BKBGosEI.mjs → game-output--EpqHGEr.mjs} +96 -13
- package/dist/index.d.mts +13 -10
- package/dist/index.mjs +1 -1
- package/dist/{schema-ryuVGD35.d.mts → schema-hhbEFVXy.d.mts} +9 -4
- package/package.json +1 -1
- package/plugin/JestRobloxRunner.rbxm +0 -0
- package/plugin/src/init.server.luau +1 -1
- package/plugin/src/patch/CoreScriptSyncService.luau +19 -0
- package/plugin/src/patch/FileSystemService.luau +27 -0
- package/plugin/src/patch/getDataModelService.luau +19 -0
- package/plugin/src/test-in-run-mode.server.luau +13 -0
- package/plugin/src/test-runner.luau +81 -0
package/dist/cli.d.mts
CHANGED
package/dist/cli.mjs
CHANGED
|
@@ -1,11 +1,115 @@
|
|
|
1
|
-
import { g as createOpenCloudBackend, i as execute, m as createStudioBackend, n as parseGameOutput, o as writeJsonFile, r as writeGameOutput, t as formatGameOutputNotice, u as loadConfig, y as LuauScriptError } from "./game-output
|
|
1
|
+
import { g as createOpenCloudBackend, i as execute, m as createStudioBackend, n as parseGameOutput, o as writeJsonFile, r as writeGameOutput, t as formatGameOutputNotice, u as loadConfig, y as LuauScriptError } from "./game-output--EpqHGEr.mjs";
|
|
2
2
|
import * as path$1 from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { parseArgs as parseArgs$1 } from "node:util";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
7
|
|
|
7
8
|
//#region package.json
|
|
8
|
-
var version = "0.0.
|
|
9
|
+
var version = "0.0.3";
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/backends/auto.ts
|
|
13
|
+
var StudioWithFallback = class {
|
|
14
|
+
studio;
|
|
15
|
+
constructor(studio) {
|
|
16
|
+
this.studio = studio;
|
|
17
|
+
}
|
|
18
|
+
async runTests(options) {
|
|
19
|
+
try {
|
|
20
|
+
return await this.studio.runTests(options);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (isStudioBusyError(err)) {
|
|
23
|
+
process.stderr.write("Studio busy, falling back to Open Cloud\n");
|
|
24
|
+
return createOpenCloudBackend().runTests(options);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function isStudioBusyError(error) {
|
|
31
|
+
if (error instanceof LuauScriptError) return /previous call to start play session/i.test(error.message);
|
|
32
|
+
return error.code === "EADDRINUSE";
|
|
33
|
+
}
|
|
34
|
+
async function probeStudioPlugin(port, timeoutMs, createServer = (wsPort) => {
|
|
35
|
+
return new WebSocketServer({ port: wsPort });
|
|
36
|
+
}) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const wss = createServer(port);
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
wss.close();
|
|
41
|
+
resolve(false);
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
wss.on("connection", () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
wss.close();
|
|
46
|
+
resolve(true);
|
|
47
|
+
});
|
|
48
|
+
wss.on("error", () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
wss.close();
|
|
51
|
+
resolve(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function resolveBackend(config, probe = probeStudioPlugin) {
|
|
56
|
+
if (config.backend === "studio") return createStudioBackend({
|
|
57
|
+
port: config.port,
|
|
58
|
+
timeout: config.timeout
|
|
59
|
+
});
|
|
60
|
+
if (config.backend === "open-cloud") return createOpenCloudBackend();
|
|
61
|
+
if (await probe(config.port, 500)) {
|
|
62
|
+
process.stderr.write("Backend: studio (plugin detected)\n");
|
|
63
|
+
const studio = createStudioBackend({
|
|
64
|
+
port: config.port,
|
|
65
|
+
timeout: config.timeout
|
|
66
|
+
});
|
|
67
|
+
if (hasOpenCloudCredentials()) return new StudioWithFallback(studio);
|
|
68
|
+
return studio;
|
|
69
|
+
}
|
|
70
|
+
if (hasOpenCloudCredentials()) {
|
|
71
|
+
process.stderr.write("Backend: open-cloud (no plugin, using Open Cloud)\n");
|
|
72
|
+
return createOpenCloudBackend();
|
|
73
|
+
}
|
|
74
|
+
throw new Error("No backend available: Studio plugin not detected and Open Cloud env vars (ROBLOX_OPEN_CLOUD_API_KEY, ROBLOX_UNIVERSE_ID, ROBLOX_PLACE_ID) are missing");
|
|
75
|
+
}
|
|
76
|
+
function hasOpenCloudCredentials() {
|
|
77
|
+
return process.env["ROBLOX_OPEN_CLOUD_API_KEY"] !== void 0 && process.env["ROBLOX_UNIVERSE_ID"] !== void 0 && process.env["ROBLOX_PLACE_ID"] !== void 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/patch.ts
|
|
82
|
+
const PATCH_MARKER = "-- jest-roblox-cli patch: enable snapshot mock override";
|
|
83
|
+
const PATCH_LINES = `${PATCH_MARKER}
|
|
84
|
+
if _G.__mockGetDataModelService then
|
|
85
|
+
\treturn _G.__mockGetDataModelService
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
`;
|
|
89
|
+
const PACKAGE_SUBPATH = path$1.join("node_modules", "@rbxts-js", "roblox-shared", "src", "getDataModelService.lua");
|
|
90
|
+
function patchGetDataModelService(rootDirectory) {
|
|
91
|
+
const filePath = findSourceFile(rootDirectory);
|
|
92
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
93
|
+
if (content.includes(PATCH_MARKER)) return {
|
|
94
|
+
filePath,
|
|
95
|
+
status: "already-patched"
|
|
96
|
+
};
|
|
97
|
+
fs.writeFileSync(filePath, PATCH_LINES + content);
|
|
98
|
+
return {
|
|
99
|
+
filePath,
|
|
100
|
+
status: "patched"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function findSourceFile(rootDirectory) {
|
|
104
|
+
let current = path$1.resolve(rootDirectory);
|
|
105
|
+
const { root } = path$1.parse(current);
|
|
106
|
+
while (current !== root) {
|
|
107
|
+
const candidate = path$1.join(current, PACKAGE_SUBPATH);
|
|
108
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
109
|
+
current = path$1.dirname(current);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Cannot find @rbxts-js/roblox-shared. Searched from ${rootDirectory} to filesystem root.`);
|
|
112
|
+
}
|
|
9
113
|
|
|
10
114
|
//#endregion
|
|
11
115
|
//#region src/utils/glob.ts
|
|
@@ -38,7 +142,7 @@ const HELP_TEXT = `
|
|
|
38
142
|
Usage: jest-roblox [options] [files...]
|
|
39
143
|
|
|
40
144
|
Options:
|
|
41
|
-
--backend <type> Backend: "open-cloud" or "studio" (default:
|
|
145
|
+
--backend <type> Backend: "auto", "open-cloud", or "studio" (default: auto)
|
|
42
146
|
--port <number> WebSocket port for studio backend (default: 3001)
|
|
43
147
|
--config <path> Path to config file
|
|
44
148
|
--testPathPattern <regex> Filter test files by path pattern
|
|
@@ -53,10 +157,12 @@ Options:
|
|
|
53
157
|
--verbose Show individual test results
|
|
54
158
|
--silent Suppress output
|
|
55
159
|
--no-color Disable colored output
|
|
160
|
+
-u, --updateSnapshot Update snapshot files
|
|
56
161
|
--no-cache Force re-upload place file (skip cache)
|
|
57
162
|
--pollInterval <ms> Open Cloud poll interval in ms (default: 500)
|
|
58
163
|
--projects <path...> DataModel paths to search for tests
|
|
59
164
|
--setupFiles <path...> DataModel paths to setup scripts
|
|
165
|
+
--setupFilesAfterEnv <path...> DataModel paths to post-env setup scripts
|
|
60
166
|
--no-show-luau Hide Luau code in failure output
|
|
61
167
|
--help Show this help message
|
|
62
168
|
--version Show version number
|
|
@@ -109,6 +215,10 @@ function parseArgs(args) {
|
|
|
109
215
|
multiple: true,
|
|
110
216
|
type: "string"
|
|
111
217
|
},
|
|
218
|
+
"setupFilesAfterEnv": {
|
|
219
|
+
multiple: true,
|
|
220
|
+
type: "string"
|
|
221
|
+
},
|
|
112
222
|
"showLuau": { type: "boolean" },
|
|
113
223
|
"silent": { type: "boolean" },
|
|
114
224
|
"sourceMap": { type: "boolean" },
|
|
@@ -118,6 +228,10 @@ function parseArgs(args) {
|
|
|
118
228
|
},
|
|
119
229
|
"testPathPattern": { type: "string" },
|
|
120
230
|
"timeout": { type: "string" },
|
|
231
|
+
"updateSnapshot": {
|
|
232
|
+
short: "u",
|
|
233
|
+
type: "boolean"
|
|
234
|
+
},
|
|
121
235
|
"verbose": { type: "boolean" },
|
|
122
236
|
"version": {
|
|
123
237
|
default: false,
|
|
@@ -147,12 +261,14 @@ function parseArgs(args) {
|
|
|
147
261
|
projects: values.projects,
|
|
148
262
|
rojoProject: values.rojoProject,
|
|
149
263
|
setupFiles: values.setupFiles,
|
|
264
|
+
setupFilesAfterEnv: values.setupFilesAfterEnv,
|
|
150
265
|
showLuau: values["no-show-luau"] === true ? false : values.showLuau,
|
|
151
266
|
silent: values.silent,
|
|
152
267
|
sourceMap: values.sourceMap,
|
|
153
268
|
testNamePattern: values.testNamePattern,
|
|
154
269
|
testPathPattern: values.testPathPattern,
|
|
155
270
|
timeout,
|
|
271
|
+
updateSnapshot: values.updateSnapshot,
|
|
156
272
|
verbose: values.verbose,
|
|
157
273
|
version: values.version
|
|
158
274
|
};
|
|
@@ -175,6 +291,7 @@ function printError(err) {
|
|
|
175
291
|
else console.error("An unknown error occurred");
|
|
176
292
|
}
|
|
177
293
|
async function runInner(args) {
|
|
294
|
+
if (args[0] === "patch") return runPatch();
|
|
178
295
|
const cli = parseArgs(args);
|
|
179
296
|
if (cli.help === true) {
|
|
180
297
|
console.log(HELP_TEXT);
|
|
@@ -192,10 +309,7 @@ async function runInner(args) {
|
|
|
192
309
|
}
|
|
193
310
|
if (!config.silent && !config.compact && !config.json && discovery.files.length !== discovery.totalFiles) process.stderr.write(`Running ${discovery.files.length} of ${discovery.totalFiles} test files\n`);
|
|
194
311
|
const result = await execute({
|
|
195
|
-
backend:
|
|
196
|
-
port: config.port,
|
|
197
|
-
timeout: config.timeout
|
|
198
|
-
}) : createOpenCloudBackend(),
|
|
312
|
+
backend: await resolveBackend(config),
|
|
199
313
|
config,
|
|
200
314
|
testFiles: discovery.files,
|
|
201
315
|
version
|
|
@@ -267,15 +381,26 @@ function mergeCliWithConfig(cli, config) {
|
|
|
267
381
|
projects: cli.projects ?? config.projects,
|
|
268
382
|
rojoProject: cli.rojoProject ?? config.rojoProject,
|
|
269
383
|
setupFiles: cli.setupFiles ?? config.setupFiles,
|
|
384
|
+
setupFilesAfterEnv: cli.setupFilesAfterEnv ?? config.setupFilesAfterEnv,
|
|
270
385
|
showLuau: cli.showLuau ?? config.showLuau,
|
|
271
386
|
silent: cli.silent ?? config.silent,
|
|
272
387
|
sourceMap: cli.sourceMap ?? config.sourceMap,
|
|
273
388
|
testNamePattern: cli.testNamePattern ?? config.testNamePattern,
|
|
274
389
|
testPathPattern: cli.testPathPattern ?? config.testPathPattern,
|
|
275
390
|
timeout: cli.timeout ?? config.timeout,
|
|
391
|
+
updateSnapshot: cli.updateSnapshot ?? config.updateSnapshot,
|
|
276
392
|
verbose: cli.verbose ?? config.verbose
|
|
277
393
|
};
|
|
278
394
|
}
|
|
395
|
+
function runPatch() {
|
|
396
|
+
const result = patchGetDataModelService(process.cwd());
|
|
397
|
+
if (result.status === "already-patched") console.log(`Already patched: ${result.filePath}`);
|
|
398
|
+
else {
|
|
399
|
+
console.log(`Patched: ${result.filePath}`);
|
|
400
|
+
console.log("Rebuild your .rbxl to include the patch.");
|
|
401
|
+
}
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
279
404
|
|
|
280
405
|
//#endregion
|
|
281
406
|
export { main, parseArgs, run };
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as path$1 from "node:path";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import { type } from "arktype";
|
|
4
6
|
import * as fs from "node:fs";
|
|
5
7
|
import { existsSync } from "node:fs";
|
|
6
|
-
import { type } from "arktype";
|
|
7
8
|
import { homedir, tmpdir } from "node:os";
|
|
8
9
|
import * as crypto from "node:crypto";
|
|
9
10
|
import { randomUUID } from "node:crypto";
|
|
10
11
|
import buffer from "node:buffer";
|
|
11
|
-
import { WebSocketServer } from "ws";
|
|
12
12
|
import { createJiti } from "jiti";
|
|
13
13
|
import color from "tinyrainbow";
|
|
14
14
|
import hljs from "highlight.js/lib/core";
|
|
@@ -55,6 +55,7 @@ function parseJestOutput(output) {
|
|
|
55
55
|
if (trimmed.startsWith("{")) {
|
|
56
56
|
const parsed = JSON.parse(trimmed);
|
|
57
57
|
const luauTiming = extractLuauTiming(parsed);
|
|
58
|
+
const snapshotWrites = extractSnapshotWrites(parsed);
|
|
58
59
|
const unwrapped = unwrapResult(parsed);
|
|
59
60
|
if (unwrapped["kind"] === "ExecutionError") {
|
|
60
61
|
const errorMessage = extractExecutionError(unwrapped);
|
|
@@ -62,12 +63,14 @@ function parseJestOutput(output) {
|
|
|
62
63
|
}
|
|
63
64
|
if (unwrapped["results"] !== void 0 && typeof unwrapped["results"] === "object") return {
|
|
64
65
|
luauTiming,
|
|
65
|
-
result: validateJestResult(unwrapped["results"])
|
|
66
|
+
result: validateJestResult(unwrapped["results"]),
|
|
67
|
+
snapshotWrites
|
|
66
68
|
};
|
|
67
69
|
try {
|
|
68
70
|
return {
|
|
69
71
|
luauTiming,
|
|
70
|
-
result: validateJestResult(unwrapped)
|
|
72
|
+
result: validateJestResult(unwrapped),
|
|
73
|
+
snapshotWrites
|
|
71
74
|
};
|
|
72
75
|
} catch {}
|
|
73
76
|
}
|
|
@@ -95,6 +98,13 @@ function extractLuauTiming(parsed) {
|
|
|
95
98
|
for (const [key, value] of Object.entries(timing)) if (typeof value === "number") record[key] = value;
|
|
96
99
|
return Object.keys(record).length > 0 ? record : void 0;
|
|
97
100
|
}
|
|
101
|
+
function extractSnapshotWrites(parsed) {
|
|
102
|
+
const writes = parsed["_snapshotWrites"];
|
|
103
|
+
if (writes === void 0 || writes === null || typeof writes !== "object") return;
|
|
104
|
+
const record = {};
|
|
105
|
+
for (const [key, value] of Object.entries(writes)) if (typeof value === "string") record[key] = value;
|
|
106
|
+
return Object.keys(record).length > 0 ? record : void 0;
|
|
107
|
+
}
|
|
98
108
|
function isValidJson(text) {
|
|
99
109
|
try {
|
|
100
110
|
JSON.parse(text);
|
|
@@ -116,7 +126,7 @@ function validateJestResult(value) {
|
|
|
116
126
|
|
|
117
127
|
//#endregion
|
|
118
128
|
//#region src/test-runner.luau
|
|
119
|
-
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 _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\nlocal t_requireJest0 = os.clock()\nlocal Jest = (require :: any)(findValue)\nlocal t_requireJest = os.clock()\n\nlocal function runTests()\n
|
|
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";
|
|
120
130
|
|
|
121
131
|
//#endregion
|
|
122
132
|
//#region src/test-script.ts
|
|
@@ -265,6 +275,7 @@ var OpenCloudBackend = class {
|
|
|
265
275
|
gameOutput,
|
|
266
276
|
luauTiming: parsed.luauTiming,
|
|
267
277
|
result: parsed.result,
|
|
278
|
+
snapshotWrites: parsed.snapshotWrites,
|
|
268
279
|
timing: {
|
|
269
280
|
executionMs,
|
|
270
281
|
uploadCached,
|
|
@@ -381,6 +392,7 @@ var StudioBackend = class {
|
|
|
381
392
|
gameOutput: message.gameOutput,
|
|
382
393
|
luauTiming: parsed.luauTiming,
|
|
383
394
|
result: parsed.result,
|
|
395
|
+
snapshotWrites: parsed.snapshotWrites,
|
|
384
396
|
timing: { executionMs }
|
|
385
397
|
};
|
|
386
398
|
}
|
|
@@ -427,7 +439,7 @@ function createStudioBackend(options) {
|
|
|
427
439
|
//#endregion
|
|
428
440
|
//#region src/config/schema.ts
|
|
429
441
|
const DEFAULT_CONFIG = {
|
|
430
|
-
backend: "
|
|
442
|
+
backend: "auto",
|
|
431
443
|
cache: true,
|
|
432
444
|
color: true,
|
|
433
445
|
compact: false,
|
|
@@ -491,7 +503,7 @@ function findExpectationColumn(lineText) {
|
|
|
491
503
|
const expectIndex = lineText.search(/\bexpect\s*\(/);
|
|
492
504
|
if (expectIndex === -1) return 1;
|
|
493
505
|
const afterExpect = lineText.slice(expectIndex);
|
|
494
|
-
const matcherRegex =
|
|
506
|
+
const matcherRegex = /[.:]\s*([A-Za-z_$][\w$]*)\s*(?=\()/g;
|
|
495
507
|
let lastMatcher = null;
|
|
496
508
|
for (const match of afterExpect.matchAll(matcherRegex)) lastMatcher = match;
|
|
497
509
|
const matcherName = lastMatcher?.[1];
|
|
@@ -503,12 +515,12 @@ function findExpectationColumn(lineText) {
|
|
|
503
515
|
//#region src/source-mapper/line-mapper.ts
|
|
504
516
|
function mapLine(luauLineNumber, luauLines, tsLines) {
|
|
505
517
|
const luauLine = normalize(luauLines[luauLineNumber - 1]);
|
|
506
|
-
if (luauLine === void 0) return;
|
|
518
|
+
if (luauLine === void 0 || luauLine === "") return;
|
|
507
519
|
for (const [index, tsLine] of tsLines.entries()) if (normalize(tsLine) === luauLine) return index + 1;
|
|
508
520
|
const stripped = luauLine.replace(/\s/g, "");
|
|
509
521
|
for (const [index, tsLine] of tsLines.entries()) {
|
|
510
522
|
const tsNormalized = normalize(tsLine);
|
|
511
|
-
if (tsNormalized === void 0) continue;
|
|
523
|
+
if (tsNormalized === void 0 || tsNormalized === "") continue;
|
|
512
524
|
const tsStripped = tsNormalized.replace(/\s/g, "");
|
|
513
525
|
if (tsStripped.includes(stripped) || stripped.includes(tsStripped)) return index + 1;
|
|
514
526
|
}
|
|
@@ -910,7 +922,7 @@ function formatTestSummary(result, timing, styles) {
|
|
|
910
922
|
const testParts = [];
|
|
911
923
|
if (result.numPassedTests > 0) testParts.push(st.summary.passed(`${result.numPassedTests} passed`));
|
|
912
924
|
if (result.numFailedTests > 0) testParts.push(st.summary.failed(`${result.numFailedTests} failed`));
|
|
913
|
-
if (result.numPendingTests > 0) testParts.push(st.summary.pending(`${result.numPendingTests}
|
|
925
|
+
if (result.numPendingTests > 0) testParts.push(st.summary.pending(`${result.numPendingTests} skipped`));
|
|
914
926
|
const testTotalLabel = st.dim(`(${result.numTotalTests})`);
|
|
915
927
|
const testsLabel = labelStyle(" Tests");
|
|
916
928
|
lines.push(`${testsLabel} ${testParts.join(" | ")} ${testTotalLabel}`);
|
|
@@ -932,8 +944,8 @@ function formatTestSummary(result, timing, styles) {
|
|
|
932
944
|
}
|
|
933
945
|
function parseErrorMessage(message) {
|
|
934
946
|
const lines = message.split("\n");
|
|
935
|
-
const expectedMatch = message.match(/Expected
|
|
936
|
-
const receivedMatch = message.match(/Received
|
|
947
|
+
const expectedMatch = message.match(/Expected\b.*?:\s*(.+)/);
|
|
948
|
+
const receivedMatch = message.match(/Received\b.*?:\s*(.+)/);
|
|
937
949
|
return {
|
|
938
950
|
expected: expectedMatch?.[1],
|
|
939
951
|
message: lines[0] ?? message,
|
|
@@ -1251,6 +1263,32 @@ async function writeJsonFile(result, filePath) {
|
|
|
1251
1263
|
fs.writeFileSync(absolutePath, formatJson(result), "utf8");
|
|
1252
1264
|
}
|
|
1253
1265
|
|
|
1266
|
+
//#endregion
|
|
1267
|
+
//#region src/snapshot/path-resolver.ts
|
|
1268
|
+
function createSnapshotPathResolver(config) {
|
|
1269
|
+
const mappings = buildMappings(config.rojoProject.tree, "");
|
|
1270
|
+
return { resolve(virtualPath) {
|
|
1271
|
+
const normalized = virtualPath.replaceAll("\\", "/");
|
|
1272
|
+
for (const [prefix, basePath] of mappings) {
|
|
1273
|
+
if (!normalized.startsWith(`${prefix}/`) && normalized !== prefix) continue;
|
|
1274
|
+
let result = `${basePath}/${normalized.slice(prefix.length + 1)}`;
|
|
1275
|
+
if (config.outDir !== void 0 && config.rootDir !== void 0 && result.startsWith(`${config.outDir}/`)) result = config.rootDir + result.slice(config.outDir.length);
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
1278
|
+
} };
|
|
1279
|
+
}
|
|
1280
|
+
function buildMappings(tree, prefix) {
|
|
1281
|
+
const mappings = [];
|
|
1282
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
1283
|
+
if (key.startsWith("$") || typeof value !== "object") continue;
|
|
1284
|
+
const dataModelPath = prefix ? `${prefix}/${key}` : key;
|
|
1285
|
+
if (value.$path !== void 0) mappings.push([dataModelPath, value.$path]);
|
|
1286
|
+
mappings.push(...buildMappings(value, dataModelPath));
|
|
1287
|
+
}
|
|
1288
|
+
mappings.sort((a, b) => b[0].length - a[0].length);
|
|
1289
|
+
return mappings;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1254
1292
|
//#endregion
|
|
1255
1293
|
//#region src/executor.ts
|
|
1256
1294
|
const rojoProjectSchema = type({ tree: "object" });
|
|
@@ -1260,10 +1298,11 @@ const tsconfigSchema = type({ "compilerOptions?": {
|
|
|
1260
1298
|
} });
|
|
1261
1299
|
async function execute(options) {
|
|
1262
1300
|
const startTime = Date.now();
|
|
1263
|
-
const { gameOutput, luauTiming, result, timing: backendTiming } = await options.backend.runTests({
|
|
1301
|
+
const { gameOutput, luauTiming, result, snapshotWrites, timing: backendTiming } = await options.backend.runTests({
|
|
1264
1302
|
config: options.config,
|
|
1265
1303
|
testFiles: options.testFiles
|
|
1266
1304
|
});
|
|
1305
|
+
if (snapshotWrites !== void 0) writeSnapshots(snapshotWrites, options.config);
|
|
1267
1306
|
const testsMs = calculateTestsMs(result.testResults);
|
|
1268
1307
|
const sourceMapper = options.config.sourceMap ? buildSourceMapper(options.config) : void 0;
|
|
1269
1308
|
const totalMs = Date.now() - startTime;
|
|
@@ -1336,6 +1375,50 @@ function printLuauTiming(timing) {
|
|
|
1336
1375
|
}
|
|
1337
1376
|
process.stderr.write(`[TIMING] total: ${String(total)}ms\n`);
|
|
1338
1377
|
}
|
|
1378
|
+
function writeSnapshots(snapshotWrites, config) {
|
|
1379
|
+
const rojoProjectPath = config.rojoProject ?? findRojoProject(config.rootDir);
|
|
1380
|
+
if (rojoProjectPath === void 0 || !fs.existsSync(rojoProjectPath)) {
|
|
1381
|
+
process.stderr.write("Warning: Cannot write snapshots - no rojo project found\n");
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
const rojoResult = rojoProjectSchema(JSON.parse(fs.readFileSync(rojoProjectPath, "utf-8")));
|
|
1386
|
+
if (rojoResult instanceof type.errors) {
|
|
1387
|
+
process.stderr.write("Warning: Cannot write snapshots - invalid rojo project\n");
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const tsconfigPath = path$1.join(config.rootDir, "tsconfig.json");
|
|
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
|
+
}
|
|
1400
|
+
const resolver = createSnapshotPathResolver({
|
|
1401
|
+
outDir: outDirectory,
|
|
1402
|
+
rojoProject: rojoResult,
|
|
1403
|
+
rootDir: rootDirectory
|
|
1404
|
+
});
|
|
1405
|
+
let written = 0;
|
|
1406
|
+
for (const [virtualPath, content] of Object.entries(snapshotWrites)) {
|
|
1407
|
+
const fsPath = resolver.resolve(virtualPath);
|
|
1408
|
+
if (fsPath === void 0) {
|
|
1409
|
+
process.stderr.write(`Warning: Cannot resolve snapshot path: ${virtualPath}\n`);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
const absolutePath = path$1.resolve(config.rootDir, fsPath);
|
|
1413
|
+
fs.mkdirSync(path$1.dirname(absolutePath), { recursive: true });
|
|
1414
|
+
fs.writeFileSync(absolutePath, content);
|
|
1415
|
+
written++;
|
|
1416
|
+
}
|
|
1417
|
+
if (written > 0) process.stderr.write(`Wrote ${String(written)} snapshot file${written === 1 ? "" : "s"}\n`);
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
process.stderr.write(`Warning: Failed to write snapshot files: ${String(err)}\n`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1339
1422
|
|
|
1340
1423
|
//#endregion
|
|
1341
1424
|
//#region src/utils/game-output.ts
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as Argv, i as ResolvedConfig, n as Config, r as DEFAULT_CONFIG, t as CliOptions } from "./schema-
|
|
2
|
-
import buffer from "node:buffer";
|
|
1
|
+
import { a as Argv, i as ResolvedConfig, n as Config, r as DEFAULT_CONFIG, t as CliOptions } from "./schema-hhbEFVXy.mjs";
|
|
3
2
|
import { WebSocketServer } from "ws";
|
|
3
|
+
import buffer from "node:buffer";
|
|
4
4
|
|
|
5
5
|
//#region src/types/jest-result.d.ts
|
|
6
6
|
interface JestResult {
|
|
@@ -29,6 +29,16 @@ interface TestFileResult {
|
|
|
29
29
|
}
|
|
30
30
|
type TestStatus = "failed" | "passed" | "pending" | "skipped";
|
|
31
31
|
//#endregion
|
|
32
|
+
//#region src/reporter/parser.d.ts
|
|
33
|
+
interface ParseResult {
|
|
34
|
+
luauTiming?: Record<string, number>;
|
|
35
|
+
result: JestResult;
|
|
36
|
+
snapshotWrites?: SnapshotWrites;
|
|
37
|
+
}
|
|
38
|
+
type SnapshotWrites = Record<string, string>;
|
|
39
|
+
declare function extractJsonFromOutput(output: string): string | undefined;
|
|
40
|
+
declare function parseJestOutput(output: string): ParseResult;
|
|
41
|
+
//#endregion
|
|
32
42
|
//#region src/backends/interface.d.ts
|
|
33
43
|
interface Backend {
|
|
34
44
|
runTests(options: BackendOptions): Promise<BackendResult>;
|
|
@@ -41,6 +51,7 @@ interface BackendResult {
|
|
|
41
51
|
gameOutput?: string;
|
|
42
52
|
luauTiming?: Record<string, number>;
|
|
43
53
|
result: JestResult;
|
|
54
|
+
snapshotWrites?: SnapshotWrites;
|
|
44
55
|
timing: BackendTiming;
|
|
45
56
|
}
|
|
46
57
|
interface BackendTiming {
|
|
@@ -213,14 +224,6 @@ declare function formatTestSummary(result: JestResult, timing: TimingResult, sty
|
|
|
213
224
|
declare function formatJson(result: JestResult): string;
|
|
214
225
|
declare function writeJsonFile(result: JestResult, filePath: string): Promise<void>;
|
|
215
226
|
//#endregion
|
|
216
|
-
//#region src/reporter/parser.d.ts
|
|
217
|
-
interface ParseResult {
|
|
218
|
-
luauTiming?: Record<string, number>;
|
|
219
|
-
result: JestResult;
|
|
220
|
-
}
|
|
221
|
-
declare function extractJsonFromOutput(output: string): string | undefined;
|
|
222
|
-
declare function parseJestOutput(output: string): ParseResult;
|
|
223
|
-
//#endregion
|
|
224
227
|
//#region src/test-script.d.ts
|
|
225
228
|
type JestArgv = Argv & {
|
|
226
229
|
testMatch: Array<string>;
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { _ as buildJestArgv, a as formatJson, b as extractJsonFromOutput, c as formatResult, d as resolveConfig, f as DEFAULT_CONFIG, g as createOpenCloudBackend, h as OpenCloudBackend, i as execute, l as formatTestSummary, m as createStudioBackend, n as parseGameOutput, o as writeJsonFile, p as StudioBackend, r as writeGameOutput, s as formatFailure, t as formatGameOutputNotice, u as loadConfig, v as generateTestScript, x as parseJestOutput } from "./game-output
|
|
1
|
+
import { _ as buildJestArgv, a as formatJson, b as extractJsonFromOutput, c as formatResult, d as resolveConfig, f as DEFAULT_CONFIG, g as createOpenCloudBackend, h as OpenCloudBackend, i as execute, l as formatTestSummary, m as createStudioBackend, n as parseGameOutput, o as writeJsonFile, p as StudioBackend, r as writeGameOutput, s as formatFailure, t as formatGameOutputNotice, u as loadConfig, v as generateTestScript, x as parseJestOutput } from "./game-output--EpqHGEr.mjs";
|
|
2
2
|
|
|
3
3
|
export { DEFAULT_CONFIG, OpenCloudBackend, StudioBackend, buildJestArgv, createOpenCloudBackend, createStudioBackend, execute, extractJsonFromOutput, formatFailure, formatGameOutputNotice, formatJson, formatResult, formatTestSummary, generateTestScript, loadConfig, parseGameOutput, parseJestOutput, resolveConfig, writeGameOutput, writeJsonFile };
|
|
@@ -757,8 +757,8 @@ type Except<ObjectType, KeysType extends keyof ObjectType, Options extends Excep
|
|
|
757
757
|
type _Except<ObjectType, KeysType extends keyof ObjectType, Options extends Required<ExceptOptions>> = { [KeyType in keyof ObjectType as Filter<KeyType, KeysType>]: ObjectType[KeyType] } & (Options['requireExactProps'] extends true ? Partial<Record<KeysType, never>> : {});
|
|
758
758
|
//#endregion
|
|
759
759
|
//#region src/config/schema.d.ts
|
|
760
|
-
interface Config extends Except<Argv, "rootDir" | "setupFiles" | "testPathPattern"> {
|
|
761
|
-
backend?:
|
|
760
|
+
interface Config extends Except<Argv, "rootDir" | "setupFiles" | "setupFilesAfterEnv" | "testPathPattern"> {
|
|
761
|
+
backend?: Backend;
|
|
762
762
|
cache?: boolean;
|
|
763
763
|
compact?: boolean;
|
|
764
764
|
compactMaxFailures?: number;
|
|
@@ -770,13 +770,15 @@ interface Config extends Except<Argv, "rootDir" | "setupFiles" | "testPathPatter
|
|
|
770
770
|
rojoProject?: string;
|
|
771
771
|
rootDir?: string;
|
|
772
772
|
setupFiles?: Array<string>;
|
|
773
|
+
setupFilesAfterEnv?: Array<string>;
|
|
773
774
|
showLuau?: boolean;
|
|
774
775
|
sourceMap?: boolean;
|
|
775
776
|
testPathPattern?: string;
|
|
776
777
|
timeout?: number;
|
|
778
|
+
updateSnapshot?: boolean;
|
|
777
779
|
}
|
|
778
780
|
interface ResolvedConfig extends Config {
|
|
779
|
-
backend: "open-cloud" | "studio";
|
|
781
|
+
backend: "auto" | "open-cloud" | "studio";
|
|
780
782
|
cache: boolean;
|
|
781
783
|
color: boolean;
|
|
782
784
|
compact: boolean;
|
|
@@ -795,9 +797,10 @@ interface ResolvedConfig extends Config {
|
|
|
795
797
|
timeout: number;
|
|
796
798
|
verbose: boolean;
|
|
797
799
|
}
|
|
800
|
+
type Backend = "auto" | "open-cloud" | "studio";
|
|
798
801
|
declare const DEFAULT_CONFIG: ResolvedConfig;
|
|
799
802
|
interface CliOptions {
|
|
800
|
-
backend?:
|
|
803
|
+
backend?: Backend;
|
|
801
804
|
cache?: boolean;
|
|
802
805
|
color?: boolean;
|
|
803
806
|
compact?: boolean;
|
|
@@ -813,12 +816,14 @@ interface CliOptions {
|
|
|
813
816
|
projects?: Array<string>;
|
|
814
817
|
rojoProject?: string;
|
|
815
818
|
setupFiles?: Array<string>;
|
|
819
|
+
setupFilesAfterEnv?: Array<string>;
|
|
816
820
|
showLuau?: boolean;
|
|
817
821
|
silent?: boolean;
|
|
818
822
|
sourceMap?: boolean;
|
|
819
823
|
testNamePattern?: string;
|
|
820
824
|
testPathPattern?: string;
|
|
821
825
|
timeout?: number;
|
|
826
|
+
updateSnapshot?: boolean;
|
|
822
827
|
verbose?: boolean;
|
|
823
828
|
version?: boolean;
|
|
824
829
|
}
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -9,7 +9,7 @@ if RunService:IsRunning() then
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
local WEBSOCKET_URL = "ws://localhost:3001"
|
|
12
|
-
local RECONNECT_DELAY =
|
|
12
|
+
local RECONNECT_DELAY = 0.1
|
|
13
13
|
|
|
14
14
|
local IsDebug = ReplicatedStorage:GetAttribute("JEST_ROBLOX_DEBUG") == true
|
|
15
15
|
ReplicatedStorage:GetAttributeChangedSignal("JEST_ROBLOX_DEBUG"):Connect(function(): ()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Mock CoreScriptSyncService: builds instance-to-path mapping
|
|
2
|
+
-- Uses custom getInstancePath that stops at game (excludes DataModel name)
|
|
3
|
+
local function getInstancePath(instance: Instance): string
|
|
4
|
+
local parts = {}
|
|
5
|
+
local curr: Instance? = instance
|
|
6
|
+
while curr and curr ~= game do
|
|
7
|
+
table.insert(parts, 1, (curr :: Instance).Name)
|
|
8
|
+
curr = (curr :: Instance).Parent
|
|
9
|
+
end
|
|
10
|
+
return table.concat(parts, "/")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
local CoreScriptSyncService = {}
|
|
14
|
+
|
|
15
|
+
function CoreScriptSyncService:GetScriptFilePath(script_: Instance): string
|
|
16
|
+
return getInstancePath(script_)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return CoreScriptSyncService
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Mock FileSystemService: collects snapshot writes in-memory Uses
|
|
2
|
+
-- _G.__snapshotWrites so all module load contexts (standard Luau + Jest
|
|
3
|
+
-- runtime) share the same table. The test-runner creates this before patching.
|
|
4
|
+
local snapshotWrites: { [string]: string } = _G.__snapshotWrites or {}
|
|
5
|
+
|
|
6
|
+
local FileSystemService = {}
|
|
7
|
+
|
|
8
|
+
function FileSystemService:WriteFile(path: string, contents: string)
|
|
9
|
+
-- Rewrite .snap.lua -> .snap.luau for Rojo compatibility
|
|
10
|
+
snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] = contents
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
function FileSystemService:CreateDirectories(_path: string) end
|
|
14
|
+
|
|
15
|
+
function FileSystemService:Exists(path: string): boolean
|
|
16
|
+
return snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] ~= nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
function FileSystemService:Remove(path: string)
|
|
20
|
+
snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
function FileSystemService:IsRegularFile(path: string): boolean
|
|
24
|
+
return snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] ~= nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
return FileSystemService
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Patched getDataModelService: returns mock services for snapshot support
|
|
2
|
+
local CoreScriptSyncService = require(script.Parent.CoreScriptSyncService)
|
|
3
|
+
local FileSystemService = require(script.Parent.FileSystemService)
|
|
4
|
+
|
|
5
|
+
return function(service: string)
|
|
6
|
+
if service == "FileSystemService" then
|
|
7
|
+
return FileSystemService
|
|
8
|
+
elseif service == "CoreScriptSyncService" then
|
|
9
|
+
return CoreScriptSyncService
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
local success, result = pcall(function()
|
|
13
|
+
local svc = game:GetService(service)
|
|
14
|
+
local _ = svc.Name
|
|
15
|
+
return svc
|
|
16
|
+
end)
|
|
17
|
+
|
|
18
|
+
return success and result or nil
|
|
19
|
+
end
|
|
@@ -30,6 +30,19 @@ end
|
|
|
30
30
|
local TestRunner = require(script.Parent["test-runner"])
|
|
31
31
|
local jestOutput, gameOutput = TestRunner.runTestsAync(script, testArgs.config or {})
|
|
32
32
|
|
|
33
|
+
-- Inject snapshot writes from shared global table into jestOutput
|
|
34
|
+
local snapshotWrites = _G.__snapshotWrites
|
|
35
|
+
if snapshotWrites and next(snapshotWrites) then
|
|
36
|
+
local ok, decoded = pcall(function()
|
|
37
|
+
return HttpService:JSONDecode(jestOutput)
|
|
38
|
+
end)
|
|
39
|
+
|
|
40
|
+
if ok and type(decoded) == "table" then
|
|
41
|
+
decoded._snapshotWrites = snapshotWrites
|
|
42
|
+
jestOutput = HttpService:JSONEncode(decoded)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
33
46
|
StudioTestService:EndTest({
|
|
34
47
|
jestOutput = jestOutput,
|
|
35
48
|
gameOutput = gameOutput,
|
|
@@ -41,6 +41,66 @@ function module.getJest(config: { jestPath: string? }): ModuleScript
|
|
|
41
41
|
return jestInstance
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
-- Find RobloxShared relative to Jest module
|
|
45
|
+
local function findRobloxShared(jestModule: ModuleScript): Instance?
|
|
46
|
+
local parent = jestModule.Parent
|
|
47
|
+
if parent then
|
|
48
|
+
local found = parent:FindFirstChild("RobloxShared")
|
|
49
|
+
or parent:FindFirstChild("jest-roblox-shared")
|
|
50
|
+
if found then
|
|
51
|
+
return found
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if parent.Parent then
|
|
55
|
+
found = parent.Parent:FindFirstChild("RobloxShared")
|
|
56
|
+
or parent.Parent:FindFirstChild("jest-roblox-shared")
|
|
57
|
+
if found then
|
|
58
|
+
return found
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return game:FindFirstChild("RobloxShared", true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
-- Replace a child ModuleScript inside RobloxShared with a patched clone
|
|
67
|
+
local function replaceChild(parent: Instance, name: string, patchSource: ModuleScript)
|
|
68
|
+
local original = parent:FindFirstChild(name)
|
|
69
|
+
if original then
|
|
70
|
+
original:Destroy()
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
local clone = patchSource:Clone()
|
|
74
|
+
clone.Name = name
|
|
75
|
+
clone.Parent = parent
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
-- Patch RobloxShared by replacing ModuleScript children
|
|
79
|
+
local function patchRobloxShared(jestModule: ModuleScript, patchFolder: Instance): boolean
|
|
80
|
+
local robloxShared = findRobloxShared(jestModule)
|
|
81
|
+
if not robloxShared then
|
|
82
|
+
warn("[jest-roblox-cli] Could not find RobloxShared; snapshot support disabled")
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
local patchGetDataModelService = patchFolder:FindFirstChild("getDataModelService")
|
|
87
|
+
local patchFileSystemService = patchFolder:FindFirstChild("FileSystemService")
|
|
88
|
+
local patchCoreScriptSyncService = patchFolder:FindFirstChild("CoreScriptSyncService")
|
|
89
|
+
|
|
90
|
+
if not (patchGetDataModelService and patchFileSystemService and patchCoreScriptSyncService) then
|
|
91
|
+
warn("[jest-roblox-cli] Missing patch modules; snapshot support disabled")
|
|
92
|
+
return false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
-- Replace children inside RobloxShared
|
|
96
|
+
replaceChild(robloxShared, "FileSystemService", patchFileSystemService :: ModuleScript)
|
|
97
|
+
replaceChild(robloxShared, "CoreScriptSyncService", patchCoreScriptSyncService :: ModuleScript)
|
|
98
|
+
replaceChild(robloxShared, "getDataModelService", patchGetDataModelService :: ModuleScript)
|
|
99
|
+
|
|
100
|
+
warn("[jest-roblox-cli] Patched RobloxShared at:", robloxShared:GetFullName())
|
|
101
|
+
return true
|
|
102
|
+
end
|
|
103
|
+
|
|
44
104
|
function module.runTestsAync(
|
|
45
105
|
callingScript: LuaSourceContainer,
|
|
46
106
|
config: {
|
|
@@ -66,6 +126,17 @@ function module.runTestsAync(
|
|
|
66
126
|
if logSuccess then logHistory else "[]"
|
|
67
127
|
end
|
|
68
128
|
|
|
129
|
+
-- Snapshot support: create shared table BEFORE patching so all module
|
|
130
|
+
-- load contexts (standard Luau + Jest runtime) reference the same table
|
|
131
|
+
_G.__snapshotWrites = {}
|
|
132
|
+
|
|
133
|
+
local t_patchSnapshot0 = os.clock()
|
|
134
|
+
local patchFolder = (callingScript :: any).Parent.patch
|
|
135
|
+
if patchFolder then
|
|
136
|
+
patchRobloxShared(findValue, patchFolder)
|
|
137
|
+
end
|
|
138
|
+
local t_patchSnapshot = os.clock()
|
|
139
|
+
|
|
69
140
|
local t_requireJest0 = os.clock()
|
|
70
141
|
local Jest = (require :: any)(findValue)
|
|
71
142
|
local t_requireJest = os.clock()
|
|
@@ -98,6 +169,15 @@ function module.runTestsAync(
|
|
|
98
169
|
|
|
99
170
|
config.setupFiles = resolved
|
|
100
171
|
end
|
|
172
|
+
if config.setupFilesAfterEnv and #config.setupFilesAfterEnv > 0 then
|
|
173
|
+
local resolved = {}
|
|
174
|
+
|
|
175
|
+
for _, setupPath in config.setupFilesAfterEnv do
|
|
176
|
+
table.insert(resolved, module.findInstance(setupPath))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
config.setupFilesAfterEnv = resolved
|
|
180
|
+
end
|
|
101
181
|
local t_resolveSetupFiles = os.clock()
|
|
102
182
|
|
|
103
183
|
config._timing = nil
|
|
@@ -115,6 +195,7 @@ function module.runTestsAync(
|
|
|
115
195
|
result._timing = {
|
|
116
196
|
configDecode = 0,
|
|
117
197
|
findJest = t_findJest - t_findJest0,
|
|
198
|
+
patchSnapshot = t_patchSnapshot - t_patchSnapshot0,
|
|
118
199
|
requireJest = t_requireJest - t_requireJest0,
|
|
119
200
|
resolveProjects = t_resolveProjects - t_resolveProjects0,
|
|
120
201
|
resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,
|