@el4cteo/rbx-studio-mcp 0.8.4 → 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 +20 -3
- 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/Config.luau +1 -1
- 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/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 +33 -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 +16 -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
|
@@ -44,6 +44,7 @@ local Device = require(script.handlers.Device)
|
|
|
44
44
|
local Data = require(script.handlers.Data)
|
|
45
45
|
local Api = require(script.handlers.Api)
|
|
46
46
|
local Scripts = require(script.handlers.Scripts)
|
|
47
|
+
local Sync = require(script.handlers.Sync)
|
|
47
48
|
local Session = require(script.handlers.Session)
|
|
48
49
|
|
|
49
50
|
local SETTING_PORT = "port"
|
|
@@ -196,6 +197,7 @@ Audio.register()
|
|
|
196
197
|
Exec.register()
|
|
197
198
|
Viewport.register()
|
|
198
199
|
Scripts.register()
|
|
200
|
+
Sync.register()
|
|
199
201
|
Input.register()
|
|
200
202
|
Device.register()
|
|
201
203
|
Data.register()
|
|
@@ -580,6 +582,12 @@ Commands.setup({
|
|
|
580
582
|
|
|
581
583
|
Console.setPromptChat(chat)
|
|
582
584
|
|
|
585
|
+
-- Sync reports what it changed in the log; handed a function rather than the
|
|
586
|
+
-- console itself, for the same reason `Commands` is.
|
|
587
|
+
Sync.setup(function(level, message, detail)
|
|
588
|
+
Console.log(level :: any, message, detail)
|
|
589
|
+
end)
|
|
590
|
+
|
|
583
591
|
-- The saved preset, now that there is something to paint. Done immediately
|
|
584
592
|
-- after mounting and before the first line is logged, so nothing is ever drawn
|
|
585
593
|
-- in the wrong palette.
|
|
@@ -755,7 +763,30 @@ end
|
|
|
755
763
|
and any `*Async` call does -- and serialising them would let one slow edit
|
|
756
764
|
stall every other request on the stream.
|
|
757
765
|
]]
|
|
758
|
-
|
|
766
|
+
--[[
|
|
767
|
+
Commands that run without a line in the log.
|
|
768
|
+
|
|
769
|
+
`sync watch` asks what changed twice a second for as long as it runs, and
|
|
770
|
+
`sync.log` exists to print a line of its own. Logging either call would bury
|
|
771
|
+
everything else the panel shows under a heartbeat nobody needs to see.
|
|
772
|
+
]]
|
|
773
|
+
local QUIET: { [string]: boolean } = {
|
|
774
|
+
["sync.changes"] = true,
|
|
775
|
+
["sync.log"] = true,
|
|
776
|
+
["sync.shape"] = true,
|
|
777
|
+
["sync.revisions"] = true,
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
local function onCommand(id: string, op: string, params: { [string]: any }?, deadlineMs: number?)
|
|
781
|
+
-- A sync op can also ask to be quiet: `watch` runs the same scans a manual
|
|
782
|
+
-- sync does, and logging each would print a heartbeat. What a watch
|
|
783
|
+
-- actually changes is still logged, by `sync.apply` and `sync.log`.
|
|
784
|
+
if QUIET[op] or (string.sub(op, 1, 5) == "sync." and params ~= nil and params.quiet == true) then
|
|
785
|
+
task.spawn(function()
|
|
786
|
+
Transport.sendResult(Dispatch.invoke(id, op, params, deadlineMs))
|
|
787
|
+
end)
|
|
788
|
+
return
|
|
789
|
+
end
|
|
759
790
|
task.spawn(function()
|
|
760
791
|
local startedAt = os.clock()
|
|
761
792
|
--[[
|
|
@@ -778,7 +809,7 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
|
|
|
778
809
|
]]
|
|
779
810
|
Console.beginCall(title, Phrase.kindOf(op))
|
|
780
811
|
|
|
781
|
-
local result = Dispatch.invoke(id, op, params)
|
|
812
|
+
local result = Dispatch.invoke(id, op, params, deadlineMs)
|
|
782
813
|
local milliseconds = (os.clock() - startedAt) * 1000
|
|
783
814
|
|
|
784
815
|
--[[
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Build from a clean output directory so removed tools cannot enter npm packs. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
8
|
+
const output = join(root, "dist");
|
|
9
|
+
if (existsSync(output)) {
|
|
10
|
+
// Refuse junctions/symlinks outside the exact generated-output directory.
|
|
11
|
+
if (realpathSync(output) !== output) throw new Error("dist resolves outside the expected build directory");
|
|
12
|
+
rmSync(output, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
execFileSync(process.execPath, [join(root, "node_modules", "typescript", "bin", "tsc")], { cwd: root, stdio: "inherit" });
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory Studio answering the plugin's `sync.*` ops, for the offline
|
|
3
|
+
* sync tests. It keeps the plugin's contracts -- revisions, writes and moves
|
|
4
|
+
* conditional on them, per-item results, change tracking that also reports
|
|
5
|
+
* the echo of sync's own writes -- without any of its Roblox.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { ToolError } from "../dist/lib/errors.js";
|
|
9
|
+
|
|
10
|
+
export const fingerprint = (source) => createHash("sha1").update(source).digest("hex").slice(0, 12);
|
|
11
|
+
|
|
12
|
+
/** A scan from a flat map of script paths, the way the plugin reports one. */
|
|
13
|
+
export function scanOf(scripts, folders = {}) {
|
|
14
|
+
const nodes = [];
|
|
15
|
+
const index = new Map();
|
|
16
|
+
const nodeOf = (segments, className) => {
|
|
17
|
+
const key = segments.join(".");
|
|
18
|
+
if (index.has(key)) return index.get(key);
|
|
19
|
+
const parent = segments.length > 1 ? nodeOf(segments.slice(0, -1), folders[segments.slice(0, -1).join(".")] ?? "Folder") : 0;
|
|
20
|
+
nodes.push({ path: key, name: segments.at(-1), className, parent });
|
|
21
|
+
index.set(key, nodes.length);
|
|
22
|
+
return nodes.length;
|
|
23
|
+
};
|
|
24
|
+
const items = [];
|
|
25
|
+
for (const [scriptPath, info] of Object.entries(scripts)) {
|
|
26
|
+
const node = nodeOf(scriptPath.split("."), info.className);
|
|
27
|
+
nodes[node - 1].className = info.className;
|
|
28
|
+
items.push({ node, revision: info.revision, fileSynced: info.fileSynced });
|
|
29
|
+
}
|
|
30
|
+
return { nodes, items, roots: [...new Set(Object.keys(scripts).map((key) => key.split(".")[0]))] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `{value, type}` property specs back to plain values, as Studio would store them. */
|
|
34
|
+
function untype(spec) {
|
|
35
|
+
const out = { className: spec.className, name: spec.name };
|
|
36
|
+
if (spec.properties) {
|
|
37
|
+
out.properties = Object.fromEntries(
|
|
38
|
+
Object.entries(spec.properties).map(([key, value]) => [key, value && typeof value === "object" && "value" in value ? value.value : value]),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (spec.attributes) out.attributes = spec.attributes;
|
|
42
|
+
if (spec.tags) out.tags = spec.tags;
|
|
43
|
+
if (spec.children) out.children = spec.children.map(untype);
|
|
44
|
+
for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key];
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const classesIn = (spec, into = new Set()) => {
|
|
49
|
+
into.add(spec.className);
|
|
50
|
+
for (const child of spec.children ?? []) classesIn(child, into);
|
|
51
|
+
return into;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function fakeStudio(placeId = 1) {
|
|
55
|
+
const scripts = new Map(); // path -> { className, source }
|
|
56
|
+
const trees = new Map(); // path -> build spec (plain values)
|
|
57
|
+
const dirty = new Set();
|
|
58
|
+
const logged = [];
|
|
59
|
+
const session = { studioId: "edit", placeId, placeName: "Test", context: "edit" };
|
|
60
|
+
const chain = (item) => [item.parentPath, ...(item.parents ?? []).map((link) => link.name)].filter(Boolean).join(".");
|
|
61
|
+
const calls = [];
|
|
62
|
+
|
|
63
|
+
const bridge = {
|
|
64
|
+
scripts,
|
|
65
|
+
trees,
|
|
66
|
+
logged,
|
|
67
|
+
calls,
|
|
68
|
+
/** Studio restarted: same place, new session id. */
|
|
69
|
+
restart() {
|
|
70
|
+
session.studioId = `${session.studioId}+`;
|
|
71
|
+
},
|
|
72
|
+
/** An edit made in Studio: tracked, as the plugin's events would. */
|
|
73
|
+
edit(path, source) {
|
|
74
|
+
scripts.get(path).source = source;
|
|
75
|
+
dirty.add(path);
|
|
76
|
+
},
|
|
77
|
+
async sessions() {
|
|
78
|
+
return { list: [session], activeId: null, activeIsChosen: false };
|
|
79
|
+
},
|
|
80
|
+
async call(op, params = {}, options = {}) {
|
|
81
|
+
calls.push(op);
|
|
82
|
+
if (options.studioId !== undefined && options.studioId !== session.studioId) {
|
|
83
|
+
throw new ToolError("UNKNOWN_STUDIO", `No connected Studio has id "${options.studioId}".`);
|
|
84
|
+
}
|
|
85
|
+
switch (op) {
|
|
86
|
+
case "sync.scan": {
|
|
87
|
+
const byPath = {};
|
|
88
|
+
for (const [key, value] of scripts) {
|
|
89
|
+
byPath[key] = { className: value.className, revision: params.revisions === false ? undefined : fingerprint(value.source) };
|
|
90
|
+
}
|
|
91
|
+
const scan = scanOf(byPath);
|
|
92
|
+
scan.roots = ["ServerScriptService", "ReplicatedStorage", "StarterGui"];
|
|
93
|
+
return scan;
|
|
94
|
+
}
|
|
95
|
+
case "sync.shape":
|
|
96
|
+
return { shape: [...scripts.keys()].sort().join("\n") };
|
|
97
|
+
case "sync.read":
|
|
98
|
+
return {
|
|
99
|
+
items: params.paths.map((key) =>
|
|
100
|
+
scripts.has(key)
|
|
101
|
+
? { path: key, source: scripts.get(key).source, revision: fingerprint(scripts.get(key).source), className: scripts.get(key).className }
|
|
102
|
+
: { path: key, code: "NOT_FOUND", message: "gone" },
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
case "sync.revisions":
|
|
106
|
+
return {
|
|
107
|
+
items: params.paths.map((key) =>
|
|
108
|
+
scripts.has(key) ? { path: key, revision: fingerprint(scripts.get(key).source) } : { path: key, missing: true },
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
case "sync.changes": {
|
|
112
|
+
const out = { token: "tracking", reset: params.token !== "tracking", dirty: [...dirty], structural: false };
|
|
113
|
+
dirty.clear();
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
case "sync.apply": {
|
|
117
|
+
const out = { writes: [], creates: [], deletes: [], moves: [], undoStep: "MCP sync" };
|
|
118
|
+
for (const item of params.creates ?? []) {
|
|
119
|
+
const target = `${chain(item)}.${item.name}`;
|
|
120
|
+
if (scripts.has(target)) out.creates.push({ ok: false, code: "EXISTS", message: "exists" });
|
|
121
|
+
else {
|
|
122
|
+
scripts.set(target, { className: item.className, source: item.source });
|
|
123
|
+
out.creates.push({ ok: true, path: target, revision: fingerprint(item.source) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const item of params.deletes ?? []) {
|
|
127
|
+
const current = scripts.get(item.path);
|
|
128
|
+
if (!current || fingerprint(current.source) !== item.revision) out.deletes.push({ ok: false, path: item.path, code: "STALE_SCRIPT", message: "changed" });
|
|
129
|
+
else {
|
|
130
|
+
scripts.delete(item.path);
|
|
131
|
+
out.deletes.push({ ok: true, path: item.path });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const item of params.moves ?? []) {
|
|
135
|
+
const current = scripts.get(item.path);
|
|
136
|
+
if (!current || fingerprint(current.source) !== item.revision) out.moves.push({ ok: false, from: item.path, code: "STALE_SCRIPT", message: "changed" });
|
|
137
|
+
else {
|
|
138
|
+
const target = `${chain(item)}.${item.name}`;
|
|
139
|
+
scripts.delete(item.path);
|
|
140
|
+
scripts.set(target, current);
|
|
141
|
+
out.moves.push({ ok: true, from: item.path, path: target });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const item of params.writes ?? []) {
|
|
145
|
+
const current = scripts.get(item.path);
|
|
146
|
+
if (!current || fingerprint(current.source) !== item.revision) out.writes.push({ ok: false, path: item.path, code: "STALE_SCRIPT", message: "changed" });
|
|
147
|
+
else {
|
|
148
|
+
current.source = item.source;
|
|
149
|
+
// The plugin's editor event fires for sync's own write too.
|
|
150
|
+
dirty.add(item.path);
|
|
151
|
+
out.writes.push({ ok: true, path: item.path, revision: fingerprint(item.source) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
case "sync.classes": {
|
|
157
|
+
const spec = trees.get(params.path);
|
|
158
|
+
if (!spec) throw new ToolError("NOT_FOUND", `nothing at ${params.path}`);
|
|
159
|
+
const segments = params.path.split(".");
|
|
160
|
+
return {
|
|
161
|
+
path: params.path,
|
|
162
|
+
classes: [...classesIn(spec)].sort(),
|
|
163
|
+
chain: segments.map((name, index) => ({
|
|
164
|
+
name,
|
|
165
|
+
className: index === segments.length - 1 ? spec.className : index === 0 ? name : "Folder",
|
|
166
|
+
path: segments.slice(0, index + 1).join("."),
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case "sync.export": {
|
|
171
|
+
const spec = trees.get(params.path);
|
|
172
|
+
return { path: params.path, spec: structuredClone(spec), instances: classesIn(spec).size, scripts: 0, skipped: [] };
|
|
173
|
+
}
|
|
174
|
+
case "sync.build": {
|
|
175
|
+
const target = `${params.parent}.${params.spec.name ?? params.spec.className}`;
|
|
176
|
+
if (params.replaces && params.replaces !== target) trees.delete(params.replaces);
|
|
177
|
+
trees.set(target, untype(params.spec));
|
|
178
|
+
return { path: target, carried: [] };
|
|
179
|
+
}
|
|
180
|
+
case "sync.log":
|
|
181
|
+
logged.push(...params.lines);
|
|
182
|
+
return { logged: true };
|
|
183
|
+
case "sync.stop":
|
|
184
|
+
return { stopped: true };
|
|
185
|
+
default:
|
|
186
|
+
throw new Error(`fake Studio has no ${op}`);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
return bridge;
|
|
191
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `sync` at scale, against a real Studio: thousands of scripts, same-named
|
|
4
|
+
* siblings, a source past the `.Source` limit -- timed, so a slowdown shows up
|
|
5
|
+
* as a number rather than as a watch that "feels laggy".
|
|
6
|
+
*
|
|
7
|
+
* Builds ServerStorage.__mcp_sync_scale and a temp folder, and removes both.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node scripts/test-live-sync-scale.mjs [--scripts 2000] [--port 44755] [--studio-id ID]
|
|
10
|
+
*/
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const flag = (name, fallback) => {
|
|
20
|
+
const at = args.indexOf(`--${name}`);
|
|
21
|
+
return at === -1 ? fallback : args[at + 1];
|
|
22
|
+
};
|
|
23
|
+
const COUNT = Number(flag("scripts", "2000"));
|
|
24
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
const FIXTURE = "ServerStorage.__mcp_sync_scale";
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "rbx-sync-scale-"));
|
|
27
|
+
const file = (relative) => join(dir, "ServerStorage", "__mcp_sync_scale", ...relative.split("/"));
|
|
28
|
+
const studioId = flag("studio-id");
|
|
29
|
+
|
|
30
|
+
const client = new Client({ name: "test-live-sync-scale", version: "0" });
|
|
31
|
+
await client.connect(
|
|
32
|
+
new StdioClientTransport({
|
|
33
|
+
command: process.execPath,
|
|
34
|
+
args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
|
|
35
|
+
stderr: "inherit",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
let failures = 0;
|
|
40
|
+
const timings = [];
|
|
41
|
+
async function call(name, params = {}) {
|
|
42
|
+
const started = performance.now();
|
|
43
|
+
const result = await client.callTool({ name, arguments: { ...(studioId ? { studioId } : {}), ...params } }, undefined, { timeout: 600_000 });
|
|
44
|
+
const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
45
|
+
return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
|
|
46
|
+
}
|
|
47
|
+
function report(label, good, detail = "") {
|
|
48
|
+
if (!good) failures += 1;
|
|
49
|
+
process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(48)} ${detail.replace(/\s+/g, " ").slice(0, good ? 100 : 400)}\n`);
|
|
50
|
+
}
|
|
51
|
+
async function timed(label, name, params, expect = /./) {
|
|
52
|
+
const reply = await call(name, params);
|
|
53
|
+
timings.push([label, reply.ms]);
|
|
54
|
+
report(label, !reply.isError && expect.test(reply.text), `${reply.ms}ms ${reply.text}`);
|
|
55
|
+
return reply;
|
|
56
|
+
}
|
|
57
|
+
const luau = async (source) => {
|
|
58
|
+
const reply = await call("execute_luau", { source });
|
|
59
|
+
if (reply.isError) throw new Error(reply.text);
|
|
60
|
+
return reply.text;
|
|
61
|
+
};
|
|
62
|
+
const sync = (op, extra = {}) => ({ op, dir, roots: [FIXTURE], ...extra });
|
|
63
|
+
const until = async (probe, seconds = 10) => {
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
for (;;) {
|
|
66
|
+
if (await probe()) return Date.now() - started;
|
|
67
|
+
if (Date.now() - started > seconds * 1000) return -1;
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// A place-shaped tree: 40 folders of modules with realistic bodies, two
|
|
74
|
+
// same-named siblings, and one script too long for `.Source`.
|
|
75
|
+
await luau(`
|
|
76
|
+
local old = game.ServerStorage:FindFirstChild("__mcp_sync_scale")
|
|
77
|
+
if old then old:Destroy() end
|
|
78
|
+
local fixture = Instance.new("Folder"); fixture.Name = "__mcp_sync_scale"
|
|
79
|
+
local body = string.rep("local value = math.random() * 100 -- padding to look like real code\\n", 30)
|
|
80
|
+
for f = 1, 40 do
|
|
81
|
+
local folder = Instance.new("Folder"); folder.Name = "Feature" .. f; folder.Parent = fixture
|
|
82
|
+
for s = 1, math.floor(${COUNT} / 40) do
|
|
83
|
+
local module = Instance.new("ModuleScript"); module.Name = "Module" .. s
|
|
84
|
+
module.Source = "-- " .. f .. "/" .. s .. "\\n" .. body .. "return {}\\n"
|
|
85
|
+
module.Parent = folder
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
local a = Instance.new("ModuleScript"); a.Name = "Dup"; a.Source = "return 'first'\\n"; a:SetAttribute("Which", "first"); a.Parent = fixture
|
|
89
|
+
local b = Instance.new("ModuleScript"); b.Name = "Dup"; b.Source = "return 'second'\\n"; b:SetAttribute("Which", "second"); b.Parent = fixture
|
|
90
|
+
fixture.Parent = game.ServerStorage
|
|
91
|
+
local big = Instance.new("ModuleScript"); big.Name = "Big"; big.Parent = fixture
|
|
92
|
+
game:GetService("ScriptEditorService"):UpdateSourceAsync(big, function() return string.rep("-- a long generated table row\\n", 12000) .. "return {}\\n" end)
|
|
93
|
+
return "ok"`);
|
|
94
|
+
|
|
95
|
+
await timed(`pull ${COUNT + 3} scripts`, "sync", sync("pull"), new RegExp(`${COUNT + 3} created on disk`));
|
|
96
|
+
await timed("no-op sync (nothing changed)", "sync", sync("sync"), /nothing to do/);
|
|
97
|
+
await timed("no-op sync again (revision cache warm)", "sync", sync("sync"), /nothing to do/);
|
|
98
|
+
|
|
99
|
+
writeFileSync(file("Feature7/Module3.luau"), "return 'edited'\n");
|
|
100
|
+
await timed("one file edited", "sync", sync("sync"), /1 written to Studio/);
|
|
101
|
+
const edited = await luau(`return game.ServerStorage.__mcp_sync_scale.Feature7.Module3.Source`);
|
|
102
|
+
report("the right script took it", edited.includes("edited"), edited);
|
|
103
|
+
|
|
104
|
+
// Same-named siblings: two files, each bound to its own script.
|
|
105
|
+
report("duplicate names get two files", existsSync(file("Dup.luau")) && existsSync(file("Dup~2.luau")));
|
|
106
|
+
const second = readFileSync(file("Dup~2.luau"), "utf8").includes("second") ? "Dup~2.luau" : "Dup.luau";
|
|
107
|
+
writeFileSync(file(second), "return 'second, edited'\n");
|
|
108
|
+
await timed("edit one of two same-named scripts", "sync", sync("sync"), /1 written to Studio/);
|
|
109
|
+
const which = await luau(`
|
|
110
|
+
for _, s in game.ServerStorage.__mcp_sync_scale:GetChildren() do
|
|
111
|
+
if s.Name == "Dup" and string.find(s.Source, "edited") then return s:GetAttribute("Which") end
|
|
112
|
+
end
|
|
113
|
+
return "none"`);
|
|
114
|
+
report("edit landed on the matching duplicate", which.includes("second"), which);
|
|
115
|
+
|
|
116
|
+
// Past the .Source limit: round trip through the editor path.
|
|
117
|
+
const big = readFileSync(file("Big.luau"), "utf8");
|
|
118
|
+
report("large script pulled whole", big.length > 300_000, `${big.length} chars`);
|
|
119
|
+
writeFileSync(file("Big.luau"), `${big}-- appended\n`);
|
|
120
|
+
await timed("push a 360KB script", "sync", sync("sync"), /1 written to Studio/);
|
|
121
|
+
const tail = await luau(`local s = game.ServerStorage.__mcp_sync_scale.Big.Source return string.sub(s, -12)`);
|
|
122
|
+
report("large script edit landed", tail.includes("appended"), tail);
|
|
123
|
+
|
|
124
|
+
// Watch latency at this size.
|
|
125
|
+
await timed("watch start", "sync", sync("watch"), /Watching|Already/);
|
|
126
|
+
writeFileSync(file("Feature20/Module5.luau"), "return 'watched'\n");
|
|
127
|
+
const toStudio = await until(async () => (await luau(`return game.ServerStorage.__mcp_sync_scale.Feature20.Module5.Source`)).includes("watched"));
|
|
128
|
+
timings.push(["watch: file -> Studio", toStudio]);
|
|
129
|
+
report("watch: file edit reached Studio", toStudio >= 0, `${toStudio}ms`);
|
|
130
|
+
await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_sync_scale.Feature21.Module6, function() return "return 'from studio'\\n" end) return 1`);
|
|
131
|
+
const toDisk = await until(() => readFileSync(file("Feature21/Module6.luau"), "utf8").includes("from studio"));
|
|
132
|
+
timings.push(["watch: Studio -> file", toDisk]);
|
|
133
|
+
report("watch: Studio edit reached the file", toDisk >= 0, `${toDisk}ms`);
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
135
|
+
const status = await call("sync", sync("status"));
|
|
136
|
+
report("watch settles: echoes skipped, not re-synced", /echo/.test(status.text), status.text.split("\n").slice(-1)[0]);
|
|
137
|
+
await call("sync", sync("stop"));
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
report("run", false, String(cause?.stack ?? cause));
|
|
140
|
+
} finally {
|
|
141
|
+
await call("sync", { op: "stop", dir }).catch(() => undefined);
|
|
142
|
+
await luau(`local f = game.ServerStorage:FindFirstChild("__mcp_sync_scale") if f then f:Destroy() end return 1`).catch(() => undefined);
|
|
143
|
+
rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
await client.close();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write("\ntimings\n");
|
|
148
|
+
for (const [label, ms] of timings) process.stdout.write(` ${label.padEnd(44)} ${ms}ms\n`);
|
|
149
|
+
process.stdout.write(failures === 0 ? "\nlive sync scale: ok\n" : `\nlive sync scale: ${failures} failure(s)\n`);
|
|
150
|
+
process.exit(failures === 0 ? 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);
|