@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-plugin.mjs
CHANGED
|
@@ -1,85 +1,103 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runs the plugin's unit tests outside Roblox Studio.
|
|
3
|
-
*
|
|
4
|
-
* The modules under test import their dependencies with `require(script.Parent.X)`,
|
|
5
|
-
* which only resolves inside Studio. Rather than mock the engine, this bundles
|
|
6
|
-
* the real module source with a stub for its one dependency and runs the result
|
|
7
|
-
* through the standalone Luau interpreter, so the tests exercise the shipped
|
|
8
|
-
* code rather than a copy of it.
|
|
9
|
-
*
|
|
10
|
-
* Needs the `luau` binary on PATH, in ./tools, or named by the LUAU variable.
|
|
11
|
-
* Get one from https://github.com/luau-lang/luau/releases.
|
|
12
|
-
*
|
|
13
|
-
* Usage: node scripts/test-plugin.mjs
|
|
14
|
-
*/
|
|
15
|
-
import { spawnSync } from "node:child_process";
|
|
16
|
-
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
18
|
-
import { dirname, join, resolve } from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { locateLuau, missingLuau } from "./locate-luau.mjs";
|
|
21
|
-
|
|
22
|
-
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
-
const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
|
|
24
|
-
if (luau === null) {
|
|
25
|
-
process.stderr.write(missingLuau("luau", "LUAU"));
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Modules under test, paired with the test file that exercises each. */
|
|
30
|
-
const suites = [
|
|
31
|
-
{ module: "plugin/src/
|
|
32
|
-
{ module: "plugin/src/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"",
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
process.exit(
|
|
1
|
+
/**
|
|
2
|
+
* Runs the plugin's unit tests outside Roblox Studio.
|
|
3
|
+
*
|
|
4
|
+
* The modules under test import their dependencies with `require(script.Parent.X)`,
|
|
5
|
+
* which only resolves inside Studio. Rather than mock the engine, this bundles
|
|
6
|
+
* the real module source with a stub for its one dependency and runs the result
|
|
7
|
+
* through the standalone Luau interpreter, so the tests exercise the shipped
|
|
8
|
+
* code rather than a copy of it.
|
|
9
|
+
*
|
|
10
|
+
* Needs the `luau` binary on PATH, in ./tools, or named by the LUAU variable.
|
|
11
|
+
* Get one from https://github.com/luau-lang/luau/releases.
|
|
12
|
+
*
|
|
13
|
+
* Usage: node scripts/test-plugin.mjs
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { locateLuau, missingLuau } from "./locate-luau.mjs";
|
|
21
|
+
|
|
22
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
|
|
24
|
+
if (luau === null) {
|
|
25
|
+
process.stderr.write(missingLuau("luau", "LUAU"));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Modules under test, paired with the test file that exercises each. */
|
|
30
|
+
const suites = [
|
|
31
|
+
{ module: "plugin/src/handlers/Playtest.luau", test: "tests/playtests.luau", prelude: "tests/playtests-stub.luau" },
|
|
32
|
+
{ module: "plugin/src/Commands.luau", test: "tests/playtests-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
|
|
33
|
+
{ module: "plugin/src/RemoteTrace.luau", test: "tests/remote-trace.luau", prelude: "tests/remote-trace-stub.luau" },
|
|
34
|
+
{ module: "plugin/src/ExecRuntime.luau", test: "tests/exec-runtime.luau", prelude: "tests/exec-runtime-stub.luau" },
|
|
35
|
+
{ module: "plugin/src/ClientRelay.luau", test: "tests/client-relay.luau", prelude: "tests/client-relay-stub.luau" },
|
|
36
|
+
{ module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" },
|
|
37
|
+
{ module: "plugin/src/Format.luau", test: "tests/format.luau" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stub stands in for Dispatch. It has to raise the same structured table the
|
|
42
|
+
* real one does, because the tests assert on `code` -- that contract is what the
|
|
43
|
+
* MCP server turns into an actionable error for the agent.
|
|
44
|
+
*/
|
|
45
|
+
const DISPATCH_STUB = `local Dispatch = {}
|
|
46
|
+
function Dispatch.fail(code, message, hint)
|
|
47
|
+
\terror({ code = code, message = message, hint = hint }, 0)
|
|
48
|
+
end
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
/** Drops the module's own requires; the stub above is already in scope. */
|
|
52
|
+
const stripRequires = (source) =>
|
|
53
|
+
source.replace(/^local \w+ = require\(script[^\n]*\n/gm, "");
|
|
54
|
+
|
|
55
|
+
let failures = 0;
|
|
56
|
+
|
|
57
|
+
for (const suite of suites) {
|
|
58
|
+
const moduleSource = stripRequires(readFileSync(join(root, suite.module), "utf8"));
|
|
59
|
+
const testSource = readFileSync(join(root, suite.test), "utf8");
|
|
60
|
+
|
|
61
|
+
const bundle = [
|
|
62
|
+
DISPATCH_STUB,
|
|
63
|
+
suite.prelude ? readFileSync(join(root, suite.prelude), "utf8") : "",
|
|
64
|
+
suite.dependency ? `local Playtest = (function()\n${stripRequires(readFileSync(join(root, suite.dependency), "utf8"))}\nend)()` : "",
|
|
65
|
+
"local function loadModule()",
|
|
66
|
+
moduleSource,
|
|
67
|
+
"end",
|
|
68
|
+
"local Module = loadModule()",
|
|
69
|
+
"local run = function(...)",
|
|
70
|
+
testSource,
|
|
71
|
+
"end",
|
|
72
|
+
"run(Module)",
|
|
73
|
+
"",
|
|
74
|
+
].join("\n");
|
|
75
|
+
|
|
76
|
+
const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-test-")), "bundle.luau");
|
|
77
|
+
writeFileSync(bundlePath, bundle, "utf8");
|
|
78
|
+
|
|
79
|
+
const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
|
|
80
|
+
if (result.error) {
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
`could not run '${luau}': ${result.error.message}\n` +
|
|
83
|
+
"Set LUAU to the path of a Luau interpreter.\n",
|
|
84
|
+
);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
if (result.status !== 0) {
|
|
88
|
+
process.stderr.write(`FAIL ${suite.test}\n`);
|
|
89
|
+
failures += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Embedded LocalScript bodies must compile too; the module compiler sees strings.
|
|
94
|
+
for (const name of ["Exec", "Input", "Debug"]) {
|
|
95
|
+
const source = readFileSync(join(root, `plugin/src/handlers/${name}.luau`), "utf8");
|
|
96
|
+
const relay = source.match(/\[==\[([\s\S]*?)\]==\]/)?.[1];
|
|
97
|
+
if (!relay) throw new Error(`Missing ${name} relay`);
|
|
98
|
+
const path = join(mkdtempSync(join(tmpdir(), "studio-mcp-relay-")), "compile.luau");
|
|
99
|
+
writeFileSync(path, `local chunk, err = loadstring(${JSON.stringify(relay)})\nassert(chunk, err)\n`);
|
|
100
|
+
if (spawnSync(luau, [path], {stdio:"inherit"}).status !== 0) failures += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline checks for tool output shaping that needs no Studio.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node scripts/test-tools.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { CHARACTER_LIMIT } from "../dist/lib/format.js";
|
|
8
|
+
import { clipListing, numbered } from "../dist/tools/scripts.js";
|
|
9
|
+
|
|
10
|
+
const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
|
|
11
|
+
const listing = (path, lines) =>
|
|
12
|
+
`${path} (ModuleScript, ${lines} lines)\n` +
|
|
13
|
+
numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
|
|
14
|
+
|
|
15
|
+
// Small reads pass through untouched.
|
|
16
|
+
{
|
|
17
|
+
const blocks = [listing("A", 3)];
|
|
18
|
+
assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A big read is cut on a whole line and names the exact next window.
|
|
22
|
+
{
|
|
23
|
+
const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
|
|
24
|
+
assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
|
|
25
|
+
const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
|
|
26
|
+
assert.ok(match, "names where to continue");
|
|
27
|
+
assert.equal(Number(match[2]), Number(match[1]) + 1);
|
|
28
|
+
const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
|
|
29
|
+
assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
|
|
30
|
+
assert.match(out, /1 more script\(s\) after it were not shown/);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Failures are never the part that gets clipped away.
|
|
34
|
+
{
|
|
35
|
+
const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
|
|
36
|
+
assert.ok(out.startsWith("Could not read 1 path(s)"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.stdout.write("tools: ok\n");
|
|
40
|
+
|
|
41
|
+
// Exercise the public schemas and forwarding through the real tool handlers.
|
|
42
|
+
const { z } = await import("zod");
|
|
43
|
+
const { registerExecTools } = await import("../dist/tools/exec.js");
|
|
44
|
+
const { registerInputTools } = await import("../dist/tools/input.js");
|
|
45
|
+
const registered = new Map();
|
|
46
|
+
const calls = [];
|
|
47
|
+
const context = {
|
|
48
|
+
server: { registerTool(name, spec, handler) { registered.set(name, { spec, handler }); } },
|
|
49
|
+
bridge: { async call(op, params, options) {
|
|
50
|
+
calls.push({op, params, options});
|
|
51
|
+
return op === "exec.run" ? {ok:true,returned:["nil",{answer:42}],output:[],milliseconds:1} : {delivered:true,steps:1,player:"Alice"};
|
|
52
|
+
} },
|
|
53
|
+
};
|
|
54
|
+
registerExecTools(context);
|
|
55
|
+
registerInputTools(context);
|
|
56
|
+
const execute = registered.get("execute_luau");
|
|
57
|
+
const parsed = z.object(execute.spec.inputSchema).parse({source:"return nil",target:"client",player:"Alice",timeoutSeconds:2});
|
|
58
|
+
await execute.handler(parsed);
|
|
59
|
+
assert.equal(calls.at(-1).params.target, "client");
|
|
60
|
+
assert.equal(calls.at(-1).params.player, "Alice");
|
|
61
|
+
assert.equal(calls.at(-1).options.timeoutMs, 12000);
|
|
62
|
+
await execute.handler(z.object(execute.spec.inputSchema).parse({source:"return 1"}));
|
|
63
|
+
assert.equal(calls.at(-1).params.target, "studio");
|
|
64
|
+
assert.equal(calls.at(-1).options.timeoutMs, 60000);
|
|
65
|
+
const input = registered.get("input");
|
|
66
|
+
for (const step of [{kind:"click",target:"PlayerGui.HUD.BuyButton"},{kind:"text",target:"PlayerGui.HUD.NameBox",text:"hello"},{kind:"click",x:100,y:200}]) {
|
|
67
|
+
await input.handler(z.object(input.spec.inputSchema).parse({steps:[step]}));
|
|
68
|
+
assert.deepEqual(calls.at(-1).params.steps[0], step);
|
|
69
|
+
}
|
|
70
|
+
assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
|
|
71
|
+
process.stdout.write("client tool schemas: ok\n");
|
|
72
|
+
|
|
73
|
+
const { registerDebugTools } = await import("../dist/tools/debug.js");
|
|
74
|
+
registerDebugTools(context);
|
|
75
|
+
const debug = registered.get("debug");
|
|
76
|
+
const remoteArgs = z.object(debug.spec.inputSchema).parse({op:"remotes",path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
77
|
+
await debug.handler(remoteArgs);
|
|
78
|
+
assert.equal(calls.at(-1).op, "debug.remotes");
|
|
79
|
+
assert.deepEqual(calls.at(-1).params, {path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
80
|
+
assert.equal(calls.at(-1).options.timeoutMs, 18000);
|
|
81
|
+
assert.throws(() => z.object(debug.spec.inputSchema).parse({op:"remotes",seconds:16}));
|
|
82
|
+
assert.deepEqual([...registered.keys()].sort(), ["debug", "execute_luau", "input", "viewport"]);
|
|
83
|
+
process.stdout.write("remote trace schema: ok\n");
|
|
84
|
+
|
|
85
|
+
const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
|
|
86
|
+
registerPlaytestTools(context);
|
|
87
|
+
const playtest = registered.get("playtest");
|
|
88
|
+
for (const guidance of ["AGENTS.md", "CLAUDE.md", "user instructions", "project guidance", "even when ON", "Do not bypass"]) {
|
|
89
|
+
assert.ok(playtest.spec.description.includes(guidance), `playtest guidance preserves ${guidance}`);
|
|
90
|
+
}
|
|
91
|
+
const normalCall = context.bridge.call;
|
|
92
|
+
context.bridge.call = async (op, params) => {
|
|
93
|
+
if (["play", "run", "multiplayer"].includes(params.op)) {
|
|
94
|
+
const { ToolError } = await import("../dist/lib/errors.js");
|
|
95
|
+
throw new ToolError("PLAYTEST_DISABLED", "Playtests are disabled by the user.", "Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.");
|
|
96
|
+
}
|
|
97
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
98
|
+
};
|
|
99
|
+
for (const op of ["play", "run", "multiplayer"]) {
|
|
100
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op}));
|
|
101
|
+
assert.equal(result.isError, true);
|
|
102
|
+
assert.match(result.content[0].text, /PLAYTEST_DISABLED/);
|
|
103
|
+
assert.match(result.content[0].text, /playtests on/);
|
|
104
|
+
}
|
|
105
|
+
const stateResult = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"state"}));
|
|
106
|
+
assert.ok(!stateResult.isError);
|
|
107
|
+
context.bridge.call = normalCall;
|
|
108
|
+
process.stdout.write("playtest lock errors and instruction precedence: ok\n");
|
|
109
|
+
|
|
110
|
+
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
111
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
112
|
+
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
113
|
+
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
114
|
+
registerDiscoverTools(context);
|
|
115
|
+
registerScriptTools(context);
|
|
116
|
+
registerScreenshotTools(context);
|
|
117
|
+
const editOps = [];
|
|
118
|
+
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
119
|
+
context.bridge.call = async (op, params) => {
|
|
120
|
+
editOps.push(op);
|
|
121
|
+
if (op === "playtest.control") {
|
|
122
|
+
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
123
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
124
|
+
}
|
|
125
|
+
const results = {
|
|
126
|
+
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
127
|
+
"discover.tree": {items:[],total:0,offset:0},
|
|
128
|
+
"discover.inspect": {items:[],failures:[]},
|
|
129
|
+
"script.read": {items:[],failures:[]},
|
|
130
|
+
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
131
|
+
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
132
|
+
};
|
|
133
|
+
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
134
|
+
return results[op];
|
|
135
|
+
};
|
|
136
|
+
for (const [name, args, expected] of [
|
|
137
|
+
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
138
|
+
["tree",{},"discover.tree"],
|
|
139
|
+
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
140
|
+
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
141
|
+
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
142
|
+
["screenshot",{},"capture.screenshot"],
|
|
143
|
+
]) {
|
|
144
|
+
const tool = registered.get(name);
|
|
145
|
+
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
146
|
+
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
147
|
+
assert.equal(editOps.at(-1),expected);
|
|
148
|
+
}
|
|
149
|
+
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
150
|
+
context.bridge.call = normalCall;
|
|
151
|
+
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|
|
@@ -1,58 +1,71 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Exercises the plugin's SSE frame parser outside Studio.
|
|
3
|
-
*
|
|
4
|
-
* `Transport.luau` cannot be loaded whole by the interpreter -- it reaches for
|
|
5
|
-
* HttpService at module scope -- and `parseFrames` is a local, so neither the
|
|
6
|
-
* existing bundling harness nor a plain require can get at it. It is lifted out
|
|
7
|
-
* of the real file by name instead, so this cannot quietly drift from what ships:
|
|
8
|
-
* rename or delete the function and the extraction fails the test run.
|
|
9
|
-
*
|
|
10
|
-
* The bug it covers cost a session to find and would have been invisible in
|
|
11
|
-
* review. Two SSE writes in the same tick arrive as one chunk, and the old
|
|
12
|
-
* parser JSON-decoded the whole chunk, so BOTH frames were dropped. It showed up
|
|
13
|
-
* as a client-count badge that would not come back down; the part that mattered
|
|
14
|
-
* is that a command sharing a chunk with a keepalive would have vanished the
|
|
15
|
-
* same way, losing a tool call with no error at either end.
|
|
16
|
-
*
|
|
17
|
-
* Usage: node scripts/test-transport.mjs
|
|
18
|
-
*/
|
|
19
|
-
import { spawnSync } from "node:child_process";
|
|
20
|
-
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
22
|
-
import { dirname, join, resolve } from "node:path";
|
|
23
|
-
import { fileURLToPath } from "node:url";
|
|
24
|
-
import { locateLuau, missingLuau } from "./locate-luau.mjs";
|
|
25
|
-
|
|
26
|
-
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
-
const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
|
|
28
|
-
if (luau === null) {
|
|
29
|
-
process.stderr.write(missingLuau("luau", "LUAU"));
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Lifts one top-level `local function` out of a module, body and all. */
|
|
34
|
-
function extractFunction(source, name) {
|
|
35
|
-
// Normalised first: the plugin sources are CRLF and every anchor here is LF.
|
|
36
|
-
const text = source.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10));
|
|
37
|
-
const opener = `local function ${name}(`;
|
|
38
|
-
const start = text.indexOf(opener);
|
|
39
|
-
if (start === -1) throw new Error(`${name} is not in Transport.luau any more`);
|
|
40
|
-
const closer = String.fromCharCode(10) + 'end' + String.fromCharCode(10);
|
|
41
|
-
const end = text.indexOf(closer, start);
|
|
42
|
-
if (end === -1) throw new Error(`could not find the end of ${name}`);
|
|
43
|
-
return text.slice(start, end + closer.length).replace(opener, `function ${name}(`);
|
|
44
|
-
}
|
|
45
|
-
const transport = readFileSync(join(root, "plugin", "src", "Transport.luau"), "utf8");
|
|
46
|
-
const stub = readFileSync(join(root, "tests", "jsonstub.luau"), "utf8").replace(/^return Net$/m, "");
|
|
47
|
-
const cases = readFileSync(join(root, "tests", "sseframes.luau"), "utf8");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Exercises the plugin's SSE frame parser outside Studio.
|
|
3
|
+
*
|
|
4
|
+
* `Transport.luau` cannot be loaded whole by the interpreter -- it reaches for
|
|
5
|
+
* HttpService at module scope -- and `parseFrames` is a local, so neither the
|
|
6
|
+
* existing bundling harness nor a plain require can get at it. It is lifted out
|
|
7
|
+
* of the real file by name instead, so this cannot quietly drift from what ships:
|
|
8
|
+
* rename or delete the function and the extraction fails the test run.
|
|
9
|
+
*
|
|
10
|
+
* The bug it covers cost a session to find and would have been invisible in
|
|
11
|
+
* review. Two SSE writes in the same tick arrive as one chunk, and the old
|
|
12
|
+
* parser JSON-decoded the whole chunk, so BOTH frames were dropped. It showed up
|
|
13
|
+
* as a client-count badge that would not come back down; the part that mattered
|
|
14
|
+
* is that a command sharing a chunk with a keepalive would have vanished the
|
|
15
|
+
* same way, losing a tool call with no error at either end.
|
|
16
|
+
*
|
|
17
|
+
* Usage: node scripts/test-transport.mjs
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { locateLuau, missingLuau } from "./locate-luau.mjs";
|
|
25
|
+
|
|
26
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
|
|
28
|
+
if (luau === null) {
|
|
29
|
+
process.stderr.write(missingLuau("luau", "LUAU"));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Lifts one top-level `local function` out of a module, body and all. */
|
|
34
|
+
function extractFunction(source, name) {
|
|
35
|
+
// Normalised first: the plugin sources are CRLF and every anchor here is LF.
|
|
36
|
+
const text = source.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10));
|
|
37
|
+
const opener = `local function ${name}(`;
|
|
38
|
+
const start = text.indexOf(opener);
|
|
39
|
+
if (start === -1) throw new Error(`${name} is not in Transport.luau any more`);
|
|
40
|
+
const closer = String.fromCharCode(10) + 'end' + String.fromCharCode(10);
|
|
41
|
+
const end = text.indexOf(closer, start);
|
|
42
|
+
if (end === -1) throw new Error(`could not find the end of ${name}`);
|
|
43
|
+
return text.slice(start, end + closer.length).replace(opener, `function ${name}(`);
|
|
44
|
+
}
|
|
45
|
+
const transport = readFileSync(join(root, "plugin", "src", "Transport.luau"), "utf8");
|
|
46
|
+
const stub = readFileSync(join(root, "tests", "jsonstub.luau"), "utf8").replace(/^return Net$/m, "");
|
|
47
|
+
const cases = readFileSync(join(root, "tests", "sseframes.luau"), "utf8");
|
|
48
|
+
|
|
49
|
+
/** Lifts one top-level `local NAME = ...` constant, so a lifted function can see it. */
|
|
50
|
+
function extractConstant(source, name) {
|
|
51
|
+
const match = new RegExp(`^local ${name}\\b[^\\r\\n]*`, "m").exec(source);
|
|
52
|
+
if (match === null) throw new Error(`${name} is not in Transport.luau any more`);
|
|
53
|
+
return match[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bundle = [
|
|
57
|
+
stub,
|
|
58
|
+
extractConstant(transport, "MAX_PENDING"),
|
|
59
|
+
extractFunction(transport, "parseFrames"),
|
|
60
|
+
extractFunction(transport, "frameReader"),
|
|
61
|
+
cases,
|
|
62
|
+
].join("\n");
|
|
63
|
+
const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-sse-")), "bundle.luau");
|
|
64
|
+
writeFileSync(bundlePath, bundle, "utf8");
|
|
65
|
+
|
|
66
|
+
const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
|
|
67
|
+
if (result.error) {
|
|
68
|
+
process.stderr.write(`could not run '${luau}': ${result.error.message}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
process.exit(result.status ?? 1);
|