@el4cteo/rbx-studio-mcp 0.7.7 → 0.8.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/README.md +175 -175
- package/dist/bridge/harness.js +14 -5
- package/dist/bridge/harness.js.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +38 -23
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/format.js +43 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/pluginbuild.js +10 -0
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/lib/png.js +104 -0
- package/dist/lib/png.js.map +1 -1
- package/dist/resources.js +3 -2
- package/dist/resources.js.map +1 -1
- package/dist/tools/api.js +4 -3
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +2 -2
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/device.js +2 -2
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +7 -3
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +4 -4
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +32 -7
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +35 -13
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +34 -13
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/screenshot.js +55 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +4 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/terrain.js +3 -3
- package/dist/tools/terrain.js.map +1 -1
- package/dist/tools/world.js +8 -8
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +128 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/LogBuffer.luau +363 -277
- package/plugin/src/Paths.luau +15 -4
- package/plugin/src/Phrase.luau +1 -20
- package/plugin/src/ScriptEdit.luau +14 -0
- package/plugin/src/Serialize.luau +17 -0
- package/plugin/src/TextEdit.luau +6 -0
- package/plugin/src/handlers/Api.luau +8 -0
- package/plugin/src/handlers/Capture.luau +219 -3
- package/plugin/src/handlers/Perf.luau +913 -889
- package/plugin/src/handlers/Playtest.luau +6 -1
- package/plugin/src/init.server.luau +3 -2
- package/scripts/test-live.mjs +106 -1
- package/scripts/test-plugin.mjs +2 -0
- package/scripts/test-tools.mjs +259 -162
- package/dist/tools/anim.js +0 -159
- package/dist/tools/anim.js.map +0 -1
- package/plugin/src/handlers/Anim.luau +0 -897
|
@@ -67,13 +67,18 @@ local function editModeActive(): boolean?
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
local function state(): { [string]: any }
|
|
70
|
+
local players = {}
|
|
71
|
+
for _, player in game:GetService("Players"):GetPlayers() do
|
|
72
|
+
table.insert(players, { name = player.Name, userId = player.UserId })
|
|
73
|
+
end
|
|
70
74
|
return {
|
|
71
75
|
playtestsAllowed = Playtest.isAllowed(),
|
|
72
76
|
isEdit = RunService:IsEdit(),
|
|
73
77
|
isRunning = RunService:IsRunning(),
|
|
74
78
|
isRunMode = RunService:IsRunMode(),
|
|
75
79
|
editModeActive = editModeActive(),
|
|
76
|
-
playerCount = #
|
|
80
|
+
playerCount = #players,
|
|
81
|
+
players = if #players > 0 then players else nil,
|
|
77
82
|
testPending = pending,
|
|
78
83
|
-- Kept from the previous test rather than discarded, since whatever
|
|
79
84
|
-- EndTest returned is the only thing a test session can hand back.
|
|
@@ -17,6 +17,7 @@ local Console = require(script.Console)
|
|
|
17
17
|
local Dispatch = require(script.Dispatch)
|
|
18
18
|
local History = require(script.History)
|
|
19
19
|
local LogBuffer = require(script.LogBuffer)
|
|
20
|
+
local ClientRelay = require(script.ClientRelay)
|
|
20
21
|
local Mirror = require(script.Mirror)
|
|
21
22
|
local Phrase = require(script.Phrase)
|
|
22
23
|
local ScriptEdit = require(script.ScriptEdit)
|
|
@@ -41,7 +42,6 @@ local Viewport = require(script.handlers.Viewport)
|
|
|
41
42
|
local Input = require(script.handlers.Input)
|
|
42
43
|
local Device = require(script.handlers.Device)
|
|
43
44
|
local Data = require(script.handlers.Data)
|
|
44
|
-
local Anim = require(script.handlers.Anim)
|
|
45
45
|
local Api = require(script.handlers.Api)
|
|
46
46
|
local Scripts = require(script.handlers.Scripts)
|
|
47
47
|
local Session = require(script.handlers.Session)
|
|
@@ -130,6 +130,7 @@ end
|
|
|
130
130
|
-- is unrecoverable. This runs even in a client session that will never connect,
|
|
131
131
|
-- since the console tool reads it and connectivity is a separate question.
|
|
132
132
|
LogBuffer.start()
|
|
133
|
+
ClientRelay.startDiagnostics()
|
|
133
134
|
|
|
134
135
|
local storedPort = plugin:GetSetting(SETTING_PORT)
|
|
135
136
|
if typeof(storedPort) == "number" then
|
|
@@ -195,7 +196,6 @@ Scripts.register()
|
|
|
195
196
|
Input.register()
|
|
196
197
|
Device.register()
|
|
197
198
|
Data.register()
|
|
198
|
-
Anim.register()
|
|
199
199
|
Api.register()
|
|
200
200
|
|
|
201
201
|
--[[
|
|
@@ -887,6 +887,7 @@ button.Click:Connect(function()
|
|
|
887
887
|
end)
|
|
888
888
|
|
|
889
889
|
plugin.Unloading:Connect(function()
|
|
890
|
+
ClientRelay.stopDiagnostics()
|
|
890
891
|
Transport.stop()
|
|
891
892
|
end)
|
|
892
893
|
|
package/scripts/test-live.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Everything it makes is named `__mcp_live_*` under ServerScriptService and is
|
|
13
13
|
* deleted before it exits, pass or fail.
|
|
14
14
|
*
|
|
15
|
-
* Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000] [--client] [--remotes] [--playtests-off] [--studio-id ID] [--player NAME]
|
|
15
|
+
* Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000] [--client] [--remotes] [--playtest-start] [--playtest-op play|multiplayer] [--stale-cursor CURSOR] [--playtests-off] [--studio-id ID] [--player NAME]
|
|
16
16
|
*/
|
|
17
17
|
import { expectedPluginBuildId } from "../dist/lib/pluginbuild.js";
|
|
18
18
|
|
|
@@ -42,6 +42,16 @@ async function call(op, params, timeoutMs = 20_000) {
|
|
|
42
42
|
return body.data;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
async function callTo(studioId, op, params, timeoutMs = 20_000) {
|
|
46
|
+
const response = await fetch(`${base}/call`, {
|
|
47
|
+
method: "POST", headers: HEADERS,
|
|
48
|
+
body: JSON.stringify({ op, params, timeoutMs, studioId }),
|
|
49
|
+
});
|
|
50
|
+
const body = await response.json();
|
|
51
|
+
if (!body.ok) throw new Error(`${op}: [${body.error?.code}] ${body.error?.message}`);
|
|
52
|
+
return body.data;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
/** Luau source of about `size` bytes, with quotes and non-ASCII so escaping is exercised. */
|
|
46
56
|
function sourceOf(size) {
|
|
47
57
|
const lines = [];
|
|
@@ -125,6 +135,40 @@ if (args.includes("--client")) {
|
|
|
125
135
|
try {
|
|
126
136
|
const identity = await client('return game:GetService("RunService"):IsClient(), game:GetService("Players").LocalPlayer.Name');
|
|
127
137
|
check(identity.ok && identity.returned[0] === true && typeof identity.returned[1] === "string", "exec runs in the actual client VM");
|
|
138
|
+
const playerName = identity.returned[1];
|
|
139
|
+
const baseline = await call("perf.console", {target:"client",player:playerName,limit:10});
|
|
140
|
+
check(baseline.capturing === true && typeof baseline.nextCursor === "string", "persistent client diagnostics relay is ready");
|
|
141
|
+
const diagnosticPath = `Players.${playerName}.PlayerGui.__mcp_live_diagnostics`;
|
|
142
|
+
try {
|
|
143
|
+
await call("script.create", {scripts:[{parent:`Players.${playerName}.PlayerGui`,name:"__mcp_live_diagnostics",className:"LocalScript",source:'print("mcp live client print"); warn("mcp live client warning"); error("mcp live client error")'}]});
|
|
144
|
+
let captured;
|
|
145
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
146
|
+
captured = await call("perf.console", {target:"client",player:playerName,since:baseline.nextCursor,limit:20});
|
|
147
|
+
if (captured.items.some(item => item.message.includes("mcp live client error"))) break;
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
149
|
+
}
|
|
150
|
+
check(captured.items.some(item => item.message.includes("mcp live client print") && item.level === "print") &&
|
|
151
|
+
captured.items.some(item => item.message.includes("mcp live client warning") && item.level === "warning") &&
|
|
152
|
+
captured.items.some(item => item.message.includes("mcp live client error") && item.level === "error"),
|
|
153
|
+
"console captures LocalScript output outside execute_luau");
|
|
154
|
+
check(captured.items.some(item => item.level === "error" && item.stack && item.source), "client errors include stack and source");
|
|
155
|
+
const incremental = await call("perf.console", {target:"client",player:playerName,since:captured.nextCursor,limit:20});
|
|
156
|
+
check(incremental.items.length === 0, "client console cursor does not repeat old output");
|
|
157
|
+
const runtimeState = await call("playtest.control", {op:"state"});
|
|
158
|
+
const other = runtimeState.state.players?.find(player => player.name !== playerName);
|
|
159
|
+
if (other) {
|
|
160
|
+
const second = await call("perf.console", {target:"client",player:other.name,limit:20});
|
|
161
|
+
check(!second.items.some(item => item.message.includes("mcp live client error")), "multiplayer client logs stay with their player");
|
|
162
|
+
try {
|
|
163
|
+
await call("perf.console", {target:"client",player:other.name,since:captured.nextCursor});
|
|
164
|
+
check(false, "a cursor from another player is rejected");
|
|
165
|
+
} catch (cause) {
|
|
166
|
+
check(cause.message.includes("BAD_CURSOR"), "a cursor from another player is rejected");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
await call("instances.delete", {paths:[diagnosticPath]}).catch(() => {});
|
|
171
|
+
}
|
|
128
172
|
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
173
|
check(values.ok && values.returned[1] === "nil" && values.returned[2].self === "<circular reference>", "client structured values preserve nil and cycles");
|
|
130
174
|
check(values.output.some(v => v.message.includes("client print")) && values.output.some(v => v.level === "warning"), "client prints and warnings captured");
|
|
@@ -279,5 +323,66 @@ if (args.includes("--playtests-off")) {
|
|
|
279
323
|
}
|
|
280
324
|
}
|
|
281
325
|
|
|
326
|
+
// Starts a real playtest through the public tool handler, proving its returned
|
|
327
|
+
// ID is the attached server session. Run only from an edit session, opt in.
|
|
328
|
+
if (args.includes("--playtest-start")) {
|
|
329
|
+
const { z } = await import("zod");
|
|
330
|
+
const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
|
|
331
|
+
let handler, spec;
|
|
332
|
+
const bridge = {
|
|
333
|
+
sessions: async () => (await (await fetch(`${base}/sessions`, {headers:HEADERS})).json()),
|
|
334
|
+
call: (op, params, options = {}) => callTo(options.studioId ?? studio.studioId, op, params, options.timeoutMs),
|
|
335
|
+
notePlaceName: async () => {},
|
|
336
|
+
};
|
|
337
|
+
registerPlaytestTools({bridge,server:{registerTool(_name, definition, invoke) {spec = definition; handler = invoke;}}});
|
|
338
|
+
let startedId;
|
|
339
|
+
let started = false;
|
|
340
|
+
const startOp = flag("playtest-op", "play");
|
|
341
|
+
if (!["play", "multiplayer"].includes(startOp)) throw new Error("--playtest-op must be play or multiplayer");
|
|
342
|
+
try {
|
|
343
|
+
const before = await callTo(studio.studioId, "playtest.control", {op:"state"});
|
|
344
|
+
if (!before.state.isEdit || before.state.playtestsAllowed === false) {
|
|
345
|
+
check(false, "--playtest-start needs an edit session with playtests enabled");
|
|
346
|
+
} else {
|
|
347
|
+
started = true;
|
|
348
|
+
const result = await handler(z.object(spec.inputSchema).parse({op:startOp,players:2,studioId:studio.studioId}));
|
|
349
|
+
const id = /"studioId": "([^"]+)"/.exec(result.content[0].text)?.[1];
|
|
350
|
+
startedId = id;
|
|
351
|
+
const sessions = await bridge.sessions();
|
|
352
|
+
check(Boolean(id && id !== studio.studioId && sessions.list.some(s => s.studioId === id && s.context?.includes("playtest"))), "playtest returns its connected server studioId directly");
|
|
353
|
+
const state = id ? await callTo(id, "playtest.control", {op:"state"}) : null;
|
|
354
|
+
if (state?.state.players?.length && !result.content[0].text.includes("players are still joining"))
|
|
355
|
+
check(/"players": \[/.test(result.content[0].text), "playtest includes available player identities");
|
|
356
|
+
if (startOp === "multiplayer") {
|
|
357
|
+
const reply = result.content[0].text;
|
|
358
|
+
const named = state?.state.players?.length === 2 && state.state.players.every(player => reply.includes(`"name": "${player.name}"`));
|
|
359
|
+
check(named || reply.includes("players are still joining"), "multiplayer returns player names when connected, or identifies late joins");
|
|
360
|
+
}
|
|
361
|
+
const staleCursor = flag("stale-cursor", undefined);
|
|
362
|
+
if (id && staleCursor) {
|
|
363
|
+
let players = state?.state.players;
|
|
364
|
+
for (let attempt = 0; !players?.length && attempt < 20; attempt += 1) {
|
|
365
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
366
|
+
players = (await callTo(id, "playtest.control", {op:"state"})).state.players;
|
|
367
|
+
}
|
|
368
|
+
if (players?.length) {
|
|
369
|
+
try {
|
|
370
|
+
await callTo(id, "perf.console", {target:"client",player:players[0].name,since:staleCursor});
|
|
371
|
+
check(false, "a client cursor from the prior playtest is rejected");
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
check(cause.message.includes("BAD_CURSOR"), "a client cursor from the prior playtest is rejected");
|
|
374
|
+
}
|
|
375
|
+
} else check(false, "a player must join to check the prior playtest cursor");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch (cause) {
|
|
379
|
+
check(false, `playtest returned ID: ${cause.message}`);
|
|
380
|
+
} finally {
|
|
381
|
+
const sessions = await bridge.sessions().catch(() => ({list:[]}));
|
|
382
|
+
const target = startedId ?? (started ? sessions.list.find(s => s.studioId !== studio.studioId && s.context?.includes("playtest"))?.studioId : undefined);
|
|
383
|
+
if (target) await callTo(target, "playtest.control", {op:"endTest",value:"live check complete"}).catch(() => {});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
282
387
|
process.stdout.write(failures === 0 ? "live: ok\n" : `live: ${failures} failure(s)\n`);
|
|
283
388
|
process.exit(failures === 0 ? 0 : 1);
|
package/scripts/test-plugin.mjs
CHANGED
|
@@ -33,6 +33,8 @@ const suites = [
|
|
|
33
33
|
{ module: "plugin/src/RemoteTrace.luau", test: "tests/remote-trace.luau", prelude: "tests/remote-trace-stub.luau" },
|
|
34
34
|
{ module: "plugin/src/ExecRuntime.luau", test: "tests/exec-runtime.luau", prelude: "tests/exec-runtime-stub.luau" },
|
|
35
35
|
{ module: "plugin/src/ClientRelay.luau", test: "tests/client-relay.luau", prelude: "tests/client-relay-stub.luau" },
|
|
36
|
+
{ module: "plugin/src/LogBuffer.luau", test: "tests/log-buffer.luau", prelude: "tests/log-buffer-stub.luau" },
|
|
37
|
+
{ module: "plugin/src/handlers/Perf.luau", test: "tests/perf-console.luau", prelude: "tests/perf-console-stub.luau" },
|
|
36
38
|
{ module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" },
|
|
37
39
|
{ module: "plugin/src/Format.luau", test: "tests/format.luau" },
|
|
38
40
|
];
|
package/scripts/test-tools.mjs
CHANGED
|
@@ -1,162 +1,259 @@
|
|
|
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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
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");
|
|
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 sessions() { return {list:[]}; }, 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
|
+
// A successful start includes the newly attached runtime session and player
|
|
122
|
+
// names, using the same discovery path as stop. The editor ID is excluded.
|
|
123
|
+
{
|
|
124
|
+
let started = false;
|
|
125
|
+
let operation = "play";
|
|
126
|
+
const originalSessions = context.bridge.sessions;
|
|
127
|
+
const originalCall = context.bridge.call;
|
|
128
|
+
context.bridge.sessions = async () => ({list: started
|
|
129
|
+
? [{studioId:"editor",context:"edit"},{studioId:"runtime",context:"playtest server"}]
|
|
130
|
+
: [{studioId:"editor",context:"edit"}]});
|
|
131
|
+
context.bridge.call = async (op, params, options) => {
|
|
132
|
+
assert.equal(op, "playtest.control");
|
|
133
|
+
if (params.op === operation) {
|
|
134
|
+
started = true;
|
|
135
|
+
return {changed:true,state:{testPending:true,isRunning:false}};
|
|
136
|
+
}
|
|
137
|
+
assert.equal(options.studioId, "runtime");
|
|
138
|
+
return {changed:false,state:{players:operation === "multiplayer" ? [{name:"Alice",userId:123},{name:"Bob",userId:456}] : [{name:"Alice",userId:123}]}};
|
|
139
|
+
};
|
|
140
|
+
for (operation of ["play", "multiplayer"]) {
|
|
141
|
+
started = false;
|
|
142
|
+
const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:operation,studioId:"editor"}));
|
|
143
|
+
assert.match(result.content[0].text, /"studioId": ?"runtime"/);
|
|
144
|
+
assert.match(result.content[0].text, /"name": ?"Alice"/);
|
|
145
|
+
}
|
|
146
|
+
context.bridge.sessions = originalSessions;
|
|
147
|
+
context.bridge.call = originalCall;
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write("playtest runtime identity: ok\n");
|
|
150
|
+
|
|
151
|
+
// OFF affects simulation starts, not the ordinary edit-mode tool paths.
|
|
152
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
153
|
+
const { registerScriptTools } = await import("../dist/tools/scripts.js");
|
|
154
|
+
const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
|
|
155
|
+
registerDiscoverTools(context);
|
|
156
|
+
registerScriptTools(context);
|
|
157
|
+
registerScreenshotTools(context);
|
|
158
|
+
const editOps = [];
|
|
159
|
+
context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
|
|
160
|
+
context.bridge.call = async (op, params) => {
|
|
161
|
+
editOps.push(op);
|
|
162
|
+
if (op === "playtest.control") {
|
|
163
|
+
if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
|
|
164
|
+
return {changed:false,state:{playtestsAllowed:false}};
|
|
165
|
+
}
|
|
166
|
+
const results = {
|
|
167
|
+
"exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
|
|
168
|
+
"discover.tree": {items:[],total:0,offset:0},
|
|
169
|
+
"discover.inspect": {items:[],failures:[]},
|
|
170
|
+
"script.read": {items:[],failures:[]},
|
|
171
|
+
"viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
|
|
172
|
+
"capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
|
|
173
|
+
};
|
|
174
|
+
assert.ok(op in results, `unexpected edit-mode operation ${op}`);
|
|
175
|
+
return results[op];
|
|
176
|
+
};
|
|
177
|
+
for (const [name, args, expected] of [
|
|
178
|
+
["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
|
|
179
|
+
["tree",{},"discover.tree"],
|
|
180
|
+
["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
|
|
181
|
+
["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
|
|
182
|
+
["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
|
|
183
|
+
["screenshot",{},"capture.screenshot"],
|
|
184
|
+
]) {
|
|
185
|
+
const tool = registered.get(name);
|
|
186
|
+
const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
187
|
+
assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
|
|
188
|
+
assert.equal(editOps.at(-1),expected);
|
|
189
|
+
}
|
|
190
|
+
assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
|
|
191
|
+
context.bridge.call = normalCall;
|
|
192
|
+
process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
|
|
193
|
+
|
|
194
|
+
const { registerPerfTools } = await import("../dist/tools/perf.js");
|
|
195
|
+
registerPerfTools(context);
|
|
196
|
+
const consoleTool = registered.get("console");
|
|
197
|
+
const consoleArgs = z.object(consoleTool.spec.inputSchema).parse({target:"client",player:"Alice",studioId:"runtime",since:"cursor-1",limit:10});
|
|
198
|
+
context.bridge.call = async (op, params, options) => {
|
|
199
|
+
assert.equal(op, "perf.console");
|
|
200
|
+
assert.equal(params.target, "client");
|
|
201
|
+
assert.equal(params.player, "Alice");
|
|
202
|
+
assert.equal(params.since, "cursor-1");
|
|
203
|
+
assert.equal(options.studioId, "runtime");
|
|
204
|
+
return {items:[],total:0,dropped:0,evicted:2,nextCursor:"cursor-2",capturing:true};
|
|
205
|
+
};
|
|
206
|
+
const consoleResult = await consoleTool.handler(consoleArgs);
|
|
207
|
+
assert.match(consoleResult.content[0].text, /2 older lines were evicted/);
|
|
208
|
+
assert.match(consoleResult.content[0].text, /nextCursor: cursor-2/);
|
|
209
|
+
context.bridge.call = async () => ({
|
|
210
|
+
items: Array.from({length:60}, (_, index) => ({level:"print",message:`${index} ${"x".repeat(1000)}`})),
|
|
211
|
+
total:60,dropped:0,nextCursor:"cursor-3",
|
|
212
|
+
});
|
|
213
|
+
const boundedConsole = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
|
|
214
|
+
assert.ok(boundedConsole.content[0].text.length < 25_000);
|
|
215
|
+
assert.match(boundedConsole.content[0].text, /older matching lines omitted/);
|
|
216
|
+
process.stdout.write("client console schema and cursor forwarding: ok\n");
|
|
217
|
+
|
|
218
|
+
// `create` must not advertise a recursive schema -- Gemini / Vertex AI reject a
|
|
219
|
+
// `$ref` with HTTP 400 -- yet a bad nested child is still refused by name.
|
|
220
|
+
{
|
|
221
|
+
const { registerInstanceTools } = await import("../dist/tools/instances.js");
|
|
222
|
+
const tools = new Map();
|
|
223
|
+
registerInstanceTools({ server: { registerTool(name, spec, handler) { tools.set(name, { spec, handler }); } }, bridge: { async call() { throw new Error("must not reach Studio"); } } });
|
|
224
|
+
const create = tools.get("create");
|
|
225
|
+
const schema = JSON.stringify(z.toJSONSchema(z.object(create.spec.inputSchema)));
|
|
226
|
+
assert.ok(!schema.includes("$ref") && !schema.includes("$defs") && !schema.includes("definitions"), "create schema is not recursive");
|
|
227
|
+
const bad = await create.handler(z.object(create.spec.inputSchema).parse({ instances: [{ parent: "Workspace", className: "Model", children: [{ className: "Part", children: [{ name: "NoClass" }] }] }] }));
|
|
228
|
+
assert.equal(bad.isError, true);
|
|
229
|
+
assert.match(bad.content[0].text, /BAD_PARAMS\] instances\[0\]\.children\[0\]\.children\[0\]\.className/);
|
|
230
|
+
process.stdout.write("create schema without recursion, nested validation: ok\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Compact JSON: short structures on one line, and always parsed back identical.
|
|
234
|
+
{
|
|
235
|
+
const { stringify } = await import("../dist/lib/format.js");
|
|
236
|
+
const value = { rows: [{ path: "Workspace.A", className: "Part" }], nested: { list: [1, "two", null, undefined], skip: undefined, when: new Date(0) }, long: "x".repeat(150) };
|
|
237
|
+
assert.deepEqual(JSON.parse(stringify(value)), JSON.parse(JSON.stringify(value)));
|
|
238
|
+
assert.match(stringify(value), /\n "rows": \[\{"path":"Workspace.A","className":"Part"\}\],\n/, "a short row stays on one line");
|
|
239
|
+
assert.equal(stringify([]), "[]");
|
|
240
|
+
assert.equal(stringify(undefined), "null");
|
|
241
|
+
process.stdout.write("compact json: ok\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Screenshot scaling: box filter averages everything under a pixel; enlarging copies blocks.
|
|
245
|
+
{
|
|
246
|
+
const { boxResample, upscaleNearest } = await import("../dist/lib/png.js");
|
|
247
|
+
const stripes = Buffer.from([0,0,0, 200,200,200, 0,0,0, 200,200,200, 0,0,0, 200,200,200, 0,0,0, 200,200,200]);
|
|
248
|
+
assert.deepEqual([...boxResample(stripes, 4, 2, 2).rgb], [100,100,100, 100,100,100], "averages, never samples");
|
|
249
|
+
const W = 1661, H = 719, line = Buffer.alloc(W * H * 3);
|
|
250
|
+
for (let y = 0; y < H; y += 1) line[(y * W + 831) * 3] = 255;
|
|
251
|
+
const scaled = boxResample(line, W, H, 800);
|
|
252
|
+
assert.equal(scaled.width, 800);
|
|
253
|
+
assert.equal(scaled.height, 346);
|
|
254
|
+
let red = 0; for (let i = 0; i < scaled.rgb.length; i += 3) red = Math.max(red, scaled.rgb[i]);
|
|
255
|
+
assert.ok(red > 100, "a one-pixel line survives a 2x reduction");
|
|
256
|
+
assert.equal(boxResample(stripes, 4, 2, 8).width, 4, "never scales up");
|
|
257
|
+
assert.deepEqual([...upscaleNearest(Buffer.from([1,2,3,4,5,6]), 2, 1, 2).rgb], [1,2,3,1,2,3,4,5,6,4,5,6,1,2,3,1,2,3,4,5,6,4,5,6]);
|
|
258
|
+
process.stdout.write("screenshot scaling: ok\n");
|
|
259
|
+
}
|