@el4cteo/rbx-studio-mcp 0.7.1 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/api.js +8 -8
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/rpc.js +18 -18
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +17 -21
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/opencloud.js +7 -7
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/tool.js +7 -0
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +11 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +7 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/scripts.js +55 -12
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +0 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -74
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +21 -21
- package/plugin/src/Emulation.luau +8 -8
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/ScriptEdit.luau +14 -14
- package/plugin/src/Secret.luau +0 -9
- package/plugin/src/Transport.luau +58 -1
- package/plugin/src/Visuals.luau +8 -8
- package/plugin/src/handlers/Anim.luau +50 -50
- package/plugin/src/handlers/Character.luau +8 -8
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Discover.luau +11 -11
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Instances.luau +5 -5
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/handlers/Scripts.luau +68 -19
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +40 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +107 -99
- package/scripts/test-live.mjs +283 -0
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +151 -0
- package/scripts/test-transport.mjs +71 -58
package/scripts/test-bridge.mjs
CHANGED
|
@@ -322,6 +322,46 @@ function twoStudios() {
|
|
|
322
322
|
second.destroy();
|
|
323
323
|
await settle();
|
|
324
324
|
assert.equal(await count(), 0, "a stream that dies without a goodbye is dropped");
|
|
325
|
+
|
|
326
|
+
// A command far bigger than one socket read goes down the stream as ONE
|
|
327
|
+
// event, and its answer comes back. The plugin half of this -- joining the
|
|
328
|
+
// reads back together -- is covered by the frameReader cases in
|
|
329
|
+
// tests/sseframes.luau; this pins the bridge half over real sockets.
|
|
330
|
+
const source = "local x = 1\n".repeat(100_000);
|
|
331
|
+
const events = [];
|
|
332
|
+
const plugin = await new Promise((resolve) => {
|
|
333
|
+
const req = request(
|
|
334
|
+
{ host: "127.0.0.1", port, path: "/events", method: "POST", headers: { "x-roblox-studio-mcp": "test" } },
|
|
335
|
+
(res) => {
|
|
336
|
+
let pending = "";
|
|
337
|
+
res.setEncoding("utf8");
|
|
338
|
+
res.on("data", (chunk) => {
|
|
339
|
+
pending += chunk;
|
|
340
|
+
let cut;
|
|
341
|
+
while ((cut = pending.indexOf("\n\n")) !== -1) {
|
|
342
|
+
const block = pending.slice(0, cut);
|
|
343
|
+
pending = pending.slice(cut + 2);
|
|
344
|
+
if (!block.startsWith("data: ")) continue;
|
|
345
|
+
const frame = JSON.parse(block.slice(6));
|
|
346
|
+
if (typeof frame.id !== "string") continue;
|
|
347
|
+
events.push(frame);
|
|
348
|
+
void fetch(`http://127.0.0.1:${port}/result?studioId=big`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "x-roblox-studio-mcp": "test" },
|
|
351
|
+
body: JSON.stringify({ id: frame.id, ok: true, data: { length: frame.params.source.length } }),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
resolve(req);
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
req.end(JSON.stringify({ studioId: "big", placeName: "p", placeId: 1 }));
|
|
359
|
+
});
|
|
360
|
+
const answer = await server.bridge.call("script.create", { source }, { studioId: "big" });
|
|
361
|
+
assert.equal(answer.length, source.length, "a 1.2MB command arrives whole and is answered");
|
|
362
|
+
assert.equal(events.length, 1, "as exactly one event");
|
|
363
|
+
plugin.destroy();
|
|
364
|
+
|
|
325
365
|
await server.close();
|
|
326
366
|
}
|
|
327
367
|
|
package/scripts/test-console.mjs
CHANGED
|
@@ -590,3 +590,28 @@ if (process.platform === "win32") {
|
|
|
590
590
|
}
|
|
591
591
|
|
|
592
592
|
process.stdout.write(`console: ${checks} checks pass\n`);
|
|
593
|
+
|
|
594
|
+
// OFF stops all detected test contexts through the existing control operation.
|
|
595
|
+
{
|
|
596
|
+
const sessions = ["edit", "server", "run", "idle"].map(studioId => ({studioId,placeName:studioId}));
|
|
597
|
+
const calls = [];
|
|
598
|
+
const bridge = {
|
|
599
|
+
list: () => sessions,
|
|
600
|
+
call: async (op, params, options) => {
|
|
601
|
+
assert.equal(op, "playtest.control");
|
|
602
|
+
calls.push({params, options});
|
|
603
|
+
const id = options.studioId;
|
|
604
|
+
return {state:{isEdit:id === "edit" || id === "idle",isRunning:id === "server" || id === "run",isRunMode:id === "run",testPending:id === "edit",editModeActive:id !== "edit"}};
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
const request = {studioId:"edit",command:"playtests",args:["off"],line:"playtests off"};
|
|
608
|
+
const rows = await handleConsole(bridge,44755,request);
|
|
609
|
+
assert.deepEqual(rows, []);
|
|
610
|
+
const stops = calls.filter(call => call.params.op !== "state");
|
|
611
|
+
assert.deepEqual(stops.map(call => [call.options.studioId,call.params.op]), [["edit","stop"],["server","endTest"],["run","stop"]]);
|
|
612
|
+
const before = calls.length;
|
|
613
|
+
const refused = await handleConsole(bridge,44755,{...request,args:["on"]});
|
|
614
|
+
assert.equal(refused[0].level, "error");
|
|
615
|
+
assert.equal(calls.length,before,"bridge panel endpoint cannot enable or start playtests");
|
|
616
|
+
}
|
|
617
|
+
process.stdout.write("panel playtest stop routing: ok\n");
|
|
@@ -20,7 +20,15 @@ import { startBridgeServer } from "../dist/bridge/server.js";
|
|
|
20
20
|
import { CLIENT_HEADER } from "../dist/lib/protocol.js";
|
|
21
21
|
import { PEER_HEADER } from "../dist/bridge/remote.js";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// A free port, not a fixed one: a fixed port fails the whole suite whenever
|
|
24
|
+
// anything else on the machine happens to hold it.
|
|
25
|
+
const PORT = await new Promise((resolve, reject) => {
|
|
26
|
+
const probe = createServer().listen(0, "127.0.0.1", () => {
|
|
27
|
+
const { port } = probe.address();
|
|
28
|
+
probe.close(() => resolve(port));
|
|
29
|
+
});
|
|
30
|
+
probe.once("error", reject);
|
|
31
|
+
});
|
|
24
32
|
|
|
25
33
|
/** Waits for `check` to hold, or gives up loudly rather than hanging the suite. */
|
|
26
34
|
async function until(check, what, timeoutMs = 20_000) {
|
|
@@ -133,102 +141,102 @@ function handshake(port, studioId) {
|
|
|
133
141
|
await owner.close();
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
// POST /hello carries the panel's spawned marker all the way to the roster.
|
|
137
|
-
//
|
|
138
|
-
// The shipped bug: every layer marked the agent the panel starts -- the spawn's
|
|
139
|
-
// environment, the MCP config handed to the agent, the hello body -- and this
|
|
140
|
-
// route typed the body with three fields and dropped the fourth. So the panel
|
|
141
|
-
// said "2 MCP clients connected" on every prompt and kept a stopped agent in
|
|
142
|
-
// `clients` until the stale sweep. Tested at the route, because the route is
|
|
143
|
-
// the layer that was wrong while the bridge underneath it was right.
|
|
144
|
-
{
|
|
145
|
-
const owner = await startBridgeServer({ port: PORT });
|
|
146
|
-
|
|
147
|
-
const hello = async (id, body) => {
|
|
148
|
-
const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
|
|
149
|
-
method: "POST",
|
|
150
|
-
headers: {
|
|
151
|
-
[CLIENT_HEADER]: "test",
|
|
152
|
-
[PEER_HEADER]: id,
|
|
153
|
-
"Content-Type": "application/json",
|
|
154
|
-
},
|
|
155
|
-
body: JSON.stringify(body),
|
|
156
|
-
});
|
|
157
|
-
return (await sent.json()).clients;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
|
|
161
|
-
const withAgent = await hello("panel-agent", {
|
|
162
|
-
name: "claude-code",
|
|
163
|
-
version: "1",
|
|
164
|
-
pid: 2,
|
|
165
|
-
spawned: true,
|
|
166
|
-
});
|
|
167
|
-
assert.equal(withAgent, alone, "an agent the panel started is not another client");
|
|
168
|
-
// A second stranger does count, or the filter would be hiding everyone.
|
|
169
|
-
const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
|
|
170
|
-
assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
|
|
171
|
-
|
|
172
|
-
// A keepalive that says nothing must not erase what the first hello said, and
|
|
173
|
-
// must not re-announce the client as if it had just arrived.
|
|
174
|
-
assert.equal(
|
|
175
|
-
await hello("a-stranger", { pid: 1 }),
|
|
176
|
-
withStranger,
|
|
177
|
-
"a nameless keepalive is not a new client",
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
await owner.close();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
|
|
184
|
-
//
|
|
185
|
-
// The shipped bug: only the fetch was guarded, so a socket cut after the
|
|
186
|
-
// headers threw "TypeError: fetch failed / SocketError: other side closed"
|
|
187
|
-
// straight past the catch. It surfaced right under the takeover notice, which
|
|
188
|
-
// is the exact moment a handover cuts a live response.
|
|
189
|
-
//
|
|
190
|
-
// The fake owner declares a Content-Length it never delivers, then hangs up.
|
|
191
|
-
// That distinction is the whole test: a socket destroyed BEFORE the headers
|
|
192
|
-
// makes Node reject the fetch itself, which the old code already caught -- the
|
|
193
|
-
// bug only shows when the reply has started and stops halfway. Two earlier
|
|
194
|
-
// versions of this test cut the socket too early, passed with the bug present,
|
|
195
|
-
// and proved nothing.
|
|
196
|
-
{
|
|
197
|
-
const halfDead = createServer((req, res) => {
|
|
198
|
-
if (req.url === "/identity") {
|
|
199
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
200
|
-
res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
|
|
204
|
-
res.write('{"ok":true,"data":"' + "x".repeat(100));
|
|
205
|
-
setTimeout(() => res.socket.destroy(), 150);
|
|
206
|
-
});
|
|
207
|
-
await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
|
|
208
|
-
|
|
209
|
-
const peer = await startBridgeServer({ port: PORT });
|
|
210
|
-
assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
|
|
211
|
-
|
|
212
|
-
let raised;
|
|
213
|
-
try {
|
|
214
|
-
await peer.bridge.call("studio.status", {});
|
|
215
|
-
} catch (cause) {
|
|
216
|
-
raised = cause;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
assert.ok(raised, "a truncated answer must not resolve as success");
|
|
220
|
-
assert.equal(
|
|
221
|
-
raised.code,
|
|
222
|
-
"OWNER_GONE",
|
|
223
|
-
`a body cut short is the owner going away, not a raw ${raised?.name}`,
|
|
224
|
-
);
|
|
225
|
-
assert.ok(
|
|
226
|
-
/try the call again/.test(raised.hint ?? ""),
|
|
227
|
-
"and says what to do about it, which a raw socket error never does",
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
await peer.close();
|
|
231
|
-
await new Promise((resolve) => halfDead.close(resolve));
|
|
232
|
-
}
|
|
233
|
-
|
|
144
|
+
// POST /hello carries the panel's spawned marker all the way to the roster.
|
|
145
|
+
//
|
|
146
|
+
// The shipped bug: every layer marked the agent the panel starts -- the spawn's
|
|
147
|
+
// environment, the MCP config handed to the agent, the hello body -- and this
|
|
148
|
+
// route typed the body with three fields and dropped the fourth. So the panel
|
|
149
|
+
// said "2 MCP clients connected" on every prompt and kept a stopped agent in
|
|
150
|
+
// `clients` until the stale sweep. Tested at the route, because the route is
|
|
151
|
+
// the layer that was wrong while the bridge underneath it was right.
|
|
152
|
+
{
|
|
153
|
+
const owner = await startBridgeServer({ port: PORT });
|
|
154
|
+
|
|
155
|
+
const hello = async (id, body) => {
|
|
156
|
+
const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
[CLIENT_HEADER]: "test",
|
|
160
|
+
[PEER_HEADER]: id,
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
return (await sent.json()).clients;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
|
|
169
|
+
const withAgent = await hello("panel-agent", {
|
|
170
|
+
name: "claude-code",
|
|
171
|
+
version: "1",
|
|
172
|
+
pid: 2,
|
|
173
|
+
spawned: true,
|
|
174
|
+
});
|
|
175
|
+
assert.equal(withAgent, alone, "an agent the panel started is not another client");
|
|
176
|
+
// A second stranger does count, or the filter would be hiding everyone.
|
|
177
|
+
const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
|
|
178
|
+
assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
|
|
179
|
+
|
|
180
|
+
// A keepalive that says nothing must not erase what the first hello said, and
|
|
181
|
+
// must not re-announce the client as if it had just arrived.
|
|
182
|
+
assert.equal(
|
|
183
|
+
await hello("a-stranger", { pid: 1 }),
|
|
184
|
+
withStranger,
|
|
185
|
+
"a nameless keepalive is not a new client",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await owner.close();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
|
|
192
|
+
//
|
|
193
|
+
// The shipped bug: only the fetch was guarded, so a socket cut after the
|
|
194
|
+
// headers threw "TypeError: fetch failed / SocketError: other side closed"
|
|
195
|
+
// straight past the catch. It surfaced right under the takeover notice, which
|
|
196
|
+
// is the exact moment a handover cuts a live response.
|
|
197
|
+
//
|
|
198
|
+
// The fake owner declares a Content-Length it never delivers, then hangs up.
|
|
199
|
+
// That distinction is the whole test: a socket destroyed BEFORE the headers
|
|
200
|
+
// makes Node reject the fetch itself, which the old code already caught -- the
|
|
201
|
+
// bug only shows when the reply has started and stops halfway. Two earlier
|
|
202
|
+
// versions of this test cut the socket too early, passed with the bug present,
|
|
203
|
+
// and proved nothing.
|
|
204
|
+
{
|
|
205
|
+
const halfDead = createServer((req, res) => {
|
|
206
|
+
if (req.url === "/identity") {
|
|
207
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
208
|
+
res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
|
|
212
|
+
res.write('{"ok":true,"data":"' + "x".repeat(100));
|
|
213
|
+
setTimeout(() => res.socket.destroy(), 150);
|
|
214
|
+
});
|
|
215
|
+
await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
|
|
216
|
+
|
|
217
|
+
const peer = await startBridgeServer({ port: PORT });
|
|
218
|
+
assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
|
|
219
|
+
|
|
220
|
+
let raised;
|
|
221
|
+
try {
|
|
222
|
+
await peer.bridge.call("studio.status", {});
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
raised = cause;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
assert.ok(raised, "a truncated answer must not resolve as success");
|
|
228
|
+
assert.equal(
|
|
229
|
+
raised.code,
|
|
230
|
+
"OWNER_GONE",
|
|
231
|
+
`a body cut short is the owner going away, not a raw ${raised?.name}`,
|
|
232
|
+
);
|
|
233
|
+
assert.ok(
|
|
234
|
+
/try the call again/.test(raised.hint ?? ""),
|
|
235
|
+
"and says what to do about it, which a raw socket error never does",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await peer.close();
|
|
239
|
+
await new Promise((resolve) => halfDead.close(resolve));
|
|
240
|
+
}
|
|
241
|
+
|
|
234
242
|
process.stdout.write("failover: ok\n");
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Drives a real, connected Studio through the running bridge and checks the
|
|
4
|
+
* paths no fake can: big payloads over the actual stream, and scripts that
|
|
5
|
+
* survive a create -> read -> edit -> delete round trip.
|
|
6
|
+
*
|
|
7
|
+
* Not part of `npm test`, because it needs Studio open with the plugin. It
|
|
8
|
+
* exists because the worst transport bug this project had -- any command the
|
|
9
|
+
* stream delivered in more than one piece was dropped, so a big `script_create`
|
|
10
|
+
* timed out -- passed every offline test and only showed up against Studio.
|
|
11
|
+
*
|
|
12
|
+
* Everything it makes is named `__mcp_live_*` under ServerScriptService and is
|
|
13
|
+
* deleted before it exits, pass or fail.
|
|
14
|
+
*
|
|
15
|
+
* Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000] [--client] [--remotes] [--playtests-off] [--studio-id ID] [--player NAME]
|
|
16
|
+
*/
|
|
17
|
+
import { expectedPluginBuildId } from "../dist/lib/pluginbuild.js";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flag = (name, fallback) => {
|
|
21
|
+
const at = args.indexOf(`--${name}`);
|
|
22
|
+
return at === -1 ? fallback : args[at + 1];
|
|
23
|
+
};
|
|
24
|
+
const port = Number.parseInt(flag("port", "44755"), 10);
|
|
25
|
+
const sizes = flag("sizes", "1000,16000,64000,256000").split(",").map(Number);
|
|
26
|
+
const base = `http://127.0.0.1:${port}`;
|
|
27
|
+
const HEADERS = {
|
|
28
|
+
"x-roblox-studio-mcp": "test-live",
|
|
29
|
+
"x-roblox-studio-mcp-peer": `test-live-${process.pid}`,
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
};
|
|
32
|
+
const PREFIX = "__mcp_live_";
|
|
33
|
+
|
|
34
|
+
async function call(op, params, timeoutMs = 20_000) {
|
|
35
|
+
const response = await fetch(`${base}/call`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: HEADERS,
|
|
38
|
+
body: JSON.stringify({ op, params, timeoutMs, studioId: flag("studio-id", undefined) }),
|
|
39
|
+
});
|
|
40
|
+
const body = await response.json();
|
|
41
|
+
if (!body.ok) throw new Error(`${op}: [${body.error?.code}] ${body.error?.message}`);
|
|
42
|
+
return body.data;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Luau source of about `size` bytes, with quotes and non-ASCII so escaping is exercised. */
|
|
46
|
+
function sourceOf(size) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
let length = 0;
|
|
49
|
+
for (let index = 0; length < size; index += 1) {
|
|
50
|
+
const line = `local value${index} = "line ${index}: \\"quoted\\" ünïcode ✓"`;
|
|
51
|
+
lines.push(line);
|
|
52
|
+
length += line.length + 1;
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let sessions;
|
|
58
|
+
try {
|
|
59
|
+
sessions = await (await fetch(`${base}/sessions`, { headers: HEADERS })).json();
|
|
60
|
+
} catch {
|
|
61
|
+
process.stderr.write(`Nothing is listening on port ${port}. Start the MCP server and open Studio.\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const studio = flag("studio-id", undefined)
|
|
65
|
+
? sessions.list.find((entry) => entry.studioId === flag("studio-id"))
|
|
66
|
+
: sessions.list[0];
|
|
67
|
+
if (studio === undefined) {
|
|
68
|
+
process.stderr.write("The bridge is up but no Studio is connected.\n");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (studio.buildId !== expectedPluginBuildId()) {
|
|
72
|
+
process.stderr.write(
|
|
73
|
+
`warning: Studio runs plugin build ${studio.buildId}, this checkout is ` +
|
|
74
|
+
`${expectedPluginBuildId()}. Results describe the plugin Studio has loaded.\n`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(`Studio: ${studio.placeName} (${studio.transport})\n`);
|
|
78
|
+
|
|
79
|
+
const created = [];
|
|
80
|
+
let failures = 0;
|
|
81
|
+
const check = (ok, what) => {
|
|
82
|
+
process.stdout.write(`${ok ? "ok " : "FAIL"} ${what}\n`);
|
|
83
|
+
if (!ok) failures += 1;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
for (const size of sizes) {
|
|
88
|
+
const name = `${PREFIX}${size}`;
|
|
89
|
+
const source = sourceOf(size);
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
try {
|
|
92
|
+
await call("script.create", {
|
|
93
|
+
scripts: [{ parent: "ServerScriptService", name, className: "ModuleScript", source }],
|
|
94
|
+
});
|
|
95
|
+
created.push(`ServerScriptService.${name}`);
|
|
96
|
+
const read = await call("script.read", { paths: [`ServerScriptService.${name}`] });
|
|
97
|
+
const item = read.items[0];
|
|
98
|
+
check(item?.source === source, `${size} bytes: create + read back identical (${Date.now() - started}ms)`);
|
|
99
|
+
|
|
100
|
+
const edited = await call("script.edit", {
|
|
101
|
+
edits: [{ path: `ServerScriptService.${name}`, find: "local value0 =", replace: "local first =", revision: item.revision }],
|
|
102
|
+
});
|
|
103
|
+
check(edited.items[0]?.edits === 1, `${size} bytes: conditional edit applied`);
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
check(false, `${size} bytes: ${cause.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
if (created.length > 0) {
|
|
110
|
+
await call("instances.delete", { paths: created }).catch((cause) => {
|
|
111
|
+
process.stderr.write(`cleanup failed, delete ${created.join(", ")} by hand: ${cause.message}\n`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --client requires a running playtest with this checkout's plugin loaded.
|
|
117
|
+
// --studio-id selects its server session; --player supports multiplayer tests.
|
|
118
|
+
if (args.includes("--client")) {
|
|
119
|
+
if (studio.buildId !== expectedPluginBuildId()) {
|
|
120
|
+
check(false, "client checks require the current plugin build; reload the plugin first");
|
|
121
|
+
} else {
|
|
122
|
+
const client = (source, timeoutSeconds = 5) => call("exec.run", {
|
|
123
|
+
target: "client", player: flag("player", undefined), source, timeoutSeconds,
|
|
124
|
+
}, (timeoutSeconds + 10) * 1000);
|
|
125
|
+
try {
|
|
126
|
+
const identity = await client('return game:GetService("RunService"):IsClient(), game:GetService("Players").LocalPlayer.Name');
|
|
127
|
+
check(identity.ok && identity.returned[0] === true && typeof identity.returned[1] === "string", "exec runs in the actual client VM");
|
|
128
|
+
const values = await client('print("client print"); warn("client warning"); local t = {}; t.self = t; return 1, nil, t, Vector3.new(1,2,3)');
|
|
129
|
+
check(values.ok && values.returned[1] === "nil" && values.returned[2].self === "<circular reference>", "client structured values preserve nil and cycles");
|
|
130
|
+
check(values.output.some(v => v.message.includes("client print")) && values.output.some(v => v.level === "warning"), "client prints and warnings captured");
|
|
131
|
+
const bounds = await client('for i=1,220 do print(i) end; local t = {}; for i=1,60 do t[i]=i end; return t,2,3,4,5,6,7,8,9,10,11');
|
|
132
|
+
check(bounds.ok && bounds.output.length === 200 && bounds.returned.length === 10 && bounds.returned[0].length === 51, "client output limits match Studio execution");
|
|
133
|
+
const failed = await client('print("before error"); error("client failure")');
|
|
134
|
+
check(!failed.ok && failed.error.includes("client failure") && failed.output.length > 0, "runtime failures retain output");
|
|
135
|
+
const syntax = await client('local =');
|
|
136
|
+
check(!syntax.ok && typeof syntax.error === "string", "client syntax errors are reported");
|
|
137
|
+
const timeout = await client('task.wait(30)', 1);
|
|
138
|
+
check(!timeout.ok && timeout.error.includes("CLIENT_TIMEOUT"), "yielding client execution times out cleanly");
|
|
139
|
+
await call("script.create", { scripts: [{ parent: "ReplicatedStorage", name: "__mcp_live_cache", className: "ModuleScript", source: "return {count = 0}" }] });
|
|
140
|
+
await client('local t = require(game.ReplicatedStorage.__mcp_live_cache); t.count = 42');
|
|
141
|
+
const cached = await client('return require(game.ReplicatedStorage.__mcp_live_cache).count');
|
|
142
|
+
check(cached.ok && cached.returned[0] === 42, "separate client calls share the client module cache");
|
|
143
|
+
const gui = await client(`
|
|
144
|
+
local gui = Instance.new("ScreenGui"); gui.Name = "__mcp_live_gui"; gui.ResetOnSpawn = false
|
|
145
|
+
local button = Instance.new("TextButton"); button.Name = "BuyButton"; button.Size = UDim2.fromOffset(160,60); button.Position = UDim2.fromOffset(100,100); button.Parent = gui
|
|
146
|
+
local box = Instance.new("TextBox"); box.Name = "NameBox"; box.Size = UDim2.fromOffset(160,60); box.Position = UDim2.fromOffset(100,200); box.Text = ""; box.Parent = gui
|
|
147
|
+
gui.Parent = game.Players.LocalPlayer.PlayerGui
|
|
148
|
+
return true`);
|
|
149
|
+
check(gui.ok, "client-only GUI fixture created");
|
|
150
|
+
const typed = await call("input.send", { player: flag("player", undefined), cursor: false, steps: [{kind: "text", target: "PlayerGui.__mcp_live_gui.NameBox", text: "bridge"}] });
|
|
151
|
+
const text = await client('return game.Players.LocalPlayer.PlayerGui.__mcp_live_gui.NameBox.Text');
|
|
152
|
+
check(typed.delivered && text.returned[0] === "bridge", "GUI target focuses and types into the client TextBox");
|
|
153
|
+
const clicked = await call("input.send", { player: flag("player", undefined), cursor: false, steps: [{kind: "click", target: "PlayerGui.__mcp_live_gui.BuyButton"}] });
|
|
154
|
+
const centre = await client('local b=game.Players.LocalPlayer.PlayerGui.__mcp_live_gui.BuyButton; return b.AbsolutePosition.X+b.AbsoluteSize.X/2, b.AbsolutePosition.Y+b.AbsoluteSize.Y/2');
|
|
155
|
+
check(clicked.landed && Math.abs(clicked.landed.seen.x-centre.returned[0]) < 2 && Math.abs(clicked.landed.seen.y-centre.returned[1]) < 2, "GUI click lands at the actual client centre");
|
|
156
|
+
const missing = await call("input.send", { player: flag("player", undefined), cursor: false, steps: [{kind:"click",target:"PlayerGui.__missing"}] });
|
|
157
|
+
check(missing.notes?.some(note => note.includes("not found")), "missing GUI path reports a failure");
|
|
158
|
+
const clean = await call("exec.run", { source: 'local n=0; for _,p in game.Players:GetPlayers() do for _,v in p.PlayerGui:GetChildren() do if v.Name == "MCPExecRelay" or v.Name == "MCPInputRelay" then n+=1 end end end; return n' });
|
|
159
|
+
check(clean.returned[0] === 0, "temporary relay instances are removed after success, failure and timeout");
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
check(false, `client: ${cause.message}`);
|
|
162
|
+
} finally {
|
|
163
|
+
await client('local g=game.Players.LocalPlayer.PlayerGui:FindFirstChild("__mcp_live_gui"); if g then g:Destroy() end').catch(() => {});
|
|
164
|
+
await call("instances.delete", {paths:["ReplicatedStorage.__mcp_live_cache"]}).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --remotes can run independently of GUI/input checks. The fixture's normal
|
|
170
|
+
// server Script and LocalScript remain connected before, during and after tracing.
|
|
171
|
+
if (args.includes("--remotes")) {
|
|
172
|
+
if (studio.buildId !== expectedPluginBuildId()) {
|
|
173
|
+
check(false, "remote tracing checks require the current plugin build");
|
|
174
|
+
} else {
|
|
175
|
+
const client = (source) => call("exec.run", {target:"client",player:flag("player",undefined),source,timeoutSeconds:10}, 20000);
|
|
176
|
+
const root = "ReplicatedStorage.__mcp_live_remotes";
|
|
177
|
+
const serverPath = "ServerScriptService.__mcp_live_remote_server";
|
|
178
|
+
let clientPath;
|
|
179
|
+
let capture;
|
|
180
|
+
const fixturePaths = [];
|
|
181
|
+
try {
|
|
182
|
+
const identity = await client('return game.Players.LocalPlayer.Name');
|
|
183
|
+
if (!identity.ok) throw new Error(identity.error);
|
|
184
|
+
const player = identity.returned[0];
|
|
185
|
+
clientPath = `Players.${player}.PlayerGui.__mcp_live_remote_client`;
|
|
186
|
+
const setup = await call("exec.run", {source:`
|
|
187
|
+
assert(not game.ReplicatedStorage:FindFirstChild("__mcp_live_remotes"), "fixture already exists")
|
|
188
|
+
assert(not game.ServerScriptService:FindFirstChild("__mcp_live_remote_server"), "server fixture already exists")
|
|
189
|
+
assert(not game.Players[${JSON.stringify(player)}].PlayerGui:FindFirstChild("__mcp_live_remote_client"), "client fixture already exists")
|
|
190
|
+
local folder = Instance.new("Folder"); folder.Name = "__mcp_live_remotes"; folder.Parent = game.ReplicatedStorage
|
|
191
|
+
for _, name in {"Request", "Reply"} do local r=Instance.new("RemoteEvent"); r.Name=name; r.Parent=folder end
|
|
192
|
+
return true`});
|
|
193
|
+
if (!setup.ok) throw new Error(setup.error);
|
|
194
|
+
fixturePaths.push(root, serverPath, clientPath);
|
|
195
|
+
await call("script.create", {scripts:[{parent:"ServerScriptService",name:"__mcp_live_remote_server",className:"Script",source:`
|
|
196
|
+
local root = game.ReplicatedStorage:WaitForChild("__mcp_live_remotes")
|
|
197
|
+
root:WaitForChild("Request").OnServerEvent:Connect(function(player, ...)
|
|
198
|
+
local args = table.pack(...)
|
|
199
|
+
root:SetAttribute("ServerCount", (root:GetAttribute("ServerCount") or 0) + 1)
|
|
200
|
+
root:SetAttribute("ServerValid", args.n == 3 and args[2] == nil and args[3].level == 37)
|
|
201
|
+
root.Reply:FireClient(player, args[1], nil, args[3].level)
|
|
202
|
+
end)
|
|
203
|
+
root:SetAttribute("Ready", true)`}, {parent:`Players.${player}.PlayerGui`,name:"__mcp_live_remote_client",className:"LocalScript",source:`
|
|
204
|
+
local root = game.ReplicatedStorage:WaitForChild("__mcp_live_remotes")
|
|
205
|
+
root:WaitForChild("Reply").OnClientEvent:Connect(function(...)
|
|
206
|
+
local args = table.pack(...)
|
|
207
|
+
script:SetAttribute("ClientCount", (script:GetAttribute("ClientCount") or 0) + 1)
|
|
208
|
+
script:SetAttribute("ClientValid", args.n == 3 and args[2] == nil and args[3] == 37)
|
|
209
|
+
end)
|
|
210
|
+
script:SetAttribute("Ready", true)`}]});
|
|
211
|
+
const send = (phase, count, expected, waitForTrace = false) => client(`
|
|
212
|
+
local root = game.ReplicatedStorage:WaitForChild("__mcp_live_remotes")
|
|
213
|
+
local gui = game.Players.LocalPlayer.PlayerGui
|
|
214
|
+
local handler = gui:WaitForChild("__mcp_live_remote_client")
|
|
215
|
+
local deadline = os.clock()+8
|
|
216
|
+
repeat task.wait() until (root:GetAttribute("Ready") and handler:GetAttribute("Ready")) or os.clock()>deadline
|
|
217
|
+
assert(root:GetAttribute("Ready") and handler:GetAttribute("Ready"), "fixture startup timed out")
|
|
218
|
+
${waitForTrace ? `repeat task.wait() until (gui:FindFirstChild("MCPRemoteTraceRelay") and gui.MCPRemoteTraceRelay:GetAttribute("Capturing")) or os.clock()>deadline
|
|
219
|
+
assert(gui:FindFirstChild("MCPRemoteTraceRelay") and gui.MCPRemoteTraceRelay:GetAttribute("Capturing"), "trace startup timed out")` : ""}
|
|
220
|
+
for i=1,${count} do root.Request:FireServer("${phase}", nil, {level=37, nested={more={hidden=true}}}); task.wait(0.05) end
|
|
221
|
+
repeat task.wait() until (handler:GetAttribute("ClientCount") or 0)>=${expected} or os.clock()>deadline
|
|
222
|
+
return handler:GetAttribute("ClientCount"), handler:GetAttribute("ClientValid")`);
|
|
223
|
+
const before = await send("before", 1, 1);
|
|
224
|
+
check(before.ok && before.returned[0] === 1 && before.returned[1] === true, "fixture delivers normal traffic before tracing");
|
|
225
|
+
capture = call("debug.remotes", {path:root,player,seconds:4}, 20000);
|
|
226
|
+
// Attach a rejection handler immediately while the traffic call is running.
|
|
227
|
+
capture.catch(() => {});
|
|
228
|
+
const during = await send("during", 3, 4, true);
|
|
229
|
+
const trace = await capture;
|
|
230
|
+
check(during.ok && during.returned[0] === 4 && during.returned[1] === true, "client handlers receive unchanged arguments during tracing");
|
|
231
|
+
const upstream = trace.items.find(row => row.path.endsWith(".Request") && row.direction === "client -> server");
|
|
232
|
+
const downstream = trace.items.find(row => row.path.endsWith(".Reply") && row.direction === "server -> client");
|
|
233
|
+
check(upstream?.count === 3 && downstream?.count === 3 && trace.events === 6, "trace counts both directions without counting relay traffic");
|
|
234
|
+
check(upstream?.player === player && downstream?.player === player && upstream.callsPerSecond > 0 && downstream.callsPerSecond > 0, "trace identifies the player and reports rates");
|
|
235
|
+
check(upstream?.samples.some(sample => sample.includes("nil:nil") && sample.includes("table")), "trace summarizes argument shapes and nil positions");
|
|
236
|
+
check(Buffer.byteLength(JSON.stringify(trace)) <= 12000, "trace response obeys the hard size cap");
|
|
237
|
+
const after = await send("after", 1, 5);
|
|
238
|
+
check(after.ok && after.returned[0] === 5 && after.returned[1] === true, "client handlers still work after tracing cleanup");
|
|
239
|
+
const server = await call("exec.run", {source:'local f=game.ReplicatedStorage.__mcp_live_remotes; return f:GetAttribute("ServerCount"), f:GetAttribute("ServerValid")'});
|
|
240
|
+
check(server.ok && server.returned[0] === 5 && server.returned[1] === true, "server handler received every call exactly once with unchanged arguments");
|
|
241
|
+
const clean = await call("exec.run", {source:'for _,p in game.Players:GetPlayers() do if p.PlayerGui:FindFirstChild("MCPRemoteTraceRelay") then return false end end; return true'});
|
|
242
|
+
check(clean.ok && clean.returned[0] === true, "tracing removes temporary client relay instances");
|
|
243
|
+
const again = await call("debug.remotes", {path:root,player,seconds:1}, 16000);
|
|
244
|
+
check(again.events === 0, "a later capture starts empty and stops without traffic");
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
check(false, `remotes: ${cause.message}`);
|
|
247
|
+
} finally {
|
|
248
|
+
if (capture) await capture.catch(() => {});
|
|
249
|
+
if (fixturePaths.length) await call("instances.delete", {paths:fixturePaths}).catch(cause => {
|
|
250
|
+
check(false, `remote fixture cleanup: ${cause.message}`);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Set `playtests off` in the panel first. This opt-in check never attempts a
|
|
257
|
+
// start unless Studio has confirmed OFF, and can be rerun after a Studio restart.
|
|
258
|
+
if (args.includes("--playtests-off")) {
|
|
259
|
+
try {
|
|
260
|
+
const before = await call("playtest.control", {op:"state"});
|
|
261
|
+
if (before.state.playtestsAllowed !== false) {
|
|
262
|
+
check(false, "Set playtests off in the Studio MCP panel before running --playtests-off");
|
|
263
|
+
} else {
|
|
264
|
+
for (const op of ["play", "run", "multiplayer"]) {
|
|
265
|
+
try {
|
|
266
|
+
await call("playtest.control", {op,players:2});
|
|
267
|
+
check(false, `${op} must be refused while playtests are OFF`);
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
check(cause.message.includes("PLAYTEST_DISABLED") && cause.message.includes("disabled by the user"), `${op} is blocked by the saved panel setting`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const stopped = await call("playtest.control", {op:"stop"});
|
|
273
|
+
check(stopped.state.playtestsAllowed === false, "stop remains available while OFF");
|
|
274
|
+
const state = await call("playtest.control", {op:"state"});
|
|
275
|
+
check(state.state.playtestsAllowed === false, "state remains available and preserves OFF");
|
|
276
|
+
}
|
|
277
|
+
} catch (cause) {
|
|
278
|
+
check(false, `playtest lock: ${cause.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
process.stdout.write(failures === 0 ? "live: ok\n" : `live: ${failures} failure(s)\n`);
|
|
283
|
+
process.exit(failures === 0 ? 0 : 1);
|