@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.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/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/remote.js +9 -3
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +2 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/lib/timeout.js +13 -0
- package/dist/lib/timeout.js.map +1 -0
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +13 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +8 -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/package.json +75 -75
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +37 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +69 -0
- package/scripts/test-live.mjs +283 -115
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +162 -39
package/scripts/test-bridge.mjs
CHANGED
|
@@ -365,4 +365,41 @@ function twoStudios() {
|
|
|
365
365
|
await server.close();
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
// Normalize deadlines at the owner, even when the caller isn't input or a current peer.
|
|
369
|
+
{
|
|
370
|
+
const { normalizeTimeoutMs } = await import("../dist/lib/timeout.js");
|
|
371
|
+
assert.equal(normalizeTimeoutMs(78394.99999999999), 78395);
|
|
372
|
+
assert.equal(normalizeTimeoutMs(91613.1, 10000), 101614);
|
|
373
|
+
assert.equal(normalizeTimeoutMs(0.05), 1);
|
|
374
|
+
assert.equal(normalizeTimeoutMs(2 ** 31 - 1), 2 ** 31 - 1);
|
|
375
|
+
assert.throws(() => normalizeTimeoutMs(2 ** 31 - 1, 10000), {code:"BAD_TIMEOUT"});
|
|
376
|
+
const bridge = new Bridge();
|
|
377
|
+
bridge.attach(identity("timeout-test", 1), null);
|
|
378
|
+
const originalTimer = globalThis.setTimeout;
|
|
379
|
+
const delays = [];
|
|
380
|
+
try {
|
|
381
|
+
globalThis.setTimeout = (callback, delay, ...args) => {
|
|
382
|
+
delays.push(delay);
|
|
383
|
+
return originalTimer(callback, delay, ...args);
|
|
384
|
+
};
|
|
385
|
+
for (const timeoutMs of [78394.99999999999, 91613.1, 95762.99999999994, 15000, undefined]) {
|
|
386
|
+
const result = bridge.call("studio.ping", {}, {clientId:"test",studioId:"timeout-test",timeoutMs});
|
|
387
|
+
assert.equal(delays.at(-1), Math.ceil(timeoutMs ?? 15000));
|
|
388
|
+
const command = await bridge.waitForCommand("timeout-test");
|
|
389
|
+
bridge.settle("timeout-test", {id:command.id,ok:true,data:"ok"});
|
|
390
|
+
assert.equal(await result, "ok");
|
|
391
|
+
}
|
|
392
|
+
const before = delays.length;
|
|
393
|
+
for (const timeoutMs of [NaN, Infinity, -Infinity, -1, 0, null, "1000", {}, 2 ** 31, Number.MAX_SAFE_INTEGER]) {
|
|
394
|
+
assert.throws(() => bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
395
|
+
}
|
|
396
|
+
assert.equal(delays.length, before, "invalid deadlines never create timers");
|
|
397
|
+
assert.equal(bridge.sessions.get("timeout-test").pending.size, 0);
|
|
398
|
+
assert.equal(bridge.sessions.get("timeout-test").queue.length, 0);
|
|
399
|
+
} finally {
|
|
400
|
+
globalThis.setTimeout = originalTimer;
|
|
401
|
+
bridge.detach("timeout-test");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
368
405
|
process.stdout.write("bridge: ok\n");
|
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");
|
|
@@ -239,4 +239,73 @@ function handshake(port, studioId) {
|
|
|
239
239
|
await new Promise((resolve) => halfDead.close(resolve));
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Issue #4: the real input tool crosses peer -> owner -> fake Studio over HTTP.
|
|
243
|
+
{
|
|
244
|
+
const { z } = await import("zod");
|
|
245
|
+
const { registerInputTools } = await import("../dist/tools/input.js");
|
|
246
|
+
const owner = await startBridgeServer({port:PORT});
|
|
247
|
+
const peer = await startBridgeServer({port:PORT});
|
|
248
|
+
const headers = {[CLIENT_HEADER]:"test", "Content-Type":"application/json"};
|
|
249
|
+
const originalSignal = AbortSignal.timeout;
|
|
250
|
+
const signalDelays = [];
|
|
251
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
252
|
+
const reply = async () => {
|
|
253
|
+
const response = await fetch(`${base}/poll?studioId=decimal`, {headers,signal:originalSignal(5000)});
|
|
254
|
+
const {command} = await response.json();
|
|
255
|
+
assert.ok(command, "the command reaches Studio");
|
|
256
|
+
await fetch(`${base}/result?studioId=decimal`, {method:"POST",headers,
|
|
257
|
+
body:JSON.stringify({id:command.id,ok:true,data:{delivered:true,steps:command.params.steps?.length ?? 0,player:"Test"}})});
|
|
258
|
+
return command;
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
await handshake(PORT,"decimal");
|
|
262
|
+
assert.equal(peer.owner,false);
|
|
263
|
+
AbortSignal.timeout = delay => { signalDelays.push(delay); return originalSignal(delay); };
|
|
264
|
+
let tool;
|
|
265
|
+
registerInputTools({bridge:peer.bridge,server:{registerTool(name,spec,handler) {tool={spec,handler};}}});
|
|
266
|
+
const steps = [{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))];
|
|
267
|
+
const [result, command] = await Promise.all([
|
|
268
|
+
tool.handler(z.object(tool.spec.inputSchema).parse({steps,studioId:"decimal"})), reply(),
|
|
269
|
+
]);
|
|
270
|
+
assert.ok(!result.isError, JSON.stringify(result));
|
|
271
|
+
assert.deepEqual(command.params.steps,steps);
|
|
272
|
+
assert.ok(signalDelays.includes(88460), "input budget plus existing 10s peer padding");
|
|
273
|
+
|
|
274
|
+
const [other] = await Promise.all([
|
|
275
|
+
peer.bridge.call("another.tool", {}, {studioId:"decimal",timeoutMs:91613.1}), reply(),
|
|
276
|
+
]);
|
|
277
|
+
assert.ok(other.delivered);
|
|
278
|
+
assert.ok(signalDelays.includes(101614), "other tools are normalized at the peer boundary");
|
|
279
|
+
|
|
280
|
+
// Older/non-normalizing peers may send decimals directly to the owner.
|
|
281
|
+
const [raw] = await Promise.all([
|
|
282
|
+
fetch(`${base}/call`, {method:"POST",headers,body:JSON.stringify({op:"another.tool",studioId:"decimal",timeoutMs:95762.99999999994})}).then(res => res.json()), reply(),
|
|
283
|
+
]);
|
|
284
|
+
assert.equal(raw.ok,true);
|
|
285
|
+
for (const timeoutMs of [null, "1000", 0, -1, 2 ** 31]) {
|
|
286
|
+
const response = await fetch(`${base}/call`, {method:"POST",headers,body:JSON.stringify({op:"studio.ping",studioId:"decimal",timeoutMs})});
|
|
287
|
+
const error = await response.json();
|
|
288
|
+
assert.equal(error.error.code,"BAD_TIMEOUT", "owner rejects invalid wire values without crashing");
|
|
289
|
+
}
|
|
290
|
+
for (const timeoutMs of [NaN, Infinity, -1, 0, 2 ** 31 - 1]) {
|
|
291
|
+
await assert.rejects(() => peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
292
|
+
}
|
|
293
|
+
// A locally thrown AbortSignal error must not trigger failover or blame the owner.
|
|
294
|
+
const localError = new RangeError("local AbortSignal setup failure");
|
|
295
|
+
AbortSignal.timeout = delay => { if (delay === 10001) throw localError; return originalSignal(delay); };
|
|
296
|
+
await assert.rejects(() => peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs:1}), cause => cause === localError);
|
|
297
|
+
AbortSignal.timeout = originalSignal;
|
|
298
|
+
const [healthy] = await Promise.all([
|
|
299
|
+
peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs:1000}), reply(),
|
|
300
|
+
]);
|
|
301
|
+
assert.ok(healthy.delivered, "owner still answers after every rejected call");
|
|
302
|
+
assert.equal(owner.owner,true);
|
|
303
|
+
assert.equal(peer.owner,false);
|
|
304
|
+
} finally {
|
|
305
|
+
AbortSignal.timeout = originalSignal;
|
|
306
|
+
await peer.close();
|
|
307
|
+
await owner.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
242
311
|
process.stdout.write("failover: ok\n");
|
package/scripts/test-live.mjs
CHANGED
|
@@ -1,115 +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]
|
|
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 }),
|
|
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 =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
check(
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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);
|