@isentinel/jest-roblox 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as CliOptions } from "./schema-ryuVGD35.mjs";
1
+ import { t as CliOptions } from "./schema-CEsyg-FX.mjs";
2
2
 
3
3
  //#region src/cli.d.ts
4
4
  declare function main(): Promise<void>;
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-BKBGosEI.mjs";
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-BDatGA9S.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.1";
9
+ var version = "0.0.2";
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: open-cloud)
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,6 +157,7 @@ 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
@@ -118,6 +223,10 @@ function parseArgs(args) {
118
223
  },
119
224
  "testPathPattern": { type: "string" },
120
225
  "timeout": { type: "string" },
226
+ "updateSnapshot": {
227
+ short: "u",
228
+ type: "boolean"
229
+ },
121
230
  "verbose": { type: "boolean" },
122
231
  "version": {
123
232
  default: false,
@@ -153,6 +262,7 @@ function parseArgs(args) {
153
262
  testNamePattern: values.testNamePattern,
154
263
  testPathPattern: values.testPathPattern,
155
264
  timeout,
265
+ updateSnapshot: values.updateSnapshot,
156
266
  verbose: values.verbose,
157
267
  version: values.version
158
268
  };
@@ -175,6 +285,7 @@ function printError(err) {
175
285
  else console.error("An unknown error occurred");
176
286
  }
177
287
  async function runInner(args) {
288
+ if (args[0] === "patch") return runPatch();
178
289
  const cli = parseArgs(args);
179
290
  if (cli.help === true) {
180
291
  console.log(HELP_TEXT);
@@ -192,10 +303,7 @@ async function runInner(args) {
192
303
  }
193
304
  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
305
  const result = await execute({
195
- backend: config.backend === "studio" ? createStudioBackend({
196
- port: config.port,
197
- timeout: config.timeout
198
- }) : createOpenCloudBackend(),
306
+ backend: await resolveBackend(config),
199
307
  config,
200
308
  testFiles: discovery.files,
201
309
  version
@@ -273,9 +381,19 @@ function mergeCliWithConfig(cli, config) {
273
381
  testNamePattern: cli.testNamePattern ?? config.testNamePattern,
274
382
  testPathPattern: cli.testPathPattern ?? config.testPathPattern,
275
383
  timeout: cli.timeout ?? config.timeout,
384
+ updateSnapshot: cli.updateSnapshot ?? config.updateSnapshot,
276
385
  verbose: cli.verbose ?? config.verbose
277
386
  };
278
387
  }
388
+ function runPatch() {
389
+ const result = patchGetDataModelService(process.cwd());
390
+ if (result.status === "already-patched") console.log(`Already patched: ${result.filePath}`);
391
+ else {
392
+ console.log(`Patched: ${result.filePath}`);
393
+ console.log("Rebuild your .rbxl to include the patch.");
394
+ }
395
+ return 0;
396
+ }
279
397
 
280
398
  //#endregion
281
399
  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 LogService:ClearOutput()\n\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 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 return result\nend\n\nlocal runSuccess, runValue = pcall(runTests)\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";
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 _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 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: "open-cloud",
442
+ backend: "auto",
431
443
  cache: true,
432
444
  color: true,
433
445
  compact: false,
@@ -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} pending`));
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:\s*(.+)/);
936
- const receivedMatch = message.match(/Received:\s*(.+)/);
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-ryuVGD35.mjs";
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-CEsyg-FX.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-BKBGosEI.mjs";
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-BDatGA9S.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 };
@@ -758,7 +758,7 @@ type _Except<ObjectType, KeysType extends keyof ObjectType, Options extends Requ
758
758
  //#endregion
759
759
  //#region src/config/schema.d.ts
760
760
  interface Config extends Except<Argv, "rootDir" | "setupFiles" | "testPathPattern"> {
761
- backend?: "open-cloud" | "studio";
761
+ backend?: Backend;
762
762
  cache?: boolean;
763
763
  compact?: boolean;
764
764
  compactMaxFailures?: number;
@@ -774,9 +774,10 @@ interface Config extends Except<Argv, "rootDir" | "setupFiles" | "testPathPatter
774
774
  sourceMap?: boolean;
775
775
  testPathPattern?: string;
776
776
  timeout?: number;
777
+ updateSnapshot?: boolean;
777
778
  }
778
779
  interface ResolvedConfig extends Config {
779
- backend: "open-cloud" | "studio";
780
+ backend: "auto" | "open-cloud" | "studio";
780
781
  cache: boolean;
781
782
  color: boolean;
782
783
  compact: boolean;
@@ -795,9 +796,10 @@ interface ResolvedConfig extends Config {
795
796
  timeout: number;
796
797
  verbose: boolean;
797
798
  }
799
+ type Backend = "auto" | "open-cloud" | "studio";
798
800
  declare const DEFAULT_CONFIG: ResolvedConfig;
799
801
  interface CliOptions {
800
- backend?: "open-cloud" | "studio";
802
+ backend?: Backend;
801
803
  cache?: boolean;
802
804
  color?: boolean;
803
805
  compact?: boolean;
@@ -819,6 +821,7 @@ interface CliOptions {
819
821
  testNamePattern?: string;
820
822
  testPathPattern?: string;
821
823
  timeout?: number;
824
+ updateSnapshot?: boolean;
822
825
  verbose?: boolean;
823
826
  version?: boolean;
824
827
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/jest-roblox",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
5
  "keywords": [
6
6
  "jest",
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 = 5
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()
@@ -115,6 +186,7 @@ function module.runTestsAync(
115
186
  result._timing = {
116
187
  configDecode = 0,
117
188
  findJest = t_findJest - t_findJest0,
189
+ patchSnapshot = t_patchSnapshot - t_patchSnapshot0,
118
190
  requireJest = t_requireJest - t_requireJest0,
119
191
  resolveProjects = t_resolveProjects - t_resolveProjects0,
120
192
  resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,