@el4cteo/rbx-studio-mcp 0.8.3 → 0.8.6

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.
Files changed (68) hide show
  1. package/README.md +24 -6
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Commands.luau +72 -5
  37. package/plugin/src/Config.luau +65 -65
  38. package/plugin/src/Console.luau +5 -0
  39. package/plugin/src/Dispatch.luau +131 -90
  40. package/plugin/src/ExecRuntime.luau +190 -168
  41. package/plugin/src/LogBuffer.luau +38 -9
  42. package/plugin/src/Paths.luau +42 -0
  43. package/plugin/src/Phrase.luau +41 -0
  44. package/plugin/src/Prompt.luau +23 -3
  45. package/plugin/src/ScriptEdit.luau +94 -8
  46. package/plugin/src/Serialize.luau +16 -1
  47. package/plugin/src/Transport.luau +5 -2
  48. package/plugin/src/Undo.luau +74 -10
  49. package/plugin/src/handlers/Capture.luau +818 -809
  50. package/plugin/src/handlers/Debug.luau +19 -16
  51. package/plugin/src/handlers/Discover.luau +31 -1
  52. package/plugin/src/handlers/Perf.luau +100 -21
  53. package/plugin/src/handlers/Scripts.luau +274 -90
  54. package/plugin/src/handlers/Sync.luau +968 -0
  55. package/plugin/src/init.server.luau +47 -2
  56. package/scripts/build.mjs +14 -0
  57. package/scripts/sync-fake.mjs +191 -0
  58. package/scripts/test-live-sync-scale.mjs +150 -0
  59. package/scripts/test-live-sync.mjs +232 -0
  60. package/scripts/test-live-tools.mjs +6 -1
  61. package/scripts/test-plugin.mjs +17 -0
  62. package/scripts/test-results.mjs +41 -0
  63. package/scripts/test-sync-more.mjs +228 -0
  64. package/scripts/test-sync.mjs +245 -0
  65. package/dist/tools/spatial.js +0 -135
  66. package/dist/tools/spatial.js.map +0 -1
  67. package/dist/tools/upload.js +0 -294
  68. package/dist/tools/upload.js.map +0 -1
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `sync` against a real Studio, through the real MCP layer.
4
+ *
5
+ * Everything happens inside ServerStorage.__mcp_live_sync and a temp folder;
6
+ * both are removed before exit, pass or fail. Checks the things an offline fake
7
+ * cannot: the plugin's scan, the editor write path, identity kept across a
8
+ * rename, build files round-tripping real property values, and watch mode
9
+ * reacting to real file and editor events.
10
+ *
11
+ * Usage: node scripts/test-live-sync.mjs [--port 44755] [--studio-id ID]
12
+ */
13
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
15
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const args = process.argv.slice(2);
21
+ const flag = (name) => {
22
+ const at = args.indexOf(`--${name}`);
23
+ return at === -1 ? undefined : args[at + 1];
24
+ };
25
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
+ const FIXTURE = "ServerStorage.__mcp_live_sync";
27
+ const dir = mkdtempSync(join(tmpdir(), "rbx-live-sync-"));
28
+ const base = `ServerStorage/__mcp_live_sync`;
29
+ const file = (relative) => join(dir, ...`${base}/${relative}`.split("/"));
30
+
31
+ const client = new Client({ name: "test-live-sync", version: "0" });
32
+ await client.connect(
33
+ new StdioClientTransport({
34
+ command: process.execPath,
35
+ args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
36
+ stderr: "inherit",
37
+ }),
38
+ );
39
+
40
+ let failures = 0;
41
+ const studioId = flag("studio-id");
42
+
43
+ async function call(name, params = {}) {
44
+ const started = performance.now();
45
+ try {
46
+ const result = await client.callTool(
47
+ { name, arguments: { ...(studioId ? { studioId } : {}), ...params } },
48
+ undefined,
49
+ { timeout: 180_000 },
50
+ );
51
+ const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
52
+ return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
53
+ } catch (cause) {
54
+ return { text: cause.message, isError: true, ms: Math.round(performance.now() - started) };
55
+ }
56
+ }
57
+
58
+ function report(label, good, detail = "") {
59
+ if (!good) failures += 1;
60
+ process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(52)} ${detail.replace(/\s+/g, " ").slice(0, good ? 90 : 400)}\n`);
61
+ }
62
+
63
+ async function check(label, name, params, expect = /./) {
64
+ const reply = await call(name, params);
65
+ report(label, !reply.isError && expect.test(reply.text), `${reply.ms}ms ${reply.text}`);
66
+ return reply.text;
67
+ }
68
+
69
+ /** Runs Luau in the edit session and returns what it returned, as text. */
70
+ async function luau(source) {
71
+ const reply = await call("execute_luau", { source });
72
+ if (reply.isError) throw new Error(reply.text);
73
+ return reply.text;
74
+ }
75
+
76
+ const sync = (op, extra = {}) => ({ op, dir, roots: [FIXTURE], ...extra });
77
+ // The first lines that differ, for a readable failure.
78
+ const diffOf = (before, after) => {
79
+ const a = before.split("\n");
80
+ const b = after.split("\n");
81
+ const at = a.findIndex((line, index) => line !== b[index]);
82
+ return `line ${at + 1}: ${a[at]?.trim()} -> ${b[at]?.trim()}`;
83
+ };
84
+ const read = (relative) => readFileSync(file(relative), "utf8");
85
+ const until = async (probe, seconds = 8) => {
86
+ const deadline = Date.now() + seconds * 1000;
87
+ for (;;) {
88
+ if (await probe()) return true;
89
+ if (Date.now() > deadline) return false;
90
+ await new Promise((resolve) => setTimeout(resolve, 250));
91
+ }
92
+ };
93
+
94
+ try {
95
+ await luau(`
96
+ local old = game.ServerStorage:FindFirstChild("__mcp_live_sync")
97
+ if old then old:Destroy() end
98
+ local fixture = Instance.new("Folder"); fixture.Name = "__mcp_live_sync"
99
+ local shared = Instance.new("Folder"); shared.Name = "Shared"; shared.Parent = fixture
100
+ local util = Instance.new("ModuleScript"); util.Name = "Util"; util.Source = "return 1\\n"; util:SetAttribute("Marker", "util"); util.Parent = shared
101
+ local main = Instance.new("Script"); main.Name = "Main"; main.Source = "print('main')\\n"; main.Parent = fixture
102
+ local helper = Instance.new("ModuleScript"); helper.Name = "Helper"; helper.Source = "return 'helper'\\n"; helper.Parent = main
103
+ local gui = Instance.new("ScreenGui"); gui.Name = "Gui"; gui.ResetOnSpawn = false; gui.Parent = fixture
104
+ local panel = Instance.new("Frame"); panel.Name = "Panel"; panel.Size = UDim2.new(0, 200, 0, 100); panel.BackgroundColor3 = Color3.new(1, 0.5, 0); panel.Parent = gui
105
+ local title = Instance.new("TextLabel"); title.Name = "Title"; title.Text = "Hello"; title.Parent = panel
106
+ local client = Instance.new("LocalScript"); client.Name = "GuiClient"; client.Source = "-- gui\\n"; client:SetAttribute("Marker", "gui"); client.Parent = panel
107
+ fixture.Parent = game.ServerStorage
108
+ return "ready"`);
109
+
110
+ // Pull: the tree lands on disk, Rojo-style.
111
+ await check("pull: first sync writes every script", "sync", sync("pull"), /4 created on disk/);
112
+ report("pull: script with children is a folder + init", existsSync(file("Main/init.server.luau")) && read("Main/Helper.luau") === "return 'helper'\n");
113
+ report("pull: LocalScript and ModuleScript extensions", existsSync(file("Gui/Panel/GuiClient.client.luau")) && read("Shared/Util.luau") === "return 1\n");
114
+ await check("status: nothing to do right after", "sync", sync("status"), /nothing to do/);
115
+
116
+ // Push: a file edit reaches the editor buffer.
117
+ writeFileSync(file("Shared/Util.luau"), "return 2\n");
118
+ await check("push: edited file", "sync", sync("push"), /1 written to Studio/);
119
+ await check("push: Studio has the new text", "script_read", { paths: [`${FIXTURE}.Shared.Util`] }, /return 2/);
120
+
121
+ // Pull: an editor edit reaches the file.
122
+ await check("studio edit through script_edit", "script_edit", { edits: [{ path: `${FIXTURE}.Main.Helper`, find: "'helper'", replace: "'studio'" }] });
123
+ await check("sync: Studio edit comes back", "sync", sync("sync"), /1 written to disk/);
124
+ report("file has the Studio edit", read("Main/Helper.luau") === "return 'studio'\n", read("Main/Helper.luau"));
125
+
126
+ // New file in a new folder: a new script, under a new Folder.
127
+ writeFileSync(file("Shared/New.luau"), "return 'new'\n");
128
+ await check("new file becomes a script", "sync", sync("sync"), /1 created in Studio/);
129
+ const created = await luau(`return game.ServerStorage.__mcp_live_sync.Shared.New.Source`);
130
+ report("new script has the file's text", created.includes("return 'new'"), created);
131
+
132
+ // Rename a file: the SAME script is renamed (its attribute survives).
133
+ renameSync(file("Shared/Util.luau"), file("Shared/Tools.luau"));
134
+ await check("renamed file moves the script", "sync", sync("sync"), /1 moved in Studio/);
135
+ const marker = await luau(`local s = game.ServerStorage.__mcp_live_sync.Shared:FindFirstChild("Tools"); return if s then s:GetAttribute("Marker") else "missing"`);
136
+ report("same instance: attribute kept across rename", marker.includes("util"), marker);
137
+
138
+ // Both sides changed: a conflict that touches neither side.
139
+ writeFileSync(file("Shared/Tools.luau"), "return 'disk'\n");
140
+ await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_live_sync.Shared.Tools, function() return "return 'editor'\\n" end) return 1`);
141
+ await check("conflict is reported", "sync", sync("sync"), /Conflicts[\s\S]*Tools\.luau/);
142
+ report("conflict left the file alone", read("Shared/Tools.luau") === "return 'disk'\n");
143
+ await check("prefer disk settles it", "sync", sync("sync", { prefer: "disk" }), /1 written to Studio/);
144
+ await check("Studio took the disk side", "script_read", { paths: [`${FIXTURE}.Shared.Tools`] }, /return 'disk'/);
145
+
146
+ // Delete a file: the script is deleted (undoably).
147
+ unlinkSync(file("Shared/New.luau"));
148
+ await check("deleted file deletes the script", "sync", sync("sync"), /1 deleted in Studio[\s\S]*MCP sync/);
149
+ const gone = await luau(`return game.ServerStorage.__mcp_live_sync.Shared:FindFirstChild("New") == nil`);
150
+ report("script is gone", gone.includes("true"), gone);
151
+
152
+ // Build files: export, edit, rebuild -- scripts carried across.
153
+ await check("export a ScreenGui", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Gui`] }, /Gui\.build\.json/);
154
+ const spec = JSON.parse(read("Gui.build.json"));
155
+ const title = spec.children[0].children.find((child) => child.name === "Title");
156
+ report("export holds non-default values only", spec.properties?.ResetOnSpawn === false && title?.properties?.Text === "Hello" && !("Visible" in (title.properties ?? {})), JSON.stringify(spec).slice(0, 300));
157
+ report("export leaves scripts out", !JSON.stringify(spec).includes("GuiClient"));
158
+ title.properties.Text = "Rebuilt";
159
+ writeFileSync(file("Gui.build.json"), `${JSON.stringify(spec, null, 2)}\n`);
160
+ await check("build rebuilds from the edited file", "sync", { op: "build", dir, roots: [FIXTURE] }, /Gui\.build\.json/);
161
+ const rebuilt = await luau(`
162
+ local gui = game.ServerStorage.__mcp_live_sync.Gui
163
+ local client = gui.Panel:FindFirstChild("GuiClient")
164
+ return gui.Panel.Title.Text .. "|" .. tostring(client and client:GetAttribute("Marker")) .. "|" .. tostring(gui.Panel.BackgroundColor3)`);
165
+ report("rebuilt text, same script, colour kept", rebuilt.includes("Rebuilt|gui|1, 0.5") || rebuilt.includes("Rebuilt|gui|1, 0.50"), rebuilt);
166
+
167
+ // Build files must round-trip every kind of value exactly, and references
168
+ // inside a rebuilt tree must point into the NEW tree, not the old one.
169
+ await luau(`
170
+ local fixture = game.ServerStorage.__mcp_live_sync
171
+ local model = Instance.new("Model"); model.Name = "Rig"
172
+ local a = Instance.new("Part"); a.Name = "A"; a.Anchored = true; a.Material = Enum.Material.Neon; a.BrickColor = BrickColor.new("Bright red")
173
+ a.CFrame = CFrame.new(1, 2, 3) * CFrame.Angles(0, math.rad(45), 0); a.Size = Vector3.new(2, 3, 4); a.Parent = model
174
+ local b = Instance.new("Part"); b.Name = "B"; b.Shape = Enum.PartType.Ball; b.Color = Color3.fromRGB(10, 200, 30); b.Position = Vector3.new(5, 2, 3); b.Parent = model
175
+ local weld = Instance.new("WeldConstraint"); weld.Name = "Weld"; weld.Part0 = a; weld.Part1 = b; weld.Parent = model
176
+ local pointer = Instance.new("ObjectValue"); pointer.Name = "Pointer"; pointer.Value = b; pointer.Parent = model
177
+ model.PrimaryPart = a
178
+ local gui = Instance.new("Frame"); gui.Name = "Card"; gui.Size = UDim2.new(0.5, 10, 0, 40); gui.Parent = model
179
+ local corner = Instance.new("UICorner"); corner.CornerRadius = UDim.new(0, 12); corner.Parent = gui
180
+ local gradient = Instance.new("UIGradient")
181
+ gradient.Color = ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) })
182
+ gradient.Transparency = NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 0.5) })
183
+ gradient.Rotation = 90; gradient.Parent = gui
184
+ local label = Instance.new("TextLabel"); label.Name = "Label"; label.Text = "Tag"; label.RichText = true
185
+ label.FontFace = Font.new("rbxasset://fonts/families/GothamSSm.json", Enum.FontWeight.Bold, Enum.FontStyle.Italic); label.Parent = gui
186
+ local image = Instance.new("ImageLabel"); image.Name = "Icon"; image.Image = "rbxassetid://123456"; image.ScaleType = Enum.ScaleType.Slice
187
+ image.SliceCenter = Rect.new(4, 4, 12, 12); image.Parent = gui
188
+ model.Parent = fixture
189
+ return 1`);
190
+ await check("export a tree of many value types", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Rig`] }, /Rig\.build\.json/);
191
+ const exportedRig = read("Rig.build.json");
192
+ report(
193
+ "references export as paths into the tree",
194
+ exportedRig.includes(`"Part0": "${FIXTURE}.Rig.A"`) && exportedRig.includes(`"PrimaryPart": "${FIXTURE}.Rig.A"`),
195
+ exportedRig.slice(0, 200),
196
+ );
197
+ const missing = ["FontFace", "\"Size\": \"2, 3, 4\"", "\"Shape\"", '"Color"', '"Transparency"', "SliceCenter", "CornerRadius", "Material", "CFrame", "Part0", "Part1", "RichText", "rbxassetid://123456"]
198
+ .filter((needle) => !exportedRig.includes(needle));
199
+ report("every value type made it into the file", missing.length === 0, missing.join(", "));
200
+ // Rebuild from the untouched file, then export again: nothing may drift.
201
+ await check("rebuild from the same file", "sync", { op: "build", dir, roots: [FIXTURE], files: [`${base}/Rig.build.json`], prefer: "disk" }, /Rig\.build\.json/);
202
+ await check("export the rebuilt tree", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Rig`] }, /Rig\.build\.json/);
203
+ const again = read("Rig.build.json");
204
+ report("round trip is lossless", again === exportedRig, again === exportedRig ? "" : diffOf(exportedRig, again));
205
+ const wired = await luau(`
206
+ local rig = game.ServerStorage.__mcp_live_sync.Rig
207
+ local weld = rig.Weld
208
+ return tostring(weld.Part0 == rig.A and weld.Part1 == rig.B and rig.Pointer.Value == rig.B and rig.PrimaryPart == rig.A)
209
+ .. "|" .. tostring(#game.ServerStorage.__mcp_live_sync:GetChildren())`);
210
+ report("references point into the new tree, old tree gone", wired.includes("true|4"), wired);
211
+
212
+ // Watch: both directions within seconds, with no call in between.
213
+ await check("watch starts", "sync", sync("watch"), /Watching/);
214
+ writeFileSync(file("Main/Helper.luau"), "return 'watched'\n");
215
+ const toStudio = await until(async () => (await luau(`return game.ServerStorage.__mcp_live_sync.Main.Helper.Source`)).includes("watched"));
216
+ report("watch: file edit reaches Studio", toStudio);
217
+ await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_live_sync.Main.Helper, function() return "return 'from studio'\\n" end) return 1`);
218
+ const toDisk = await until(async () => read("Main/Helper.luau") === "return 'from studio'\n");
219
+ report("watch: Studio edit reaches the file", toDisk, read("Main/Helper.luau"));
220
+ await check("watch: status shows it running", "sync", sync("status"), /Watching since/);
221
+ await check("watch stops", "sync", sync("stop"), /Stopped watching/);
222
+ } catch (cause) {
223
+ report("run", false, String(cause?.stack ?? cause));
224
+ } finally {
225
+ await call("sync", { op: "stop", dir }).catch(() => undefined);
226
+ await luau(`local f = game.ServerStorage:FindFirstChild("__mcp_live_sync") if f then f:Destroy() end return 1`).catch(() => undefined);
227
+ rmSync(dir, { recursive: true, force: true });
228
+ await client.close();
229
+ }
230
+
231
+ process.stdout.write(failures === 0 ? "\nlive sync: ok\n" : `\nlive sync: ${failures} failure(s)\n`);
232
+ process.exit(failures === 0 ? 0 : 1);
@@ -138,7 +138,12 @@ try {
138
138
  await ok("inspect: named properties", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Position"] }, /Position/);
139
139
  await ok("script_read", "script_read", { paths: [`${FIXTURE}.Mod`] }, /rev [0-9a-f]+/);
140
140
  await ok("script_read: a window", "script_read", { paths: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 3 }] }, /lines 2-3 of 5/);
141
- await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod:2/);
141
+ await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod +rev=[0-9a-f]+-[0-9a-f]+\n2: /);
142
+ await ok("script_grep: patterns", "script_grep", { patterns: ["add", "return"], path: FIXTURE }, /\[patterns 1\]/);
143
+ await ok("script_grep: counts", "script_grep", { patterns: ["add", "nothing-here"], path: FIXTURE, mode: "counts" }, /"lines": ?0/);
144
+ await ok("script_grep: files", "script_grep", { pattern: "add", literal: true, path: FIXTURE, mode: "files" }, /Mod \| ModuleScript \| [0-9a-f]+-/);
145
+ await ok("find: properties", "find", { path: FIXTURE, className: "Part", properties: ["Anchored", "NotAProperty"] }, /"Anchored":\s*true[\s\S]*NotAProperty/);
146
+ await ok("console: drain", "console", { mode: "drain", limit: 5 }, /nextCursor/);
142
147
  await ok("api: describe", "api", { op: "describe", className: "Part" }, /superclass/);
143
148
  await ok("api: classes", "api", { op: "classes", contains: "Constraint" }, /HingeConstraint/);
144
149
  await ok("console", "console", { limit: 5 });
@@ -28,8 +28,18 @@ if (luau === null) {
28
28
 
29
29
  /** Modules under test, paired with the test file that exercises each. */
30
30
  const suites = [
31
+ { module: "plugin/src/Dispatch.luau", test: "tests/deadlines.luau", prelude: "tests/deadlines-stub.luau" },
32
+ { module: "plugin/src/ScriptEdit.luau", test: "tests/scriptedit.luau", prelude: "tests/scriptedit-stub.luau" },
33
+ { module: "plugin/src/Undo.luau", test: "tests/undo.luau", prelude: "tests/undo-stub.luau" },
34
+ {
35
+ module: "plugin/src/handlers/Scripts.luau",
36
+ test: "tests/scripts.luau",
37
+ prelude: "tests/scripts-stub.luau",
38
+ dependencies: [{ name: "TextEdit", path: "plugin/src/TextEdit.luau" }],
39
+ },
31
40
  { module: "plugin/src/handlers/Playtest.luau", test: "tests/playtests.luau", prelude: "tests/playtests-stub.luau" },
32
41
  { module: "plugin/src/Commands.luau", test: "tests/playtests-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
42
+ { module: "plugin/src/Commands.luau", test: "tests/chat-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
33
43
  { module: "plugin/src/RemoteTrace.luau", test: "tests/remote-trace.luau", prelude: "tests/remote-trace-stub.luau" },
34
44
  { module: "plugin/src/ExecRuntime.luau", test: "tests/exec-runtime.luau", prelude: "tests/exec-runtime-stub.luau" },
35
45
  { module: "plugin/src/ClientRelay.luau", test: "tests/client-relay.luau", prelude: "tests/client-relay-stub.luau" },
@@ -45,6 +55,8 @@ const suites = [
45
55
  * MCP server turns into an actionable error for the agent.
46
56
  */
47
57
  const DISPATCH_STUB = `local Dispatch = {}
58
+ function Dispatch.checkDeadline() end
59
+ function Dispatch.deadline() return nil end
48
60
  function Dispatch.fail(code, message, hint)
49
61
  \terror({ code = code, message = message, hint = hint }, 0)
50
62
  end
@@ -63,6 +75,11 @@ for (const suite of suites) {
63
75
  const bundle = [
64
76
  DISPATCH_STUB,
65
77
  suite.prelude ? readFileSync(join(root, suite.prelude), "utf8") : "",
78
+ // Real modules loaded as the module's own dependencies, not stubbed.
79
+ ...(suite.dependencies ?? []).map(
80
+ (dependency) =>
81
+ `local ${dependency.name} = (function()\n${stripRequires(readFileSync(join(root, dependency.path), "utf8"))}\nend)()`,
82
+ ),
66
83
  suite.dependency ? `local Playtest = (function()\n${stripRequires(readFileSync(join(root, suite.dependency), "utf8"))}\nend)()` : "",
67
84
  "local function loadModule()",
68
85
  moduleSource,
@@ -0,0 +1,41 @@
1
+ /** Response budgets, cursors that survive clipping, and grouped grep rendering. */
2
+ import assert from "node:assert/strict";
3
+ import { z } from "zod";
4
+ import { CHARACTER_LIMIT, decodeCursor, json, page, table } from "../dist/lib/format.js";
5
+ import { registerScriptTools } from "../dist/tools/scripts.js";
6
+ const output = result => result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
7
+ const rows = Array.from({length: 20}, (_, i) => ({path: "P" + i, data: "x".repeat(4000)}));
8
+ for (const result of [table(["path", "data"], rows, {offset: 10, total: 30}), page(rows, {offset: 10, total: 30})]) {
9
+ const rendered = output(result);
10
+ assert.ok(rendered.length <= CHARACTER_LIMIT);
11
+ const cursor = rendered.match(/cursor: "([^"]+)"/)?.[1];
12
+ const next = decodeCursor(cursor);
13
+ assert.ok(next > 10 && next < 30, "budget cursor must not skip unseen rows");
14
+ assert.ok(rendered.includes("P" + (next - 11)) && !rendered.includes("P" + (next - 10)));
15
+ }
16
+ for (const value of [{data: "\u0000".repeat(100000)}, Array.from({length: 5000}, () => ({nested: {data: "x".repeat(10000)}}))]) {
17
+ const rendered = output(json(value));
18
+ assert.ok(rendered.length <= CHARACTER_LIMIT);
19
+ assert.equal(JSON.parse(rendered).truncated, true, "large JSON must stay valid");
20
+ }
21
+ const registered = new Map();
22
+ const context = {server: {registerTool: (name, spec, handler) => registered.set(name, {spec, handler})}, bridge: {}};
23
+ registerScriptTools(context);
24
+ const call = (name, args) => {const tool = registered.get(name); return tool.handler(z.object(tool.spec.inputSchema).parse(args));};
25
+ context.bridge.call = async (op, params) => {
26
+ assert.equal(op, "script.grep"); assert.deepEqual(params.patterns, ["alpha", "beta"]);
27
+ return {searched: 1, total: 2, offset: 0, items: [
28
+ {path: "Script", revision: "r1", line: 2, text: "alpha", before: ["one"], after: ["beta", "four"], needles: [1]},
29
+ {path: "Script", revision: "r1", line: 3, text: "beta", before: ["one", "alpha"], after: ["four"], needles: [2]},
30
+ ]};
31
+ };
32
+ const grep = output(await call("script_grep", {patterns: ["alpha", "beta"], contextLines: 2}));
33
+ assert.equal(grep.match(/rev=r1/g).length, 1);
34
+ assert.equal(grep.match(/alpha/g).length, 1); assert.equal(grep.match(/beta/g).length, 1);
35
+ assert.equal(grep.match(/four/g).length, 1);
36
+ context.bridge.call = async () => ({searched: 1, total: 20, offset: 0, items: Array.from({length: 20}, (_, i) => ({path: "Big", line: i + 1, text: "x".repeat(4000)}))});
37
+ const clipped = output(await call("script_grep", {pattern: "x"}));
38
+ assert.ok(clipped.length <= CHARACTER_LIMIT && clipped.includes("cursor:"));
39
+ assert.ok(decodeCursor(clipped.match(/cursor: "([^"]+)"/)[1]) < 20);
40
+
41
+ console.log("results: budgets, clipped cursors and merged search context pass");
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `sync`, offline, part two: conflicts that remember themselves, build files
3
+ * both ways, and `watch` -- echo suppression and notices on the next reply.
4
+ *
5
+ * Build files go through the real create-spec typing, which reads the API
6
+ * dump, so the dump is a small fixture in a sandboxed temp dir and the network
7
+ * is refused: the run is the same on every machine.
8
+ *
9
+ * Usage: node scripts/test-sync-more.mjs (after `npm run build`)
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import path from "node:path";
15
+
16
+ const sandbox = mkdtempSync(path.join(tmpdir(), "rbx-sync-more-"));
17
+ for (const name of ["TMPDIR", "TEMP", "TMP"]) process.env[name] = sandbox;
18
+ globalThis.fetch = async () => {
19
+ throw new Error("offline");
20
+ };
21
+ const property = (name, type) => ({ MemberType: "Property", Name: name, ValueType: { Name: type, Category: "Primitive" } });
22
+ mkdirSync(path.join(sandbox, "roblox-studio-mcp"), { recursive: true });
23
+ writeFileSync(
24
+ path.join(sandbox, "roblox-studio-mcp", "api-dump.json"),
25
+ JSON.stringify({
26
+ fetchedAt: Date.now(),
27
+ dump: {
28
+ Classes: [
29
+ { Name: "Instance", Superclass: "", Members: [property("Name", "string")] },
30
+ { Name: "ScreenGui", Superclass: "Instance", Members: [property("ResetOnSpawn", "bool")] },
31
+ { Name: "Frame", Superclass: "Instance", Members: [property("BackgroundTransparency", "float")] },
32
+ { Name: "TextLabel", Superclass: "Instance", Members: [property("Text", "string")] },
33
+ ],
34
+ Enums: [],
35
+ },
36
+ }),
37
+ );
38
+
39
+ const { runBuild, runExport, runSync, startWatch, stopAllWatches, stopWatch, watchState } = await import("../dist/lib/sync.js");
40
+ const { takeNotices } = await import("../dist/lib/notices.js");
41
+ const { fakeStudio } = await import("./sync-fake.mjs");
42
+
43
+ const dir = path.join(sandbox, "work");
44
+ const file = (relative) => path.join(dir, ...relative.split("/"));
45
+ const read = (relative) => readFileSync(file(relative), "utf8");
46
+ const until = async (probe, ms = 5_000) => {
47
+ const deadline = Date.now() + ms;
48
+ while (!(await probe())) {
49
+ if (Date.now() > deadline) return false;
50
+ await new Promise((resolve) => setTimeout(resolve, 50));
51
+ }
52
+ return true;
53
+ };
54
+
55
+ try {
56
+ // Conflicts remember themselves -----------------------------------------
57
+ {
58
+ const studio = fakeStudio();
59
+ studio.scripts.set("ServerScriptService.Mod", { className: "ModuleScript", source: "return 1\n" });
60
+ const sync = (extra = {}) => runSync(studio, { dir, direction: "both", ...extra });
61
+ await sync();
62
+
63
+ writeFileSync(file("ServerScriptService/Mod.luau"), "return 'disk'\n");
64
+ studio.scripts.get("ServerScriptService.Mod").source = "return 'studio'\n";
65
+ let report = await sync();
66
+ assert.equal(report.conflicts.length, 1);
67
+ assert.equal(read(".rbx-sync/conflicts/ServerScriptService/Mod.luau"), "return 'studio'\n", "Studio's side is kept to merge from");
68
+ report = await sync();
69
+ assert.equal(report.conflicts.length, 1, "still a conflict while neither side moves");
70
+ assert.deepEqual(report.newConflicts, [], "but it is not new");
71
+
72
+ // Resolve on disk: the merged file wins, conditionally on Studio not having moved.
73
+ writeFileSync(file("ServerScriptService/Mod.luau"), "return 'merged'\n");
74
+ report = await sync();
75
+ assert.equal(report.conflicts.length, 0);
76
+ assert.equal(studio.scripts.get("ServerScriptService.Mod").source, "return 'merged'\n");
77
+ assert.ok(!existsSync(file(".rbx-sync/conflicts/ServerScriptService/Mod.luau")), "the sidecar goes when settled");
78
+
79
+ // Resolve in Studio instead.
80
+ writeFileSync(file("ServerScriptService/Mod.luau"), "return 'disk 2'\n");
81
+ studio.scripts.get("ServerScriptService.Mod").source = "return 'studio 2'\n";
82
+ assert.equal((await sync()).conflicts.length, 1);
83
+ studio.scripts.get("ServerScriptService.Mod").source = "return 'fixed in studio'\n";
84
+ report = await sync();
85
+ assert.equal(report.conflicts.length, 0);
86
+ assert.equal(read("ServerScriptService/Mod.luau"), "return 'fixed in studio'\n");
87
+ // A file outside the roots is not created (and so not re-created every run).
88
+ mkdirSync(file("Workspace"), { recursive: true });
89
+ writeFileSync(file("Workspace/Stray.luau"), "return 'stray'\n");
90
+ report = await sync();
91
+ assert.ok(![...studio.scripts.keys()].some((key) => key.startsWith("Workspace")), "outside the roots: left alone");
92
+ assert.equal(report.failures.length, 0);
93
+ rmSync(dir, { recursive: true, force: true });
94
+ console.log("sync conflicts: Studio's side kept beside, reported once, settled by whichever side moves");
95
+ }
96
+
97
+ // Build files, both ways --------------------------------------------------
98
+ {
99
+ const studio = fakeStudio();
100
+ studio.trees.set("StarterGui.Shop", {
101
+ className: "ScreenGui",
102
+ name: "Shop",
103
+ properties: { ResetOnSpawn: false },
104
+ children: [
105
+ { className: "Frame", name: "Panel", properties: { BackgroundTransparency: 0.5 }, children: [{ className: "TextLabel", name: "Title", properties: { Text: "Hello" } }] },
106
+ ],
107
+ });
108
+ const title = () => studio.trees.get("StarterGui.Shop").children[0].children[0].properties.Text;
109
+ const setFileTitle = (text) => {
110
+ const spec = JSON.parse(read("StarterGui/Shop.build.json"));
111
+ spec.children[0].children[0].properties.Text = text;
112
+ writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(spec, null, 2)}\n`);
113
+ };
114
+ const setStudioTitle = (text) => {
115
+ studio.trees.get("StarterGui.Shop").children[0].children[0].properties.Text = text;
116
+ };
117
+ const sync = (extra = {}) => runSync(studio, { dir, direction: "both", ...extra });
118
+
119
+ const [exported] = await runExport(studio, { dir, direction: "both", paths: ["StarterGui.Shop"] });
120
+ assert.equal(exported.file, "StarterGui/Shop.build.json");
121
+ const first = read("StarterGui/Shop.build.json");
122
+ assert.ok(first.indexOf('"className"') < first.indexOf('"name"') && first.includes('"Text": "Hello"'), "canonical key order");
123
+ assert.equal((await sync()).lines.length, 0, "in step right after export");
124
+
125
+ setFileTitle("From file");
126
+ let report = await sync();
127
+ assert.deepEqual(report.builds, ["StarterGui/Shop.build.json"]);
128
+ assert.equal(title(), "From file", "file edit rebuilds the tree");
129
+ assert.equal((await sync()).lines.length, 0, "and settles");
130
+
131
+ setStudioTitle("From Studio");
132
+ report = await sync();
133
+ assert.equal(report.counts.export, 1);
134
+ assert.ok(read("StarterGui/Shop.build.json").includes('"Text": "From Studio"'), "Studio edit is written back to the file");
135
+
136
+ setFileTitle("Disk side");
137
+ setStudioTitle("Studio side");
138
+ report = await sync();
139
+ assert.equal(report.conflicts.length, 1, "both sides edited: a conflict");
140
+ assert.equal(title(), "Studio side", "the tree is not rebuilt over");
141
+ assert.ok(read(".rbx-sync/conflicts/StarterGui/Shop.build.json").includes("Studio side"));
142
+ // An explicit build refuses too, unless told the file wins.
143
+ report = await runBuild(studio, { dir, direction: "push" , files: ["StarterGui/Shop.build.json"] });
144
+ assert.equal(report.conflicts.length, 1);
145
+ assert.equal(title(), "Studio side");
146
+
147
+ setFileTitle("Merged");
148
+ report = await sync();
149
+ assert.equal(report.conflicts.length, 0);
150
+ assert.equal(title(), "Merged", "the file that moved after the conflict wins");
151
+ assert.ok(!existsSync(file(".rbx-sync/conflicts/StarterGui/Shop.build.json")));
152
+
153
+ setStudioTitle("Studio again");
154
+ report = await runBuild(studio, { dir, direction: "push", files: ["StarterGui/Shop.build.json"], prefer: "disk" });
155
+ assert.equal(title(), "Merged", "prefer disk rebuilds over Studio's edit");
156
+
157
+ // Renamed in the file: the old tree is replaced, not duplicated.
158
+ const renamed = JSON.parse(read("StarterGui/Shop.build.json"));
159
+ renamed.name = "Store";
160
+ writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(renamed, null, 2)}\n`);
161
+ await sync({ prefer: "disk" });
162
+ assert.ok(studio.trees.has("StarterGui.Store") && !studio.trees.has("StarterGui.Shop"), [...studio.trees.keys()].join(","));
163
+ await sync();
164
+ renamed.name = "Shop";
165
+ writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(renamed, null, 2)}\n`);
166
+ await sync({ prefer: "disk" });
167
+ assert.ok(studio.trees.has("StarterGui.Shop") && !studio.trees.has("StarterGui.Store"));
168
+ await sync();
169
+
170
+ // A tree deleted in Studio sends its unchanged file to the trash.
171
+ studio.trees.delete("StarterGui.Shop");
172
+ report = await sync();
173
+ assert.ok(!existsSync(file("StarterGui/Shop.build.json")));
174
+ const trash = readdirSync(file(".rbx-sync/trash"));
175
+ assert.ok(existsSync(path.join(file(".rbx-sync/trash"), trash[0], "StarterGui", "Shop.build.json")));
176
+ rmSync(dir, { recursive: true, force: true });
177
+ console.log("sync builds: export, file -> tree, tree -> file, conflict with sidecar, settle, explicit build guard, delete");
178
+ }
179
+
180
+ // Watch ---------------------------------------------------------------------
181
+ {
182
+ const studio = fakeStudio();
183
+ studio.scripts.set("ServerScriptService.Live", { className: "ModuleScript", source: "return 0\n" });
184
+ const { state } = await startWatch(studio, { dir, direction: "both" });
185
+ const scansAfterStart = studio.calls.filter((op) => op === "sync.scan").length;
186
+
187
+ writeFileSync(file("ServerScriptService/Live.luau"), "return 1\n");
188
+ assert.ok(await until(() => studio.scripts.get("ServerScriptService.Live").source === "return 1\n"), "file edit reaches Studio");
189
+ // Sync's own write comes back as a Studio edit event: it must be recognised
190
+ // as an echo, not answered with a second full sync.
191
+ assert.ok(await until(() => state.echoes >= 1), "the echo is skipped");
192
+ const cyclesAfterPush = state.cycles;
193
+
194
+ studio.edit("ServerScriptService.Live", "return 2\n");
195
+ assert.ok(await until(() => read("ServerScriptService/Live.luau") === "return 2\n"), "Studio edit reaches the file");
196
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
197
+ assert.ok(state.cycles <= cyclesAfterPush + 2, `settles after a change (${state.cycles - cyclesAfterPush} syncs)`);
198
+ const scans = studio.calls.filter((op) => op === "sync.scan").length - scansAfterStart;
199
+ assert.ok(scans <= state.cycles, "Studio is scanned only when a sync runs");
200
+
201
+ // Idle: no syncs at all.
202
+ const idle = state.cycles;
203
+ await new Promise((resolve) => setTimeout(resolve, 2_500));
204
+ assert.equal(state.cycles, idle, "nothing changed, nothing ran");
205
+
206
+ // Studio restarts on the same place: the watch follows it.
207
+ studio.restart();
208
+ writeFileSync(file("ServerScriptService/Live.luau"), "return 'after restart'\n");
209
+ assert.ok(await until(() => studio.scripts.get("ServerScriptService.Live").source === "return 'after restart'\n"), "watch reattached");
210
+ assert.ok(takeNotices()?.includes("reconnected"), "and said so");
211
+
212
+ // A conflict while watching reaches the agent on its next reply.
213
+ takeNotices();
214
+ writeFileSync(file("ServerScriptService/Live.luau"), "return 'disk'\n");
215
+ studio.edit("ServerScriptService.Live", "return 'studio'\n");
216
+ assert.ok(await until(() => state.conflicts.length === 1), "conflict found");
217
+ const notice = takeNotices();
218
+ assert.ok(notice?.includes("conflict in ServerScriptService/Live.luau"), notice);
219
+ assert.equal(takeNotices(), undefined, "said once");
220
+ assert.ok(watchState(dir));
221
+ stopWatch(dir);
222
+ assert.equal(watchState(dir), null);
223
+ console.log(`sync watch: both directions, echoes skipped (${state.echoes}), idle is silent, conflicts become notices`);
224
+ }
225
+ } finally {
226
+ stopAllWatches();
227
+ rmSync(sandbox, { recursive: true, force: true });
228
+ }