@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-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);
|
package/scripts/test-tools.mjs
CHANGED
|
@@ -1,39 +1,162 @@
|
|
|
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");
|
|
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
|
+
// Issue #4: ordinary decimal holds/waits retain padding and yield whole milliseconds.
|
|
71
|
+
for (const steps of [
|
|
72
|
+
[{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))],
|
|
73
|
+
[{kind:"key",key:"E",hold:0.05,after:1.0651}],
|
|
74
|
+
]) {
|
|
75
|
+
await input.handler(z.object(input.spec.inputSchema).parse({steps}));
|
|
76
|
+
const raw = (35 + steps.reduce((sum, step) => sum + step.hold + step.after + 0.5, 0)) * 1000;
|
|
77
|
+
assert.equal(calls.at(-1).options.timeoutMs, Math.ceil(raw));
|
|
78
|
+
assert.ok(Number.isInteger(calls.at(-1).options.timeoutMs));
|
|
79
|
+
assert.deepEqual(calls.at(-1).params.steps, steps, "step timing itself is unchanged");
|
|
80
|
+
}
|
|
81
|
+
assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
|
|
82
|
+
process.stdout.write("client tool schemas: ok\n");
|
|
83
|
+
|
|
84
|
+
const { registerDebugTools } = await import("../dist/tools/debug.js");
|
|
85
|
+
registerDebugTools(context);
|
|
86
|
+
const debug = registered.get("debug");
|
|
87
|
+
const remoteArgs = z.object(debug.spec.inputSchema).parse({op:"remotes",path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
88
|
+
await debug.handler(remoteArgs);
|
|
89
|
+
assert.equal(calls.at(-1).op, "debug.remotes");
|
|
90
|
+
assert.deepEqual(calls.at(-1).params, {path:"ReplicatedStorage",player:"Alice",seconds:3});
|
|
91
|
+
assert.equal(calls.at(-1).options.timeoutMs, 18000);
|
|
92
|
+
assert.throws(() => z.object(debug.spec.inputSchema).parse({op:"remotes",seconds:16}));
|
|
93
|
+
assert.deepEqual([...registered.keys()].sort(), ["debug", "execute_luau", "input", "viewport"]);
|
|
94
|
+
process.stdout.write("remote trace schema: ok\n");
|
|
95
|
+
|
|
96
|
+
const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
|
|
97
|
+
registerPlaytestTools(context);
|
|
98
|
+
const playtest = registered.get("playtest");
|
|
99
|
+
for (const guidance of ["AGENTS.md", "CLAUDE.md", "user instructions", "project guidance", "even when ON", "Do not bypass"]) {
|
|
100
|
+
assert.ok(playtest.spec.description.includes(guidance), `playtest guidance preserves ${guidance}`);
|
|
101
|
+
}
|
|
102
|
+
const normalCall = context.bridge.call;
|
|
103
|
+
context.bridge.call = async (op, params) => {
|
|
104
|
+
if (["play", "run", "multiplayer"].includes(params.op)) {
|
|
105
|
+
const { ToolError } = await import("../dist/lib/errors.js");
|
|
106
|
+
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.");
|
|
107
|
+
}
|
|
108
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
109
|
+
};
|
|
110
|
+
for (const op of ["play", "run", "multiplayer"]) {
|
|
111
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op}));
|
|
112
|
+
assert.equal(result.isError, true);
|
|
113
|
+
assert.match(result.content[0].text, /PLAYTEST_DISABLED/);
|
|
114
|
+
assert.match(result.content[0].text, /playtests on/);
|
|
115
|
+
}
|
|
116
|
+
const stateResult = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"state"}));
|
|
117
|
+
assert.ok(!stateResult.isError);
|
|
118
|
+
context.bridge.call = normalCall;
|
|
119
|
+
process.stdout.write("playtest lock errors and instruction precedence: ok\n");
|
|
120
|
+
|
|
121
|
+
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
122
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
123
|
+
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
124
|
+
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
125
|
+
registerDiscoverTools(context);
|
|
126
|
+
registerScriptTools(context);
|
|
127
|
+
registerScreenshotTools(context);
|
|
128
|
+
const editOps = [];
|
|
129
|
+
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
130
|
+
context.bridge.call = async (op, params) => {
|
|
131
|
+
editOps.push(op);
|
|
132
|
+
if (op === "playtest.control") {
|
|
133
|
+
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
134
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
135
|
+
}
|
|
136
|
+
const results = {
|
|
137
|
+
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
138
|
+
"discover.tree": {items:[],total:0,offset:0},
|
|
139
|
+
"discover.inspect": {items:[],failures:[]},
|
|
140
|
+
"script.read": {items:[],failures:[]},
|
|
141
|
+
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
142
|
+
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
143
|
+
};
|
|
144
|
+
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
145
|
+
return results[op];
|
|
146
|
+
};
|
|
147
|
+
for (const [name, args, expected] of [
|
|
148
|
+
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
149
|
+
["tree",{},"discover.tree"],
|
|
150
|
+
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
151
|
+
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
152
|
+
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
153
|
+
["screenshot",{},"capture.screenshot"],
|
|
154
|
+
]) {
|
|
155
|
+
const tool = registered.get(name);
|
|
156
|
+
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
157
|
+
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
158
|
+
assert.equal(editOps.at(-1),expected);
|
|
159
|
+
}
|
|
160
|
+
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
161
|
+
context.bridge.call = normalCall;
|
|
162
|
+
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|