@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.
- package/README.md +24 -6
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Commands.luau +72 -5
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +5 -0
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/Prompt.luau +23 -3
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +47 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +17 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- 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:
|
|
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 });
|
package/scripts/test-plugin.mjs
CHANGED
|
@@ -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
|
+
}
|