@el4cteo/rbx-studio-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Measures round-trip latency against a connected Studio, per transport.
|
|
4
|
+
*
|
|
5
|
+
* This exists because the README's central claim -- that pushing over SSE beats
|
|
6
|
+
* the long-polling every other Studio MCP server uses -- was written from first
|
|
7
|
+
* principles and never measured. A performance claim nobody has timed is a
|
|
8
|
+
* guess with a confident tone, which is exactly what this project criticises
|
|
9
|
+
* other servers for.
|
|
10
|
+
*
|
|
11
|
+
* Method: send `studio.ping` N times sequentially against whichever transport
|
|
12
|
+
* the plugin currently holds, and report the distribution. Sequential rather
|
|
13
|
+
* than concurrent on purpose -- an agent waits for each answer before deciding
|
|
14
|
+
* what to ask next, so serial round trips are the number that matters, and
|
|
15
|
+
* running them in parallel would measure throughput instead.
|
|
16
|
+
*
|
|
17
|
+
* `--compare` measures both transports in one run: it times whichever is in
|
|
18
|
+
* use, switches the plugin to the other, times that, and puts it back. Doing it
|
|
19
|
+
* in one run matters -- the two numbers are only comparable if the same machine
|
|
20
|
+
* measured them minutes apart rather than the favourable one being kept.
|
|
21
|
+
*
|
|
22
|
+
* Usage: node scripts/latency.mjs [--port 44755] [--count 50] [--compare]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
function flag(name, fallback) {
|
|
28
|
+
const at = args.indexOf(`--${name}`);
|
|
29
|
+
return at === -1 ? fallback : args[at + 1];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const port = Number.parseInt(flag("port", "44755"), 10);
|
|
33
|
+
const count = Number.parseInt(flag("count", "50"), 10);
|
|
34
|
+
const compare = args.includes("--compare");
|
|
35
|
+
const base = `http://127.0.0.1:${port}`;
|
|
36
|
+
const HEADERS = { "x-roblox-studio-mcp": "latency" };
|
|
37
|
+
|
|
38
|
+
/** Percentile from a sorted array, nearest-rank. */
|
|
39
|
+
function percentile(sorted, fraction) {
|
|
40
|
+
if (sorted.length === 0) return 0;
|
|
41
|
+
const rank = Math.max(0, Math.ceil(fraction * sorted.length) - 1);
|
|
42
|
+
return sorted[rank];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Asks a server that is already running to do the timing.
|
|
47
|
+
*
|
|
48
|
+
* The common case is that one is: the point of measuring is to check the claim
|
|
49
|
+
* while actually using the thing, and the alternative -- shutting down the
|
|
50
|
+
* editor's own connection so this script can take the port -- is a measurement
|
|
51
|
+
* nobody runs twice. Returns null when nothing is listening, so the caller can
|
|
52
|
+
* fall back to starting its own.
|
|
53
|
+
*/
|
|
54
|
+
async function askRunningServer() {
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetch(`${base}/latency?count=${count}`, { headers: HEADERS });
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (response.status === 404) {
|
|
62
|
+
// Something is listening but has no /latency route, which means it is an
|
|
63
|
+
// older build of this server still in memory. Worth naming, because the
|
|
64
|
+
// 404 body says only "unknown route".
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`The server on port ${port} predates this script — it has no /latency route.\n` +
|
|
67
|
+
"Restart it (in Claude Code, /mcp and reconnect) and run this again.\n",
|
|
68
|
+
);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const body = await response.json().catch(() => ({}));
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
`${body.error ?? `The server on port ${port} refused: HTTP ${response.status}.`}\n`,
|
|
75
|
+
);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
return response.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Moves the plugin to a transport and waits for it to actually get there.
|
|
83
|
+
*
|
|
84
|
+
* The switch is asynchronous by necessity -- the plugin answers first and then
|
|
85
|
+
* tears its connection down, because replying over a stream it is about to
|
|
86
|
+
* close would strand the answer. So this polls until a round trip succeeds on
|
|
87
|
+
* the new transport rather than assuming a fixed delay is long enough.
|
|
88
|
+
*/
|
|
89
|
+
async function switchTransport(mode) {
|
|
90
|
+
const response = await fetch(`${base}/transport?mode=${mode}`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: HEADERS,
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const body = await response.json().catch(() => ({}));
|
|
96
|
+
throw new Error(body.error ?? `switching to ${mode} failed: HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const deadline = Date.now() + 30_000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
102
|
+
const probe = await fetch(`${base}/latency?count=1`, { headers: HEADERS });
|
|
103
|
+
if (probe.ok) {
|
|
104
|
+
const reading = await probe.json();
|
|
105
|
+
if (reading.transport === mode) return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`the plugin did not reach ${mode} within 30s`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function main() {
|
|
112
|
+
if (compare) {
|
|
113
|
+
const first = await askRunningServer();
|
|
114
|
+
if (first === null) {
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
"--compare needs a server already running, since it drives the plugin " +
|
|
117
|
+
"between transports.\n",
|
|
118
|
+
);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const other = first.transport === "sse" ? "poll" : "sse";
|
|
123
|
+
await switchTransport(other);
|
|
124
|
+
const second = await askRunningServer();
|
|
125
|
+
// Put it back the way it was found. Leaving someone's editor on the slow
|
|
126
|
+
// path as a side effect of measuring is not a trade this script gets to
|
|
127
|
+
// make for them.
|
|
128
|
+
await switchTransport(first.transport);
|
|
129
|
+
|
|
130
|
+
const readings = { [first.transport]: first, [other]: second };
|
|
131
|
+
process.stdout.write(`${JSON.stringify(readings, null, 2)}\n`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const live = await askRunningServer();
|
|
136
|
+
if (live !== null) {
|
|
137
|
+
process.stdout.write(`${JSON.stringify(live, null, 2)}\n`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Nothing listening, so stand a bridge up and drive it directly.
|
|
142
|
+
const { startBridgeServer } = await import("../dist/bridge/server.js");
|
|
143
|
+
const server = await startBridgeServer({ port });
|
|
144
|
+
const { bridge } = server;
|
|
145
|
+
|
|
146
|
+
const deadline = Date.now() + 30_000;
|
|
147
|
+
let studios = [];
|
|
148
|
+
while (Date.now() < deadline) {
|
|
149
|
+
studios = bridge.list();
|
|
150
|
+
if (studios.length > 0) break;
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (studios.length === 0) {
|
|
155
|
+
await server.close();
|
|
156
|
+
// Written to stderr and exited non-zero: this is a measurement tool, and
|
|
157
|
+
// reporting "0ms" for a run that never happened would be worse than failing.
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
`No Studio connected on port ${port} within 30s.\n` +
|
|
160
|
+
"Open Studio with the plugin installed, then run this again.\n",
|
|
161
|
+
);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const target = studios[0];
|
|
166
|
+
const samples = [];
|
|
167
|
+
|
|
168
|
+
// One warm-up that is not recorded: the first call after an idle period pays
|
|
169
|
+
// for TCP and stream wake-up, which is real but is not what an agent's tenth
|
|
170
|
+
// call costs, and including it would flatter or penalise nothing usefully.
|
|
171
|
+
await bridge.call("studio.ping", {}, { studioId: target.studioId });
|
|
172
|
+
|
|
173
|
+
for (let index = 0; index < count; index += 1) {
|
|
174
|
+
const started = process.hrtime.bigint();
|
|
175
|
+
await bridge.call("studio.ping", {}, { studioId: target.studioId });
|
|
176
|
+
samples.push(Number(process.hrtime.bigint() - started) / 1e6);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await server.close();
|
|
180
|
+
|
|
181
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
182
|
+
const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length;
|
|
183
|
+
|
|
184
|
+
const report = {
|
|
185
|
+
transport: target.transport,
|
|
186
|
+
place: target.placeName,
|
|
187
|
+
samples: samples.length,
|
|
188
|
+
meanMs: Number(mean.toFixed(2)),
|
|
189
|
+
medianMs: Number(percentile(sorted, 0.5).toFixed(2)),
|
|
190
|
+
p95Ms: Number(percentile(sorted, 0.95).toFixed(2)),
|
|
191
|
+
minMs: Number(sorted[0].toFixed(2)),
|
|
192
|
+
maxMs: Number(sorted[sorted.length - 1].toFixed(2)),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch((cause) => {
|
|
199
|
+
process.stderr.write(`${cause instanceof Error ? cause.stack : String(cause)}\n`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds a Luau binary, the same way for every script that needs one.
|
|
3
|
+
*
|
|
4
|
+
* This is shared because it once was not. `check-plugin.mjs` looked in ./tools
|
|
5
|
+
* and `test-plugin.mjs` looked only on PATH, so on a machine with the binaries
|
|
6
|
+
* unpacked into ./tools -- which is what the README tells you to do -- one of
|
|
7
|
+
* them worked and the other reported the interpreter missing. The tests were
|
|
8
|
+
* not failing; they were not running, and saying so in a way that read like a
|
|
9
|
+
* setup problem the user had already solved.
|
|
10
|
+
*
|
|
11
|
+
* Order is: explicit environment variable, then ./tools, then PATH. The env var
|
|
12
|
+
* wins because someone who set it means it.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} envVar name of the environment variable that overrides the search
|
|
23
|
+
* @param {string[]} names candidate executable names, most specific first
|
|
24
|
+
* @returns {string | null} a command that runs, or null
|
|
25
|
+
*/
|
|
26
|
+
export function locateLuau(envVar, names) {
|
|
27
|
+
const fromEnv = process.env[envVar];
|
|
28
|
+
if (fromEnv) return fromEnv;
|
|
29
|
+
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const local = join(root, "tools", name);
|
|
32
|
+
if (existsSync(local)) return local;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
// `--help` rather than `--version`: every one of these supports it, and a
|
|
37
|
+
// binary that is present but refuses to start should not count as found.
|
|
38
|
+
const probe = spawnSync(name, ["--help"], { encoding: "utf8" });
|
|
39
|
+
if (!probe.error) return name;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The message to print when `locateLuau` comes back empty. */
|
|
46
|
+
export function missingLuau(what, envVar) {
|
|
47
|
+
return (
|
|
48
|
+
`No ${what} found. Put it on PATH or in ./tools, or set ${envVar}.\n` +
|
|
49
|
+
"Download: https://github.com/luau-lang/luau/releases\n"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emits a luau-lsp sourcemap for plugin/src.
|
|
3
|
+
*
|
|
4
|
+
* luau-lsp needs one to resolve `script.Parent.Foo` requires. Maintaining it by
|
|
5
|
+
* hand meant every new module was invisible to the type checker until someone
|
|
6
|
+
* noticed, so it is generated from the same naming rules build-plugin.mjs uses.
|
|
7
|
+
*
|
|
8
|
+
* Usage: node scripts/sourcemap.mjs [outputPath]
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const sourceDir = join(root, "plugin", "src");
|
|
16
|
+
const outputPath = resolve(process.argv[2] ?? join(root, "sourcemap.json"));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* luau-lsp resolves a sourcemap's filePaths against the working directory
|
|
20
|
+
* rather than against the sourcemap file, so they are written relative to the
|
|
21
|
+
* repo root, and always with forward slashes.
|
|
22
|
+
*/
|
|
23
|
+
const relativePath = (path) => relative(root, path).split("\\").join("/");
|
|
24
|
+
|
|
25
|
+
function buildTree(dir, name) {
|
|
26
|
+
const node = { name, className: "Folder", filePaths: [], children: [] };
|
|
27
|
+
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
29
|
+
a.name.localeCompare(b.name),
|
|
30
|
+
)) {
|
|
31
|
+
const path = join(dir, entry.name);
|
|
32
|
+
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
node.children.push(buildTree(path, entry.name));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!entry.name.endsWith(".luau")) continue;
|
|
38
|
+
|
|
39
|
+
if (entry.name === "init.server.luau") {
|
|
40
|
+
node.className = "Script";
|
|
41
|
+
node.filePaths.push(relativePath(path));
|
|
42
|
+
} else if (entry.name === "init.luau") {
|
|
43
|
+
node.className = "ModuleScript";
|
|
44
|
+
node.filePaths.push(relativePath(path));
|
|
45
|
+
} else {
|
|
46
|
+
node.children.push({
|
|
47
|
+
name: entry.name.replace(/\.luau$/, ""),
|
|
48
|
+
className: "ModuleScript",
|
|
49
|
+
filePaths: [relativePath(path)],
|
|
50
|
+
children: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
writeFileSync(outputPath, JSON.stringify(buildTree(sourceDir, "StudioMCP"), null, 2) + "\n", "utf8");
|
|
@@ -0,0 +1,82 @@
|
|
|
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 = [{ module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" }];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The stub stands in for Dispatch. It has to raise the same structured table the
|
|
34
|
+
* real one does, because the tests assert on `code` -- that contract is what the
|
|
35
|
+
* MCP server turns into an actionable error for the agent.
|
|
36
|
+
*/
|
|
37
|
+
const DISPATCH_STUB = `local Dispatch = {}
|
|
38
|
+
function Dispatch.fail(code, message, hint)
|
|
39
|
+
\terror({ code = code, message = message, hint = hint }, 0)
|
|
40
|
+
end
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/** Drops the module's own requires; the stub above is already in scope. */
|
|
44
|
+
const stripRequires = (source) =>
|
|
45
|
+
source.replace(/^local \w+ = require\(script[^\n]*\n/gm, "");
|
|
46
|
+
|
|
47
|
+
let failures = 0;
|
|
48
|
+
|
|
49
|
+
for (const suite of suites) {
|
|
50
|
+
const moduleSource = stripRequires(readFileSync(join(root, suite.module), "utf8"));
|
|
51
|
+
const testSource = readFileSync(join(root, suite.test), "utf8");
|
|
52
|
+
|
|
53
|
+
const bundle = [
|
|
54
|
+
DISPATCH_STUB,
|
|
55
|
+
"local Module = (function()",
|
|
56
|
+
moduleSource,
|
|
57
|
+
"end)()",
|
|
58
|
+
"local run = function(...)",
|
|
59
|
+
testSource,
|
|
60
|
+
"end",
|
|
61
|
+
"run(Module)",
|
|
62
|
+
"",
|
|
63
|
+
].join("\n");
|
|
64
|
+
|
|
65
|
+
const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-test-")), "bundle.luau");
|
|
66
|
+
writeFileSync(bundlePath, bundle, "utf8");
|
|
67
|
+
|
|
68
|
+
const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
|
|
69
|
+
if (result.error) {
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`could not run '${luau}': ${result.error.message}\n` +
|
|
72
|
+
"Set LUAU to the path of a Luau interpreter.\n",
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (result.status !== 0) {
|
|
77
|
+
process.stderr.write(`FAIL ${suite.test}\n`);
|
|
78
|
+
failures += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.exit(failures === 0 ? 0 : 1);
|