@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.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/README.md +20 -3
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +33 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +16 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- package/dist/tools/upload.js.map +0 -1
|
@@ -138,7 +138,12 @@ try {
|
|
|
138
138
|
await ok("inspect: named properties", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Position"] }, /Position/);
|
|
139
139
|
await ok("script_read", "script_read", { paths: [`${FIXTURE}.Mod`] }, /rev [0-9a-f]+/);
|
|
140
140
|
await ok("script_read: a window", "script_read", { paths: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 3 }] }, /lines 2-3 of 5/);
|
|
141
|
-
await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod:
|
|
141
|
+
await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod +rev=[0-9a-f]+-[0-9a-f]+\n2: /);
|
|
142
|
+
await ok("script_grep: patterns", "script_grep", { patterns: ["add", "return"], path: FIXTURE }, /\[patterns 1\]/);
|
|
143
|
+
await ok("script_grep: counts", "script_grep", { patterns: ["add", "nothing-here"], path: FIXTURE, mode: "counts" }, /"lines": ?0/);
|
|
144
|
+
await ok("script_grep: files", "script_grep", { pattern: "add", literal: true, path: FIXTURE, mode: "files" }, /Mod \| ModuleScript \| [0-9a-f]+-/);
|
|
145
|
+
await ok("find: properties", "find", { path: FIXTURE, className: "Part", properties: ["Anchored", "NotAProperty"] }, /"Anchored":\s*true[\s\S]*NotAProperty/);
|
|
146
|
+
await ok("console: drain", "console", { mode: "drain", limit: 5 }, /nextCursor/);
|
|
142
147
|
await ok("api: describe", "api", { op: "describe", className: "Part" }, /superclass/);
|
|
143
148
|
await ok("api: classes", "api", { op: "classes", contains: "Constraint" }, /HingeConstraint/);
|
|
144
149
|
await ok("console", "console", { limit: 5 });
|
package/scripts/test-plugin.mjs
CHANGED
|
@@ -28,6 +28,15 @@ if (luau === null) {
|
|
|
28
28
|
|
|
29
29
|
/** Modules under test, paired with the test file that exercises each. */
|
|
30
30
|
const suites = [
|
|
31
|
+
{ module: "plugin/src/Dispatch.luau", test: "tests/deadlines.luau", prelude: "tests/deadlines-stub.luau" },
|
|
32
|
+
{ module: "plugin/src/ScriptEdit.luau", test: "tests/scriptedit.luau", prelude: "tests/scriptedit-stub.luau" },
|
|
33
|
+
{ module: "plugin/src/Undo.luau", test: "tests/undo.luau", prelude: "tests/undo-stub.luau" },
|
|
34
|
+
{
|
|
35
|
+
module: "plugin/src/handlers/Scripts.luau",
|
|
36
|
+
test: "tests/scripts.luau",
|
|
37
|
+
prelude: "tests/scripts-stub.luau",
|
|
38
|
+
dependencies: [{ name: "TextEdit", path: "plugin/src/TextEdit.luau" }],
|
|
39
|
+
},
|
|
31
40
|
{ module: "plugin/src/handlers/Playtest.luau", test: "tests/playtests.luau", prelude: "tests/playtests-stub.luau" },
|
|
32
41
|
{ module: "plugin/src/Commands.luau", test: "tests/playtests-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
|
|
33
42
|
{ module: "plugin/src/Commands.luau", test: "tests/chat-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
|
|
@@ -46,6 +55,8 @@ const suites = [
|
|
|
46
55
|
* MCP server turns into an actionable error for the agent.
|
|
47
56
|
*/
|
|
48
57
|
const DISPATCH_STUB = `local Dispatch = {}
|
|
58
|
+
function Dispatch.checkDeadline() end
|
|
59
|
+
function Dispatch.deadline() return nil end
|
|
49
60
|
function Dispatch.fail(code, message, hint)
|
|
50
61
|
\terror({ code = code, message = message, hint = hint }, 0)
|
|
51
62
|
end
|
|
@@ -64,6 +75,11 @@ for (const suite of suites) {
|
|
|
64
75
|
const bundle = [
|
|
65
76
|
DISPATCH_STUB,
|
|
66
77
|
suite.prelude ? readFileSync(join(root, suite.prelude), "utf8") : "",
|
|
78
|
+
// Real modules loaded as the module's own dependencies, not stubbed.
|
|
79
|
+
...(suite.dependencies ?? []).map(
|
|
80
|
+
(dependency) =>
|
|
81
|
+
`local ${dependency.name} = (function()\n${stripRequires(readFileSync(join(root, dependency.path), "utf8"))}\nend)()`,
|
|
82
|
+
),
|
|
67
83
|
suite.dependency ? `local Playtest = (function()\n${stripRequires(readFileSync(join(root, suite.dependency), "utf8"))}\nend)()` : "",
|
|
68
84
|
"local function loadModule()",
|
|
69
85
|
moduleSource,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Response budgets, cursors that survive clipping, and grouped grep rendering. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CHARACTER_LIMIT, decodeCursor, json, page, table } from "../dist/lib/format.js";
|
|
5
|
+
import { registerScriptTools } from "../dist/tools/scripts.js";
|
|
6
|
+
const output = result => result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
|
7
|
+
const rows = Array.from({length: 20}, (_, i) => ({path: "P" + i, data: "x".repeat(4000)}));
|
|
8
|
+
for (const result of [table(["path", "data"], rows, {offset: 10, total: 30}), page(rows, {offset: 10, total: 30})]) {
|
|
9
|
+
const rendered = output(result);
|
|
10
|
+
assert.ok(rendered.length <= CHARACTER_LIMIT);
|
|
11
|
+
const cursor = rendered.match(/cursor: "([^"]+)"/)?.[1];
|
|
12
|
+
const next = decodeCursor(cursor);
|
|
13
|
+
assert.ok(next > 10 && next < 30, "budget cursor must not skip unseen rows");
|
|
14
|
+
assert.ok(rendered.includes("P" + (next - 11)) && !rendered.includes("P" + (next - 10)));
|
|
15
|
+
}
|
|
16
|
+
for (const value of [{data: "\u0000".repeat(100000)}, Array.from({length: 5000}, () => ({nested: {data: "x".repeat(10000)}}))]) {
|
|
17
|
+
const rendered = output(json(value));
|
|
18
|
+
assert.ok(rendered.length <= CHARACTER_LIMIT);
|
|
19
|
+
assert.equal(JSON.parse(rendered).truncated, true, "large JSON must stay valid");
|
|
20
|
+
}
|
|
21
|
+
const registered = new Map();
|
|
22
|
+
const context = {server: {registerTool: (name, spec, handler) => registered.set(name, {spec, handler})}, bridge: {}};
|
|
23
|
+
registerScriptTools(context);
|
|
24
|
+
const call = (name, args) => {const tool = registered.get(name); return tool.handler(z.object(tool.spec.inputSchema).parse(args));};
|
|
25
|
+
context.bridge.call = async (op, params) => {
|
|
26
|
+
assert.equal(op, "script.grep"); assert.deepEqual(params.patterns, ["alpha", "beta"]);
|
|
27
|
+
return {searched: 1, total: 2, offset: 0, items: [
|
|
28
|
+
{path: "Script", revision: "r1", line: 2, text: "alpha", before: ["one"], after: ["beta", "four"], needles: [1]},
|
|
29
|
+
{path: "Script", revision: "r1", line: 3, text: "beta", before: ["one", "alpha"], after: ["four"], needles: [2]},
|
|
30
|
+
]};
|
|
31
|
+
};
|
|
32
|
+
const grep = output(await call("script_grep", {patterns: ["alpha", "beta"], contextLines: 2}));
|
|
33
|
+
assert.equal(grep.match(/rev=r1/g).length, 1);
|
|
34
|
+
assert.equal(grep.match(/alpha/g).length, 1); assert.equal(grep.match(/beta/g).length, 1);
|
|
35
|
+
assert.equal(grep.match(/four/g).length, 1);
|
|
36
|
+
context.bridge.call = async () => ({searched: 1, total: 20, offset: 0, items: Array.from({length: 20}, (_, i) => ({path: "Big", line: i + 1, text: "x".repeat(4000)}))});
|
|
37
|
+
const clipped = output(await call("script_grep", {pattern: "x"}));
|
|
38
|
+
assert.ok(clipped.length <= CHARACTER_LIMIT && clipped.includes("cursor:"));
|
|
39
|
+
assert.ok(decodeCursor(clipped.match(/cursor: "([^"]+)"/)[1]) < 20);
|
|
40
|
+
|
|
41
|
+
console.log("results: budgets, clipped cursors and merged search context pass");
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync`, offline, part two: conflicts that remember themselves, build files
|
|
3
|
+
* both ways, and `watch` -- echo suppression and notices on the next reply.
|
|
4
|
+
*
|
|
5
|
+
* Build files go through the real create-spec typing, which reads the API
|
|
6
|
+
* dump, so the dump is a small fixture in a sandboxed temp dir and the network
|
|
7
|
+
* is refused: the run is the same on every machine.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node scripts/test-sync-more.mjs (after `npm run build`)
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const sandbox = mkdtempSync(path.join(tmpdir(), "rbx-sync-more-"));
|
|
17
|
+
for (const name of ["TMPDIR", "TEMP", "TMP"]) process.env[name] = sandbox;
|
|
18
|
+
globalThis.fetch = async () => {
|
|
19
|
+
throw new Error("offline");
|
|
20
|
+
};
|
|
21
|
+
const property = (name, type) => ({ MemberType: "Property", Name: name, ValueType: { Name: type, Category: "Primitive" } });
|
|
22
|
+
mkdirSync(path.join(sandbox, "roblox-studio-mcp"), { recursive: true });
|
|
23
|
+
writeFileSync(
|
|
24
|
+
path.join(sandbox, "roblox-studio-mcp", "api-dump.json"),
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
fetchedAt: Date.now(),
|
|
27
|
+
dump: {
|
|
28
|
+
Classes: [
|
|
29
|
+
{ Name: "Instance", Superclass: "", Members: [property("Name", "string")] },
|
|
30
|
+
{ Name: "ScreenGui", Superclass: "Instance", Members: [property("ResetOnSpawn", "bool")] },
|
|
31
|
+
{ Name: "Frame", Superclass: "Instance", Members: [property("BackgroundTransparency", "float")] },
|
|
32
|
+
{ Name: "TextLabel", Superclass: "Instance", Members: [property("Text", "string")] },
|
|
33
|
+
],
|
|
34
|
+
Enums: [],
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { runBuild, runExport, runSync, startWatch, stopAllWatches, stopWatch, watchState } = await import("../dist/lib/sync.js");
|
|
40
|
+
const { takeNotices } = await import("../dist/lib/notices.js");
|
|
41
|
+
const { fakeStudio } = await import("./sync-fake.mjs");
|
|
42
|
+
|
|
43
|
+
const dir = path.join(sandbox, "work");
|
|
44
|
+
const file = (relative) => path.join(dir, ...relative.split("/"));
|
|
45
|
+
const read = (relative) => readFileSync(file(relative), "utf8");
|
|
46
|
+
const until = async (probe, ms = 5_000) => {
|
|
47
|
+
const deadline = Date.now() + ms;
|
|
48
|
+
while (!(await probe())) {
|
|
49
|
+
if (Date.now() > deadline) return false;
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Conflicts remember themselves -----------------------------------------
|
|
57
|
+
{
|
|
58
|
+
const studio = fakeStudio();
|
|
59
|
+
studio.scripts.set("ServerScriptService.Mod", { className: "ModuleScript", source: "return 1\n" });
|
|
60
|
+
const sync = (extra = {}) => runSync(studio, { dir, direction: "both", ...extra });
|
|
61
|
+
await sync();
|
|
62
|
+
|
|
63
|
+
writeFileSync(file("ServerScriptService/Mod.luau"), "return 'disk'\n");
|
|
64
|
+
studio.scripts.get("ServerScriptService.Mod").source = "return 'studio'\n";
|
|
65
|
+
let report = await sync();
|
|
66
|
+
assert.equal(report.conflicts.length, 1);
|
|
67
|
+
assert.equal(read(".rbx-sync/conflicts/ServerScriptService/Mod.luau"), "return 'studio'\n", "Studio's side is kept to merge from");
|
|
68
|
+
report = await sync();
|
|
69
|
+
assert.equal(report.conflicts.length, 1, "still a conflict while neither side moves");
|
|
70
|
+
assert.deepEqual(report.newConflicts, [], "but it is not new");
|
|
71
|
+
|
|
72
|
+
// Resolve on disk: the merged file wins, conditionally on Studio not having moved.
|
|
73
|
+
writeFileSync(file("ServerScriptService/Mod.luau"), "return 'merged'\n");
|
|
74
|
+
report = await sync();
|
|
75
|
+
assert.equal(report.conflicts.length, 0);
|
|
76
|
+
assert.equal(studio.scripts.get("ServerScriptService.Mod").source, "return 'merged'\n");
|
|
77
|
+
assert.ok(!existsSync(file(".rbx-sync/conflicts/ServerScriptService/Mod.luau")), "the sidecar goes when settled");
|
|
78
|
+
|
|
79
|
+
// Resolve in Studio instead.
|
|
80
|
+
writeFileSync(file("ServerScriptService/Mod.luau"), "return 'disk 2'\n");
|
|
81
|
+
studio.scripts.get("ServerScriptService.Mod").source = "return 'studio 2'\n";
|
|
82
|
+
assert.equal((await sync()).conflicts.length, 1);
|
|
83
|
+
studio.scripts.get("ServerScriptService.Mod").source = "return 'fixed in studio'\n";
|
|
84
|
+
report = await sync();
|
|
85
|
+
assert.equal(report.conflicts.length, 0);
|
|
86
|
+
assert.equal(read("ServerScriptService/Mod.luau"), "return 'fixed in studio'\n");
|
|
87
|
+
// A file outside the roots is not created (and so not re-created every run).
|
|
88
|
+
mkdirSync(file("Workspace"), { recursive: true });
|
|
89
|
+
writeFileSync(file("Workspace/Stray.luau"), "return 'stray'\n");
|
|
90
|
+
report = await sync();
|
|
91
|
+
assert.ok(![...studio.scripts.keys()].some((key) => key.startsWith("Workspace")), "outside the roots: left alone");
|
|
92
|
+
assert.equal(report.failures.length, 0);
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
console.log("sync conflicts: Studio's side kept beside, reported once, settled by whichever side moves");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Build files, both ways --------------------------------------------------
|
|
98
|
+
{
|
|
99
|
+
const studio = fakeStudio();
|
|
100
|
+
studio.trees.set("StarterGui.Shop", {
|
|
101
|
+
className: "ScreenGui",
|
|
102
|
+
name: "Shop",
|
|
103
|
+
properties: { ResetOnSpawn: false },
|
|
104
|
+
children: [
|
|
105
|
+
{ className: "Frame", name: "Panel", properties: { BackgroundTransparency: 0.5 }, children: [{ className: "TextLabel", name: "Title", properties: { Text: "Hello" } }] },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
const title = () => studio.trees.get("StarterGui.Shop").children[0].children[0].properties.Text;
|
|
109
|
+
const setFileTitle = (text) => {
|
|
110
|
+
const spec = JSON.parse(read("StarterGui/Shop.build.json"));
|
|
111
|
+
spec.children[0].children[0].properties.Text = text;
|
|
112
|
+
writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(spec, null, 2)}\n`);
|
|
113
|
+
};
|
|
114
|
+
const setStudioTitle = (text) => {
|
|
115
|
+
studio.trees.get("StarterGui.Shop").children[0].children[0].properties.Text = text;
|
|
116
|
+
};
|
|
117
|
+
const sync = (extra = {}) => runSync(studio, { dir, direction: "both", ...extra });
|
|
118
|
+
|
|
119
|
+
const [exported] = await runExport(studio, { dir, direction: "both", paths: ["StarterGui.Shop"] });
|
|
120
|
+
assert.equal(exported.file, "StarterGui/Shop.build.json");
|
|
121
|
+
const first = read("StarterGui/Shop.build.json");
|
|
122
|
+
assert.ok(first.indexOf('"className"') < first.indexOf('"name"') && first.includes('"Text": "Hello"'), "canonical key order");
|
|
123
|
+
assert.equal((await sync()).lines.length, 0, "in step right after export");
|
|
124
|
+
|
|
125
|
+
setFileTitle("From file");
|
|
126
|
+
let report = await sync();
|
|
127
|
+
assert.deepEqual(report.builds, ["StarterGui/Shop.build.json"]);
|
|
128
|
+
assert.equal(title(), "From file", "file edit rebuilds the tree");
|
|
129
|
+
assert.equal((await sync()).lines.length, 0, "and settles");
|
|
130
|
+
|
|
131
|
+
setStudioTitle("From Studio");
|
|
132
|
+
report = await sync();
|
|
133
|
+
assert.equal(report.counts.export, 1);
|
|
134
|
+
assert.ok(read("StarterGui/Shop.build.json").includes('"Text": "From Studio"'), "Studio edit is written back to the file");
|
|
135
|
+
|
|
136
|
+
setFileTitle("Disk side");
|
|
137
|
+
setStudioTitle("Studio side");
|
|
138
|
+
report = await sync();
|
|
139
|
+
assert.equal(report.conflicts.length, 1, "both sides edited: a conflict");
|
|
140
|
+
assert.equal(title(), "Studio side", "the tree is not rebuilt over");
|
|
141
|
+
assert.ok(read(".rbx-sync/conflicts/StarterGui/Shop.build.json").includes("Studio side"));
|
|
142
|
+
// An explicit build refuses too, unless told the file wins.
|
|
143
|
+
report = await runBuild(studio, { dir, direction: "push" , files: ["StarterGui/Shop.build.json"] });
|
|
144
|
+
assert.equal(report.conflicts.length, 1);
|
|
145
|
+
assert.equal(title(), "Studio side");
|
|
146
|
+
|
|
147
|
+
setFileTitle("Merged");
|
|
148
|
+
report = await sync();
|
|
149
|
+
assert.equal(report.conflicts.length, 0);
|
|
150
|
+
assert.equal(title(), "Merged", "the file that moved after the conflict wins");
|
|
151
|
+
assert.ok(!existsSync(file(".rbx-sync/conflicts/StarterGui/Shop.build.json")));
|
|
152
|
+
|
|
153
|
+
setStudioTitle("Studio again");
|
|
154
|
+
report = await runBuild(studio, { dir, direction: "push", files: ["StarterGui/Shop.build.json"], prefer: "disk" });
|
|
155
|
+
assert.equal(title(), "Merged", "prefer disk rebuilds over Studio's edit");
|
|
156
|
+
|
|
157
|
+
// Renamed in the file: the old tree is replaced, not duplicated.
|
|
158
|
+
const renamed = JSON.parse(read("StarterGui/Shop.build.json"));
|
|
159
|
+
renamed.name = "Store";
|
|
160
|
+
writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(renamed, null, 2)}\n`);
|
|
161
|
+
await sync({ prefer: "disk" });
|
|
162
|
+
assert.ok(studio.trees.has("StarterGui.Store") && !studio.trees.has("StarterGui.Shop"), [...studio.trees.keys()].join(","));
|
|
163
|
+
await sync();
|
|
164
|
+
renamed.name = "Shop";
|
|
165
|
+
writeFileSync(file("StarterGui/Shop.build.json"), `${JSON.stringify(renamed, null, 2)}\n`);
|
|
166
|
+
await sync({ prefer: "disk" });
|
|
167
|
+
assert.ok(studio.trees.has("StarterGui.Shop") && !studio.trees.has("StarterGui.Store"));
|
|
168
|
+
await sync();
|
|
169
|
+
|
|
170
|
+
// A tree deleted in Studio sends its unchanged file to the trash.
|
|
171
|
+
studio.trees.delete("StarterGui.Shop");
|
|
172
|
+
report = await sync();
|
|
173
|
+
assert.ok(!existsSync(file("StarterGui/Shop.build.json")));
|
|
174
|
+
const trash = readdirSync(file(".rbx-sync/trash"));
|
|
175
|
+
assert.ok(existsSync(path.join(file(".rbx-sync/trash"), trash[0], "StarterGui", "Shop.build.json")));
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
console.log("sync builds: export, file -> tree, tree -> file, conflict with sidecar, settle, explicit build guard, delete");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Watch ---------------------------------------------------------------------
|
|
181
|
+
{
|
|
182
|
+
const studio = fakeStudio();
|
|
183
|
+
studio.scripts.set("ServerScriptService.Live", { className: "ModuleScript", source: "return 0\n" });
|
|
184
|
+
const { state } = await startWatch(studio, { dir, direction: "both" });
|
|
185
|
+
const scansAfterStart = studio.calls.filter((op) => op === "sync.scan").length;
|
|
186
|
+
|
|
187
|
+
writeFileSync(file("ServerScriptService/Live.luau"), "return 1\n");
|
|
188
|
+
assert.ok(await until(() => studio.scripts.get("ServerScriptService.Live").source === "return 1\n"), "file edit reaches Studio");
|
|
189
|
+
// Sync's own write comes back as a Studio edit event: it must be recognised
|
|
190
|
+
// as an echo, not answered with a second full sync.
|
|
191
|
+
assert.ok(await until(() => state.echoes >= 1), "the echo is skipped");
|
|
192
|
+
const cyclesAfterPush = state.cycles;
|
|
193
|
+
|
|
194
|
+
studio.edit("ServerScriptService.Live", "return 2\n");
|
|
195
|
+
assert.ok(await until(() => read("ServerScriptService/Live.luau") === "return 2\n"), "Studio edit reaches the file");
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
|
197
|
+
assert.ok(state.cycles <= cyclesAfterPush + 2, `settles after a change (${state.cycles - cyclesAfterPush} syncs)`);
|
|
198
|
+
const scans = studio.calls.filter((op) => op === "sync.scan").length - scansAfterStart;
|
|
199
|
+
assert.ok(scans <= state.cycles, "Studio is scanned only when a sync runs");
|
|
200
|
+
|
|
201
|
+
// Idle: no syncs at all.
|
|
202
|
+
const idle = state.cycles;
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
|
204
|
+
assert.equal(state.cycles, idle, "nothing changed, nothing ran");
|
|
205
|
+
|
|
206
|
+
// Studio restarts on the same place: the watch follows it.
|
|
207
|
+
studio.restart();
|
|
208
|
+
writeFileSync(file("ServerScriptService/Live.luau"), "return 'after restart'\n");
|
|
209
|
+
assert.ok(await until(() => studio.scripts.get("ServerScriptService.Live").source === "return 'after restart'\n"), "watch reattached");
|
|
210
|
+
assert.ok(takeNotices()?.includes("reconnected"), "and said so");
|
|
211
|
+
|
|
212
|
+
// A conflict while watching reaches the agent on its next reply.
|
|
213
|
+
takeNotices();
|
|
214
|
+
writeFileSync(file("ServerScriptService/Live.luau"), "return 'disk'\n");
|
|
215
|
+
studio.edit("ServerScriptService.Live", "return 'studio'\n");
|
|
216
|
+
assert.ok(await until(() => state.conflicts.length === 1), "conflict found");
|
|
217
|
+
const notice = takeNotices();
|
|
218
|
+
assert.ok(notice?.includes("conflict in ServerScriptService/Live.luau"), notice);
|
|
219
|
+
assert.equal(takeNotices(), undefined, "said once");
|
|
220
|
+
assert.ok(watchState(dir));
|
|
221
|
+
stopWatch(dir);
|
|
222
|
+
assert.equal(watchState(dir), null);
|
|
223
|
+
console.log(`sync watch: both directions, echoes skipped (${state.echoes}), idle is silent, conflicts become notices`);
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
stopAllWatches();
|
|
227
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
228
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync`, offline: the planner on its own, then the whole engine against a
|
|
3
|
+
* real temp folder and an in-memory Studio that behaves like the plugin's
|
|
4
|
+
* sync handler (revisions, conditional writes, creates, moves, deletes).
|
|
5
|
+
*
|
|
6
|
+
* Usage: node scripts/test-sync.mjs (after `npm run build`)
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
classFromFile,
|
|
14
|
+
decodeName,
|
|
15
|
+
emptyManifest,
|
|
16
|
+
encodeName,
|
|
17
|
+
layout,
|
|
18
|
+
placementOf,
|
|
19
|
+
plan,
|
|
20
|
+
} from "../dist/lib/syncplan.js";
|
|
21
|
+
import { hashOf, runSync, stopAllWatches } from "../dist/lib/sync.js";
|
|
22
|
+
import { fakeStudio, scanOf } from "./sync-fake.mjs";
|
|
23
|
+
|
|
24
|
+
// Names -----------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
for (const name of ["Main", "Dr. Who", "a/b\\c", "what?", "100%", "CON", "trailing.", "trailing ", "..", "", "init", "Config.server", "日本"]) {
|
|
27
|
+
for (const kind of ["dir", "script"]) {
|
|
28
|
+
const encoded = encodeName(name, kind);
|
|
29
|
+
assert.ok(!/[<>:"/\\|?*]/.test(encoded), `${JSON.stringify(name)} -> ${encoded} is file-safe`);
|
|
30
|
+
assert.ok(!/[. ]$/.test(encoded) && !/^(con|prn|aux|nul)$/i.test(encoded), `${encoded} is Windows-safe`);
|
|
31
|
+
assert.equal(decodeName(encoded), name, `${JSON.stringify(name)} round-trips as ${kind}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
assert.equal(classFromFile(`${encodeName("Config.server", "script")}.luau`).className, "ModuleScript", "a dotted name keeps its class");
|
|
35
|
+
assert.equal(classFromFile(`${encodeName("init", "script")}.luau`).init, false, "a script named init is not an init file");
|
|
36
|
+
assert.deepEqual(classFromFile("Main.server.luau"), { stem: "Main", className: "Script", init: false });
|
|
37
|
+
assert.deepEqual(classFromFile("init.client.lua"), { stem: "init", className: "LocalScript", init: true });
|
|
38
|
+
assert.equal(classFromFile("notes.txt"), null);
|
|
39
|
+
console.log("sync names: encode/decode round trips, reserved and ambiguous names");
|
|
40
|
+
|
|
41
|
+
// Layout ----------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
const shape = layout(
|
|
45
|
+
scanOf({
|
|
46
|
+
"ServerScriptService.Main": { className: "Script", revision: "r1" },
|
|
47
|
+
"ServerScriptService.Main.Helper": { className: "ModuleScript", revision: "r2" },
|
|
48
|
+
"ReplicatedStorage.Shared.Util": { className: "ModuleScript", revision: "r3" },
|
|
49
|
+
"ReplicatedStorage.Shared.util": { className: "LocalScript", revision: "r4" },
|
|
50
|
+
"ReplicatedStorage.Shared.UTIL": { className: "ModuleScript", revision: "r5" },
|
|
51
|
+
"StarterGui.Bound": { className: "LocalScript", revision: "r6", fileSynced: true },
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
assert.equal(shape.fileOf.get("ServerScriptService.Main"), "ServerScriptService/Main/init.server.luau");
|
|
55
|
+
assert.equal(shape.fileOf.get("ServerScriptService.Main.Helper"), "ServerScriptService/Main/Helper.luau");
|
|
56
|
+
assert.equal(shape.fileOf.get("ReplicatedStorage.Shared.UTIL"), "ReplicatedStorage/Shared/UTIL.luau");
|
|
57
|
+
assert.equal(shape.fileOf.get("ReplicatedStorage.Shared.Util"), "ReplicatedStorage/Shared/Util~2.luau", "case collision suffixed");
|
|
58
|
+
assert.equal(shape.fileOf.get("ReplicatedStorage.Shared.util"), "ReplicatedStorage/Shared/util.client.luau", "different extension, no collision");
|
|
59
|
+
assert.deepEqual(shape.fileSynced, ["StarterGui.Bound"]);
|
|
60
|
+
assert.equal(shape.pathOfDir.get("ReplicatedStorage/Shared"), "ReplicatedStorage.Shared");
|
|
61
|
+
|
|
62
|
+
const place = placementOf("ReplicatedStorage/Shared/New/Thing.luau", shape, () => undefined);
|
|
63
|
+
assert.deepEqual(place, { parentPath: "ReplicatedStorage.Shared", parents: [{ name: "New", className: "Folder" }], name: "Thing", className: "ModuleScript" });
|
|
64
|
+
const init = placementOf("ServerScriptService/Svc/init.server.luau", shape, () => "Script");
|
|
65
|
+
assert.deepEqual(init, { parentPath: "ServerScriptService", parents: [], name: "Svc", className: "Script" });
|
|
66
|
+
assert.ok("error" in placementOf("Loose.luau", shape, () => undefined), "files must sit inside a service folder");
|
|
67
|
+
console.log("sync layout: init folders, case collisions, Script Sync exclusions, placement");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Plan ------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
const scripts = { "ServerScriptService.A": { className: "Script", revision: "a1" }, "ServerScriptService.B": { className: "ModuleScript", revision: "b1" } };
|
|
74
|
+
const studio = layout(scanOf(scripts));
|
|
75
|
+
const inScope = () => true;
|
|
76
|
+
const manifest = emptyManifest();
|
|
77
|
+
const first = plan({ manifest, studio, disk: new Map(), inScope, direction: "both" });
|
|
78
|
+
assert.deepEqual(first.actions.map((action) => action.kind).sort(), ["create-disk", "create-disk"], "first sync pulls everything, deletes nothing");
|
|
79
|
+
|
|
80
|
+
manifest.files["ServerScriptService/A.server.luau"] = { path: "ServerScriptService.A", className: "Script", revision: "a1", hash: "ha" };
|
|
81
|
+
manifest.files["ServerScriptService/B.luau"] = { path: "ServerScriptService.B", className: "ModuleScript", revision: "b1", hash: "hb" };
|
|
82
|
+
const kinds = (input) => plan({ manifest, inScope, direction: "both", ...input }).actions.map((action) => `${action.kind} ${action.file}`).sort();
|
|
83
|
+
|
|
84
|
+
const same = new Map([["ServerScriptService/A.server.luau", "ha"], ["ServerScriptService/B.luau", "hb"]]);
|
|
85
|
+
assert.deepEqual(kinds({ studio, disk: same }), [], "in step: nothing to do");
|
|
86
|
+
assert.deepEqual(kinds({ studio, disk: new Map([...same, ["ServerScriptService/A.server.luau", "ha2"]]) }), ["push ServerScriptService/A.server.luau"]);
|
|
87
|
+
const edited = layout(scanOf({ ...scripts, "ServerScriptService.A": { className: "Script", revision: "a2" } }));
|
|
88
|
+
assert.deepEqual(kinds({ studio: edited, disk: same }), ["pull ServerScriptService/A.server.luau"]);
|
|
89
|
+
assert.deepEqual(kinds({ studio: edited, disk: new Map([...same, ["ServerScriptService/A.server.luau", "ha2"]]) }), ["merge ServerScriptService/A.server.luau"]);
|
|
90
|
+
|
|
91
|
+
// Renamed on disk: same hash under a new name moves the script.
|
|
92
|
+
const renamedOnDisk = new Map([["ServerScriptService/Alpha.server.luau", "ha"], ["ServerScriptService/B.luau", "hb"]]);
|
|
93
|
+
assert.deepEqual(kinds({ studio, disk: renamedOnDisk }), ["move-studio ServerScriptService/Alpha.server.luau"]);
|
|
94
|
+
// Renamed in Studio: same revision at a new path moves the file.
|
|
95
|
+
const renamedInStudio = layout(scanOf({ "ServerScriptService.Alpha": { className: "Script", revision: "a1" }, "ServerScriptService.B": scripts["ServerScriptService.B"] }));
|
|
96
|
+
assert.deepEqual(kinds({ studio: renamedInStudio, disk: same }), ["move-disk ServerScriptService/Alpha.server.luau"]);
|
|
97
|
+
|
|
98
|
+
// Deleted on one side, untouched on the other: delete on the other.
|
|
99
|
+
assert.deepEqual(kinds({ studio, disk: new Map([["ServerScriptService/B.luau", "hb"]]) }), ["delete-studio ServerScriptService/A.server.luau"]);
|
|
100
|
+
const withoutA = layout(scanOf({ "ServerScriptService.B": scripts["ServerScriptService.B"] }));
|
|
101
|
+
assert.deepEqual(kinds({ studio: withoutA, disk: same }), ["delete-disk ServerScriptService/A.server.luau"]);
|
|
102
|
+
// Deleted on one side, edited on the other: conflict, unless told who wins.
|
|
103
|
+
assert.deepEqual(kinds({ studio: withoutA, disk: new Map([...same, ["ServerScriptService/A.server.luau", "ha2"]]) }), ["conflict ServerScriptService/A.server.luau"]);
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
plan({ manifest, inScope, direction: "both", prefer: "disk", studio: withoutA, disk: new Map([...same, ["ServerScriptService/A.server.luau", "ha2"]]) }).actions.map((a) => a.kind),
|
|
106
|
+
["create-studio"],
|
|
107
|
+
);
|
|
108
|
+
// Gone from both: forget.
|
|
109
|
+
assert.deepEqual(kinds({ studio: withoutA, disk: new Map([["ServerScriptService/B.luau", "hb"]]) }), ["forget ServerScriptService/A.server.luau"]);
|
|
110
|
+
// Direction holds the other side's changes back rather than dropping them.
|
|
111
|
+
const pushOnly = plan({ manifest, inScope, direction: "push", studio: edited, disk: same });
|
|
112
|
+
assert.equal(pushOnly.actions.length, 0);
|
|
113
|
+
assert.deepEqual(pushOnly.held.map((action) => action.kind), ["pull"]);
|
|
114
|
+
// Out of scope entries are ignored, never deleted.
|
|
115
|
+
assert.deepEqual(plan({ manifest, inScope: () => false, direction: "both", studio: layout(scanOf({})), disk: new Map() }).actions, []);
|
|
116
|
+
console.log("sync plan: first sync, push, pull, merge, renames both ways, deletes, conflicts, direction, scope");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Engine ----------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const dir = mkdtempSync(path.join(tmpdir(), "rbx-sync-test-"));
|
|
122
|
+
const file = (relative) => path.join(dir, ...relative.split("/"));
|
|
123
|
+
const read = (relative) => readFileSync(file(relative), "utf8");
|
|
124
|
+
const studio = fakeStudio();
|
|
125
|
+
studio.scripts.set("ServerScriptService.Main", { className: "Script", source: 'print("hi")\n' });
|
|
126
|
+
studio.scripts.set("ServerScriptService.Main.Helper", { className: "ModuleScript", source: "return {}\n" });
|
|
127
|
+
studio.scripts.set("StarterGui.Shop.Client", { className: "LocalScript", source: "-- client\n" });
|
|
128
|
+
const sync = (extra = {}) => runSync(studio, { dir, direction: "both", ...extra });
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// First pull writes everything, the Rojo way.
|
|
132
|
+
let report = await sync();
|
|
133
|
+
assert.equal(report.counts["create-disk"], 3);
|
|
134
|
+
assert.equal(read("ServerScriptService/Main/init.server.luau"), 'print("hi")\n');
|
|
135
|
+
assert.equal(read("ServerScriptService/Main/Helper.luau"), "return {}\n");
|
|
136
|
+
assert.equal(read("StarterGui/Shop/Client.client.luau"), "-- client\n");
|
|
137
|
+
assert.ok(existsSync(file(".rbx-sync/manifest.json")) && read(".rbx-sync/.gitignore") === "*\n");
|
|
138
|
+
assert.equal((await sync()).lines.length, 0, "a second run is a no-op");
|
|
139
|
+
|
|
140
|
+
// Edit a file (with CRLF, as some editors save): it reaches Studio as LF.
|
|
141
|
+
writeFileSync(file("ServerScriptService/Main/Helper.luau"), "return { answer = 42 }\r\n");
|
|
142
|
+
report = await sync();
|
|
143
|
+
assert.equal(report.counts.push, 1);
|
|
144
|
+
assert.equal(studio.scripts.get("ServerScriptService.Main.Helper").source, "return { answer = 42 }\n");
|
|
145
|
+
|
|
146
|
+
// Edit in Studio: it reaches the file.
|
|
147
|
+
studio.scripts.get("StarterGui.Shop.Client").source = "-- client v2\n";
|
|
148
|
+
report = await sync();
|
|
149
|
+
assert.equal(report.counts.pull, 1);
|
|
150
|
+
assert.equal(read("StarterGui/Shop/Client.client.luau"), "-- client v2\n");
|
|
151
|
+
|
|
152
|
+
// New file on disk, in a new folder: created in Studio.
|
|
153
|
+
mkdirSync(file("ReplicatedStorage/Shared"), { recursive: true });
|
|
154
|
+
writeFileSync(file("ReplicatedStorage/Shared/Util.luau"), "return 1\n");
|
|
155
|
+
report = await sync();
|
|
156
|
+
assert.equal(report.counts["create-studio"], 1);
|
|
157
|
+
assert.equal(studio.scripts.get("ReplicatedStorage.Shared.Util").source, "return 1\n");
|
|
158
|
+
|
|
159
|
+
// Both sides edited: a conflict, and neither side is touched.
|
|
160
|
+
writeFileSync(file("ReplicatedStorage/Shared/Util.luau"), "return 2\n");
|
|
161
|
+
studio.scripts.get("ReplicatedStorage.Shared.Util").source = "return 3\n";
|
|
162
|
+
report = await sync();
|
|
163
|
+
assert.equal(report.conflicts.length, 1);
|
|
164
|
+
assert.equal(read("ReplicatedStorage/Shared/Util.luau"), "return 2\n");
|
|
165
|
+
assert.equal(studio.scripts.get("ReplicatedStorage.Shared.Util").source, "return 3\n");
|
|
166
|
+
// ...and the same text on both sides settles it without asking.
|
|
167
|
+
writeFileSync(file("ReplicatedStorage/Shared/Util.luau"), "return 3\n");
|
|
168
|
+
report = await sync();
|
|
169
|
+
assert.equal(report.conflicts.length, 0);
|
|
170
|
+
assert.equal((await sync()).lines.length, 0);
|
|
171
|
+
// prefer settles a real conflict in one direction.
|
|
172
|
+
writeFileSync(file("ReplicatedStorage/Shared/Util.luau"), "return 4\n");
|
|
173
|
+
studio.scripts.get("ReplicatedStorage.Shared.Util").source = "return 5\n";
|
|
174
|
+
report = await sync({ prefer: "disk" });
|
|
175
|
+
assert.equal(studio.scripts.get("ReplicatedStorage.Shared.Util").source, "return 4\n");
|
|
176
|
+
|
|
177
|
+
// Rename on disk: the script moves, it is not recreated.
|
|
178
|
+
const moved = studio.scripts.get("ReplicatedStorage.Shared.Util");
|
|
179
|
+
renameSync(file("ReplicatedStorage/Shared/Util.luau"), file("ReplicatedStorage/Shared/Tools.luau"));
|
|
180
|
+
report = await sync();
|
|
181
|
+
assert.equal(report.counts["move-studio"], 1);
|
|
182
|
+
assert.equal(studio.scripts.get("ReplicatedStorage.Shared.Tools"), moved, "same instance, new name");
|
|
183
|
+
assert.ok(!studio.scripts.has("ReplicatedStorage.Shared.Util"));
|
|
184
|
+
|
|
185
|
+
// Rename in Studio: the file moves.
|
|
186
|
+
studio.scripts.set("StarterGui.Shop.ShopClient", studio.scripts.get("StarterGui.Shop.Client"));
|
|
187
|
+
studio.scripts.delete("StarterGui.Shop.Client");
|
|
188
|
+
report = await sync();
|
|
189
|
+
assert.equal(report.counts["move-disk"], 1);
|
|
190
|
+
assert.ok(existsSync(file("StarterGui/Shop/ShopClient.client.luau")) && !existsSync(file("StarterGui/Shop/Client.client.luau")));
|
|
191
|
+
|
|
192
|
+
// Deleted in Studio: the file goes to the trash, not away.
|
|
193
|
+
studio.scripts.delete("StarterGui.Shop.ShopClient");
|
|
194
|
+
report = await sync();
|
|
195
|
+
assert.equal(report.counts["delete-disk"], 1);
|
|
196
|
+
assert.ok(!existsSync(file("StarterGui/Shop/ShopClient.client.luau")));
|
|
197
|
+
assert.ok(!existsSync(file("StarterGui/Shop")), "emptied folders are pruned");
|
|
198
|
+
const trashRuns = readdirSync(file(".rbx-sync/trash"));
|
|
199
|
+
assert.equal(read(`.rbx-sync/trash/${trashRuns[0]}/StarterGui/Shop/ShopClient.client.luau`), "-- client v2\n");
|
|
200
|
+
|
|
201
|
+
// Deleted on disk: the script is deleted in Studio.
|
|
202
|
+
unlinkSync(file("ReplicatedStorage/Shared/Tools.luau"));
|
|
203
|
+
report = await sync();
|
|
204
|
+
assert.equal(report.counts["delete-studio"], 1);
|
|
205
|
+
assert.ok(!studio.scripts.has("ReplicatedStorage.Shared.Tools"));
|
|
206
|
+
|
|
207
|
+
// Push holds Studio's changes back and says so.
|
|
208
|
+
studio.scripts.get("ServerScriptService.Main").source = 'print("studio")\n';
|
|
209
|
+
report = await sync({ direction: "push" });
|
|
210
|
+
assert.equal(report.held.length, 1);
|
|
211
|
+
assert.equal(read("ServerScriptService/Main/init.server.luau"), 'print("hi")\n');
|
|
212
|
+
await sync();
|
|
213
|
+
assert.equal(read("ServerScriptService/Main/init.server.luau"), 'print("studio")\n');
|
|
214
|
+
|
|
215
|
+
// Dry run changes nothing.
|
|
216
|
+
writeFileSync(file("ServerScriptService/Main/Helper.luau"), "return 'dry'\n");
|
|
217
|
+
report = await sync({ dryRun: true });
|
|
218
|
+
assert.equal(report.counts.push, 1);
|
|
219
|
+
assert.notEqual(studio.scripts.get("ServerScriptService.Main.Helper").source, "return 'dry'\n");
|
|
220
|
+
await sync();
|
|
221
|
+
|
|
222
|
+
// Another place is refused outright.
|
|
223
|
+
const other = fakeStudio(2);
|
|
224
|
+
await assert.rejects(runSync(other, { dir, direction: "both" }), /PLACE_MISMATCH|synced with place/);
|
|
225
|
+
|
|
226
|
+
// Deleting most of what is tracked stops and asks.
|
|
227
|
+
const big = mkdtempSync(path.join(tmpdir(), "rbx-sync-big-"));
|
|
228
|
+
const bigStudio = fakeStudio(3);
|
|
229
|
+
for (let index = 0; index < 20; index += 1) bigStudio.scripts.set(`ServerScriptService.S${index}`, { className: "ModuleScript", source: `return ${index}\n` });
|
|
230
|
+
await runSync(bigStudio, { dir: big, direction: "both" });
|
|
231
|
+
rmSync(path.join(big, "ServerScriptService"), { recursive: true });
|
|
232
|
+
await assert.rejects(runSync(bigStudio, { dir: big, direction: "both" }), /would delete 20/);
|
|
233
|
+
assert.equal(bigStudio.scripts.size, 20, "nothing was deleted");
|
|
234
|
+
const confirmed = await runSync(bigStudio, { dir: big, direction: "both", confirmDeletes: true });
|
|
235
|
+
assert.equal(confirmed.counts["delete-studio"], 20);
|
|
236
|
+
rmSync(big, { recursive: true, force: true });
|
|
237
|
+
|
|
238
|
+
assert.ok(studio.logged.some((line) => line.message.startsWith("sync to files")), "disk-side work is reported in the panel");
|
|
239
|
+
console.log("sync engine: pull, push (CRLF), create, conflicts, prefer, renames, trash, prune, direction, dry run, place and mass-delete guards");
|
|
240
|
+
} finally {
|
|
241
|
+
stopAllWatches();
|
|
242
|
+
rmSync(dir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
assert.equal(hashOf("a\r\nb"), hashOf("a\nb"));
|