mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 19:56:00 +02:00
A mod with a reskate-plugin.json is a plugin: Luau scripts that run in their own VM, with only
the reskate API ReSkate registers for them. Luau 0.741 is vendored in External/luau (the VM and
compiler only: no native code generation, no loadstring, io or C modules).
- Limits: 64 MB of memory, 50 ms a call, 2 ms a tick on average (over it for 3 s stops it),
more than 5 errors in 10 s stops it, 50 log lines a second. A plugin over a limit cannot catch
it in a pcall and carry on.
- API: reskate.log, on (load, unload, tick, level_loaded, level_unloading), timers, the level,
its own package's files and JSON, require inside the package; console.command and print
("console"); storage get, set, add and keys ("storage"), 1 MB in
%LOCALAPPDATA%\ReSkate\Plugins\<mod>\storage.json.
- Found every 2 s among the mods mods.json leaves on: switching a mod, or changing a plugin's
files, loads, unloads or reloads it without a restart. A package holding a program is refused.
- plugin list, commands, reload, off and on in the console. A plugin cannot take a name of
ReSkate's own commands.
- Tests: DINGOSDK_BUILD_PLUGIN_TESTS, including plugins written to break the sandbox.
469 lines
25 KiB
C++
469 lines
25 KiB
C++
// The plugin host apart from the game: manifests, the reskate API, storage, modules, hot reload,
|
|
// and the sandbox against plugins written to break it (loops, memory, error floods, escapes).
|
|
#include "Extension/Plugins/plugin_discovery.h"
|
|
#include "Extension/Plugins/plugin_host.h"
|
|
#include "Extension/Plugins/plugin_manifest.h"
|
|
#include "Engine/Vfs/mod_list.h"
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <stdexcept>
|
|
|
|
using namespace dingosdk::plugins;
|
|
namespace {
|
|
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
|
|
|
|
std::filesystem::path scratch;
|
|
|
|
struct TestEnvironment final : Environment {
|
|
std::vector<std::string> logs, lines;
|
|
double now = 1000;
|
|
void log(LogLevel, std::string_view text) override { logs.emplace_back(text); }
|
|
void console(std::string_view text) override { lines.emplace_back(text); }
|
|
double now_ms() override { return now; }
|
|
std::filesystem::path storage_root() override { return scratch / "storage"; }
|
|
bool builtin_command(std::string_view word) override { return word == "perf" || word == "load"; }
|
|
bool logged(std::string_view part) const {
|
|
return std::any_of(logs.begin(), logs.end(), [&](const auto& line) { return line.find(part) != std::string::npos; });
|
|
}
|
|
bool printed(std::string_view part) const {
|
|
return std::any_of(lines.begin(), lines.end(), [&](const auto& line) { return line.find(part) != std::string::npos; });
|
|
}
|
|
std::string all() const {
|
|
std::string text;
|
|
for (const auto& line : logs) text += line + "\n";
|
|
return text;
|
|
}
|
|
};
|
|
|
|
std::string manifest(std::string permissions = "", std::string side = "client") {
|
|
return R"({"api": 1, "entry": "plugin/main.lua", "side": ")" + side + R"(", "permissions": [)" + permissions + "]}";
|
|
}
|
|
|
|
void write(const std::filesystem::path& file, const std::string& text) {
|
|
std::filesystem::create_directories(file.parent_path());
|
|
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
|
|
}
|
|
|
|
// A mod folder holding a plugin: its reskate-plugin.json and files.
|
|
Candidate package(const std::string& id, const std::map<std::string, std::string>& files, std::string fingerprint = "1") {
|
|
const auto directory = scratch / "mods" / id;
|
|
std::filesystem::remove_all(directory);
|
|
for (const auto& [name, text] : files) write(directory / name, text);
|
|
return {id, directory, std::move(fingerprint), {}};
|
|
}
|
|
Candidate script(const std::string& id, const std::string& main, const std::string& permissions = "") {
|
|
return package(id, {{manifest_file, manifest(permissions)}, {"plugin/main.lua", main}});
|
|
}
|
|
|
|
Status status_of(const Host& host, std::string_view id) {
|
|
for (const auto& status : host.status()) if (status.id == id) return status;
|
|
throw std::runtime_error("no plugin " + std::string(id));
|
|
}
|
|
void ticks(Host& host, TestEnvironment& environment, int count) {
|
|
for (int i = 0; i < count; ++i) {
|
|
environment.now += 16.7;
|
|
host.tick(16.7);
|
|
}
|
|
}
|
|
void running(const Host& host, std::string_view id, const TestEnvironment& environment) {
|
|
const auto status = status_of(host, id);
|
|
check(status.state == "running", std::string(id) + " should run, and is " + status.state + ": " + status.reason + "\n" + environment.all());
|
|
}
|
|
void stopped(const Host& host, std::string_view id, std::string_view because) {
|
|
const auto status = status_of(host, id);
|
|
check(status.state == "stopped", std::string(id) + " should have stopped, and is " + status.state);
|
|
check(status.reason.find(because) != std::string::npos, std::string(id) + " stopped for \"" + status.reason + "\", not for " + std::string(because));
|
|
}
|
|
|
|
void a_manifest_says_what_a_plugin_is() {
|
|
const auto good = parse_manifest(manifest(R"("console", "storage")"));
|
|
check(good.api == 1 && good.entry == "plugin/main.lua" && good.side == Side::client, "A manifest is read");
|
|
check(good.permissions.contains("console") && good.permissions.contains("storage"), "with its permissions");
|
|
const auto refused = [](const std::string& text, std::string_view because) {
|
|
try {
|
|
parse_manifest(text);
|
|
} catch (const std::exception& error) {
|
|
check(std::string(error.what()).find(because) != std::string::npos, "Refused for \"" + std::string(error.what()) + "\", not " + std::string(because));
|
|
return;
|
|
}
|
|
throw std::runtime_error("A manifest that should be refused was read: " + text);
|
|
};
|
|
refused(R"({"entry": "main.lua"})", "\"api\"");
|
|
refused(R"({"api": 2, "entry": "main.lua"})", "update ReSkate");
|
|
refused(R"({"api": 1, "entry": "../main.lua"})", "inside the mod folder");
|
|
refused(R"({"api": 1, "entry": "C:/main.lua"})", "inside the mod folder");
|
|
refused(R"({"api": 1, "entry": "a\\main.lua"})", "inside the mod folder");
|
|
refused(R"({"api": 1, "entry": "main.txt"})", ".lua");
|
|
refused(R"({"api": 1, "entry": "main.lua", "side": "moon"})", "\"side\"");
|
|
refused(R"({"api": 1, "entry": "main.lua", "permissions": ["root"]})", "unknown permission");
|
|
refused("not json", "not valid JSON");
|
|
check(safe_relative_path("a/b.lua") == "a/b.lua", "A path inside the package is kept");
|
|
for (const auto* bad : {"", "/a", "a//b", "./a", "a/../b", "..", "a\\b", "c:a", "a.", "a /b", "a\nb"})
|
|
check(safe_relative_path(bad).empty(), std::string("A path that could leave the package is refused: ") + bad);
|
|
}
|
|
|
|
void a_plugin_loads_ticks_runs_its_commands_and_unloads() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("hello", R"(
|
|
local ticks = 0
|
|
reskate.on("load", function() reskate.log("loaded " .. reskate.id .. " api " .. reskate.api_version) end)
|
|
reskate.on("tick", function(dt) ticks += 1 end)
|
|
reskate.on("unload", function() reskate.log("bye after " .. ticks) end)
|
|
reskate.console.command("hello there", "Says hello", function(args)
|
|
reskate.console.print("hello " .. table.concat(args, ",") .. " after " .. ticks)
|
|
end)
|
|
print("printed", 1, true)
|
|
)", R"("console")")});
|
|
running(host, "hello", environment);
|
|
check(environment.logged("[hello] loaded hello api 1"), "The load handler ran\n" + environment.all());
|
|
check(environment.logged("[hello] printed\t1\ttrue"), "print goes to the log");
|
|
ticks(host, environment, 3);
|
|
check(host.run_console("hello there a b"), "Its command runs");
|
|
check(environment.printed("hello a,b after 3"), "with its arguments, after its ticks");
|
|
check(host.run_console("HELLO There"), "whatever the case");
|
|
check(!host.run_console("nothing here"), "and other text is left to ReSkate");
|
|
check(status_of(host, "hello").commands.size() == 1, "Its command is listed");
|
|
host.apply({});
|
|
check(environment.logged("[hello] bye after 3"), "Unloading runs its unload handler");
|
|
check(host.status().empty(), "and it is gone");
|
|
check(!host.run_console("hello there"), "with its command");
|
|
}
|
|
|
|
void a_permission_must_be_declared() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("bare", R"(
|
|
local ok, err = pcall(reskate.console.command, "x", "x", function() end)
|
|
assert(not ok and err:find("needs the \"console\" permission"), err)
|
|
ok, err = pcall(reskate.storage.get, "x")
|
|
assert(not ok and err:find("needs the \"storage\" permission"), err)
|
|
reskate.console.print("never")
|
|
)")});
|
|
stopped(host, "bare", "raised an error");
|
|
check(environment.logged("needs the \"console\" permission"), "The error names the permission\n" + environment.all());
|
|
}
|
|
|
|
void command_names_are_checked() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("first", R"(
|
|
reskate.console.command("dom start", "Starts", function() end)
|
|
assert(not pcall(reskate.console.command, "perf mine", "x", function() end), "ReSkate's own")
|
|
assert(not pcall(reskate.console.command, "plugin mine", "x", function() end), "the plugin command")
|
|
assert(not pcall(reskate.console.command, "Bad Name", "x", function() end), "uppercase")
|
|
assert(not pcall(reskate.console.command, "a b c d", "x", function() end), "four words")
|
|
assert(not pcall(reskate.console.command, "dom start", "x", function() end), "twice")
|
|
)", R"("console")"),
|
|
script("second", R"(
|
|
local ok, err = pcall(reskate.console.command, "dom", "x", function() end)
|
|
assert(not ok and err:find("first"), err)
|
|
reskate.console.command("other", "x", function() end)
|
|
)", R"("console")")});
|
|
running(host, "first", environment);
|
|
running(host, "second", environment);
|
|
}
|
|
|
|
void storage_is_kept_across_loads_and_has_a_quota() {
|
|
TestEnvironment environment;
|
|
Limits limits;
|
|
limits.storage_bytes = 300;
|
|
std::filesystem::remove_all(scratch / "storage" / "keeper");
|
|
const auto keeper = script("keeper", R"(
|
|
local runs = reskate.storage.add("runs")
|
|
reskate.storage.set("table", {a = 1, list = {1, 2, 3}, name = "x"})
|
|
local back = reskate.storage.get("table")
|
|
assert(back.a == 1 and back.list[3] == 3 and back.name == "x")
|
|
reskate.storage.set("gone", true)
|
|
reskate.storage.set("gone", nil)
|
|
assert(reskate.storage.get("gone") == nil)
|
|
local ok, err = pcall(reskate.storage.set, "big", string.rep("x", 1000))
|
|
assert(not ok and err:find("full"), err)
|
|
assert(reskate.storage.get("big") == nil)
|
|
assert(not pcall(reskate.storage.set, "f", print), "a function is not JSON")
|
|
assert(not pcall(reskate.storage.set, "", 1), "an empty key")
|
|
local cycle = {} cycle.me = cycle
|
|
assert(not pcall(reskate.storage.set, "c", cycle), "a cycle")
|
|
reskate.log("runs " .. runs)
|
|
)", R"("storage")");
|
|
{
|
|
Host host(environment, Side::client, limits);
|
|
host.apply({keeper});
|
|
running(host, "keeper", environment);
|
|
host.apply({});
|
|
host.apply({keeper});
|
|
running(host, "keeper", environment);
|
|
}
|
|
check(environment.logged("[keeper] runs 1") && environment.logged("[keeper] runs 2"), "Storage is kept from one load to the next\n" + environment.all());
|
|
check(std::filesystem::exists(scratch / "storage" / "keeper" / "storage.json"), "in the plugin's own file");
|
|
}
|
|
|
|
void packages_json_and_modules() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({package("modules", {
|
|
{manifest_file, manifest()},
|
|
{"data/parks.json", R"({"parks": [{"name": "bowl", "spots": 3}]})"},
|
|
{"data/notes.txt", "hello"},
|
|
{"plugin/lib/util.lua", "loads = (loads or 0) + 1\nreturn {double = function(x) return x * 2 end}"},
|
|
{"plugin/rules.luau", "return {name = 'rules'}"},
|
|
{"plugin/cycle_a.lua", "return require('cycle_b')"},
|
|
{"plugin/cycle_b.lua", "return require('cycle_a')"},
|
|
{"plugin/main.lua", R"(
|
|
assert(reskate.package.read("data/notes.txt") == "hello")
|
|
assert(reskate.package.read("data/missing.txt") == nil)
|
|
local parks = reskate.package.read_json("data/parks.json")
|
|
assert(parks.parks[1].name == "bowl" and parks.parks[1].spots == 3)
|
|
local listing = table.concat(reskate.package.list("data"), ",")
|
|
assert(listing == "notes.txt,parks.json", listing)
|
|
assert(table.concat(reskate.package.list(), ","):find("plugin/"), "folders end in /")
|
|
for _, bad in {"../x", "/x", "C:/x", "data\\notes.txt", "data/../../x"} do
|
|
assert(not pcall(reskate.package.read, bad), bad)
|
|
end
|
|
local util = require("./lib/util")
|
|
assert(util.double(4) == 8)
|
|
assert(require("lib/util") == util and loads == 1, "a module runs once")
|
|
assert(require("rules").name == "rules", ".luau too")
|
|
local ok, err = pcall(require, "cycle_a")
|
|
assert(not ok and err:find("requires itself"), err)
|
|
assert(not pcall(require, "missing"))
|
|
assert(not pcall(require, "../data/notes"))
|
|
local text = reskate.json.encode({a = {1, 2}, b = "x", c = true})
|
|
local back = reskate.json.decode(text)
|
|
assert(back.a[2] == 2 and back.b == "x" and back.c == true, text)
|
|
assert(not pcall(reskate.json.decode, "{nope"))
|
|
assert(not pcall(reskate.json.encode, {[{}] = 1}))
|
|
assert(not pcall(reskate.json.encode, 0/0))
|
|
reskate.log("modules fine")
|
|
)"}})});
|
|
running(host, "modules", environment);
|
|
check(environment.logged("modules fine"), "Packages, JSON and modules work\n" + environment.all());
|
|
}
|
|
|
|
// What a plugin must not be able to reach or change. Each line checks one way out.
|
|
void the_sandbox_holds() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("escape", R"(
|
|
assert(io == nil and package == nil and dofile == nil and loadfile == nil, "no files")
|
|
assert(loadstring == nil and load == nil, "no loading code, bytecode or text")
|
|
assert(os.execute == nil and os.remove == nil and os.rename == nil and os.getenv == nil and os.exit == nil, "no os")
|
|
assert(not pcall(function() reskate.log = nil end), "reskate is read only")
|
|
assert(not pcall(function() reskate.storage.get = print end), "and its tables")
|
|
assert(not pcall(rawset, reskate, "x", 1), "even by rawset")
|
|
assert(not pcall(setmetatable, reskate, {}), "or a metatable")
|
|
assert(not pcall(function() string.rep = nil end), "the libraries are read only")
|
|
assert(not pcall(function() getmetatable("").__index = {} end), "and the string metatable")
|
|
assert(not pcall(function() _G.print = nil end) or print ~= nil, "print stays")
|
|
mine = 1
|
|
assert(mine == 1, "its own globals work")
|
|
local function deep(n) return deep(n + 1) + 1 end
|
|
assert(not pcall(deep, 1), "endless recursion is an error, not a crash")
|
|
assert(not pcall(require, "C:/Windows/win.ini"))
|
|
assert(not pcall(reskate.package.read, "../../../Windows/win.ini"))
|
|
assert(not pcall(reskate.time.after, -1, print))
|
|
assert(not pcall(reskate.time.every, 0, print))
|
|
assert(not pcall(reskate.on, "made_up", print))
|
|
assert(not pcall(reskate.log, "x", "loud"))
|
|
reskate.log("sandbox fine")
|
|
)")});
|
|
running(host, "escape", environment);
|
|
check(environment.logged("sandbox fine"), "Every way out is closed\n" + environment.all());
|
|
}
|
|
|
|
void a_runaway_plugin_is_stopped_and_the_rest_go_on() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
const auto start = std::chrono::steady_clock::now();
|
|
host.apply({
|
|
script("bystander", R"(local n = 0 reskate.on("tick", function() n += 1 if n == 30 then reskate.log("bystander ticked 30") end end))"),
|
|
script("loop", "while true do end"),
|
|
script("catches", "while true do pcall(function() while true do end end) end"),
|
|
script("tick_loop", R"(reskate.on("tick", function() while true do end end))"),
|
|
script("memory", R"(local t = {} while true do t[#t + 1] = string.rep("x", 8000000) end)"),
|
|
script("memory_caught", R"(
|
|
pcall(function() local t = {} while true do t[#t + 1] = string.rep("y", 8000000) end end)
|
|
reskate.log("survived the memory bomb")
|
|
)"),
|
|
script("huge", R"(local s = string.rep("x", 100000000))"),
|
|
script("buffer", R"(local b = buffer.create(1e9))"),
|
|
script("errors", R"(reskate.on("tick", function() error("boom") end))"),
|
|
script("timers", R"(
|
|
local ok, err = pcall(function() for i = 1, 2000 do reskate.time.after(1000, function() end) end end)
|
|
assert(not ok and err:find("timers"), err)
|
|
reskate.log("timers capped")
|
|
)"),
|
|
script("chatty", R"(for i = 1, 1000 do reskate.log("line " .. i) end)"),
|
|
script("coroutines", R"(while true do coroutine.wrap(function() end)() end)"),
|
|
script("slow_unload", R"(reskate.on("unload", function() while true do end end))"),
|
|
});
|
|
for (const auto* id : {"loop", "catches"}) stopped(host, id, "ran longer than 50 ms");
|
|
stopped(host, "coroutines", ""); // its time or its memory, whichever runs out first
|
|
for (const auto* id : {"memory", "memory_caught", "huge", "buffer"}) stopped(host, id, "more than 64 MB");
|
|
check(!environment.logged("survived the memory bomb"), "A memory bomb cannot be caught and carried on from");
|
|
running(host, "tick_loop", environment);
|
|
ticks(host, environment, 1);
|
|
stopped(host, "tick_loop", "the tick handler ran longer than 50 ms");
|
|
check(environment.logged("[tick_loop] stopped: the tick handler"), "A stop is logged, which the console shows");
|
|
ticks(host, environment, 4); // five errors, with the tick above
|
|
running(host, "errors", environment);
|
|
ticks(host, environment, 1);
|
|
stopped(host, "errors", "more than 5 errors");
|
|
running(host, "timers", environment);
|
|
check(environment.logged("timers capped"), "Timers have a limit");
|
|
const auto chatty = std::count_if(environment.logs.begin(), environment.logs.end(),
|
|
[](const auto& line) { return line.starts_with("[chatty] line "); });
|
|
check(chatty == 50, "50 lines a second, and the rest left out: " + std::to_string(chatty));
|
|
ticks(host, environment, 30);
|
|
running(host, "bystander", environment);
|
|
check(environment.logged("bystander ticked 30"), "The others go on ticking");
|
|
const auto unloading = std::chrono::steady_clock::now();
|
|
host.apply({});
|
|
const auto unload_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - unloading).count();
|
|
check(unload_ms < 500, "An unload handler that never ends is cut off: " + std::to_string(unload_ms) + " ms");
|
|
const auto total = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
|
check(total < 20, "and none of it hangs the host: " + std::to_string(total) + " s");
|
|
}
|
|
|
|
void a_plugin_over_its_time_budget_is_stopped() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("busy", R"(reskate.on("tick", function() local s = os.clock() while os.clock() - s < 0.004 do end end))"),
|
|
script("light", R"(reskate.on("tick", function() local s = 0 for i = 1, 100 do s += i end end))")});
|
|
ticks(host, environment, 60 * 5);
|
|
stopped(host, "busy", "ms a tick for 3 s");
|
|
running(host, "light", environment);
|
|
}
|
|
|
|
void a_plugin_reloads_when_its_files_change() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("live", R"(reskate.log("version 1") reskate.on("unload", function() reskate.log("unloading 1") end))")});
|
|
running(host, "live", environment);
|
|
auto changed = script("live", R"(reskate.log("version 2"))");
|
|
changed.fingerprint = "2";
|
|
host.apply({changed});
|
|
check(environment.logged("unloading 1") && environment.logged("version 2"), "A changed plugin unloads and loads again\n" + environment.all());
|
|
host.apply({changed});
|
|
check(std::count_if(environment.logs.begin(), environment.logs.end(), [](const auto& line) { return line.find("version 2") != std::string::npos; }) == 1,
|
|
"and an unchanged one is left alone");
|
|
check(host.switch_off("live").find("switched off") != std::string::npos, "It can be switched off by hand");
|
|
check(status_of(host, "live").state == "switched off", "and stays off");
|
|
changed.fingerprint = "3";
|
|
host.apply({changed});
|
|
check(status_of(host, "live").state == "switched off", "even when its files change");
|
|
check(host.switch_on("live").find("started") != std::string::npos, "until it is switched on");
|
|
running(host, "live", environment);
|
|
check(host.reload("live").find("reloaded") != std::string::npos, "and reloaded by hand");
|
|
check(host.reload("nobody").starts_with("error: "), "A plugin that is not there cannot be");
|
|
}
|
|
|
|
void level_events_reach_plugins() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("levels", R"(
|
|
assert(reskate.level.current() == nil)
|
|
reskate.on("level_loaded", function(name) reskate.log("loaded " .. name .. " / " .. reskate.level.current().name) end)
|
|
reskate.on("level_unloading", function(name) reskate.log("leaving " .. name) end)
|
|
)")});
|
|
host.set_level("sanvan");
|
|
host.set_level("park");
|
|
host.set_level("");
|
|
check(environment.logged("loaded sanvan / sanvan") && environment.logged("leaving sanvan") &&
|
|
environment.logged("loaded park / park") && environment.logged("leaving park"), "Level events in order\n" + environment.all());
|
|
}
|
|
|
|
void timers_run_when_due() {
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
host.apply({script("clock", R"(
|
|
local count = 0
|
|
reskate.time.after(100, function() reskate.log("once") end)
|
|
local id = reskate.time.every(50, function() count += 1 end)
|
|
reskate.time.after(260, function() reskate.time.cancel(id) reskate.log("every ran " .. count) end)
|
|
local cancelled = reskate.time.after(10, function() reskate.log("never") end)
|
|
reskate.time.cancel(cancelled)
|
|
)")});
|
|
ticks(host, environment, 30);
|
|
check(environment.logged("[clock] once") && environment.logged("every ran 5") && !environment.logged("never"),
|
|
"Timers run once, repeat, and stop when cancelled\n" + environment.all());
|
|
}
|
|
|
|
void a_package_with_a_program_or_on_the_wrong_side_is_not_loaded() {
|
|
const auto clean = package("clean", {{manifest_file, manifest()}, {"plugin/main.lua", ""}, {"readme.txt", "A plain file"}});
|
|
check(check_package_files(clean.directory).empty(), "A plain package passes");
|
|
const auto named = package("named", {{manifest_file, manifest()}, {"plugin/main.lua", ""}, {"tools/helper.DLL", "x"}});
|
|
check(check_package_files(named.directory).find("tools/helper.dll") != std::string::npos, "A DLL by its name is refused");
|
|
const auto hidden = package("hidden", {{manifest_file, manifest()}, {"plugin/main.lua", ""}, {"data/picture.png", "MZ\x90\x00"}});
|
|
check(!check_package_files(hidden.directory).empty(), "and a program by its first bytes, whatever its name");
|
|
const auto script_file = package("batch", {{manifest_file, manifest()}, {"plugin/main.lua", ""}, {"run.bat", "echo"}});
|
|
check(!check_package_files(script_file.directory).empty(), "and a script Windows runs");
|
|
|
|
TestEnvironment environment;
|
|
Host host(environment, Side::client);
|
|
auto refused = hidden;
|
|
refused.refusal = check_package_files(hidden.directory);
|
|
host.apply({refused, package("server_only", {{manifest_file, manifest("", "server")}, {"plugin/main.lua", ""}}),
|
|
package("broken", {{manifest_file, "{"}, {"plugin/main.lua", ""}}),
|
|
package("syntax", {{manifest_file, manifest()}, {"plugin/main.lua", "local = 1"}})});
|
|
stopped(host, "hidden", "a program");
|
|
check(status_of(host, "server_only").state == "not loaded", "A server plugin waits for a server");
|
|
stopped(host, "broken", "not valid JSON");
|
|
stopped(host, "syntax", "plugin/main.lua");
|
|
}
|
|
|
|
void discovery_finds_the_plugins_among_the_mods_left_on() {
|
|
const auto root = scratch / "game";
|
|
const auto mods = root / "Mods";
|
|
write(mods / "Alpha" / manifest_file, manifest());
|
|
write(mods / "Alpha" / "plugin" / "main.lua", "");
|
|
write(mods / "Beta" / manifest_file, manifest());
|
|
write(mods / "Beta" / "plugin" / "main.lua", "");
|
|
write(mods / "Beta" / "bin" / "tool.exe", "x");
|
|
write(mods / "Gamma" / manifest_file, manifest());
|
|
write(mods / "Gamma" / "plugin" / "main.lua", "");
|
|
write(mods / "JustAMap" / "manifest.json", R"({"name": "Just a map"})");
|
|
check(dingosdk::mods::set_mod_enabled(mods, "Gamma", false), "mods.json switches a mod off");
|
|
FileChecks checks;
|
|
auto found = discover(root, checks);
|
|
std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.id < b.id; });
|
|
check(found.size() == 2 && found[0].id == "Alpha" && found[1].id == "Beta", "Only the plugins left on are found");
|
|
check(found[0].refusal.empty() && found[1].refusal.find("bin/tool.exe") != std::string::npos, "with what their files say");
|
|
const auto before = found[0].fingerprint;
|
|
write(mods / "Alpha" / "plugin" / "main.lua", "-- changed");
|
|
auto again = discover(root, checks);
|
|
std::sort(again.begin(), again.end(), [](const auto& a, const auto& b) { return a.id < b.id; });
|
|
check(again[0].fingerprint != before, "A changed file changes the fingerprint, which reloads it");
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
try {
|
|
scratch = std::filesystem::temp_directory_path() / "reskate-plugin-tests";
|
|
std::filesystem::remove_all(scratch);
|
|
a_manifest_says_what_a_plugin_is();
|
|
a_plugin_loads_ticks_runs_its_commands_and_unloads();
|
|
a_permission_must_be_declared();
|
|
command_names_are_checked();
|
|
storage_is_kept_across_loads_and_has_a_quota();
|
|
packages_json_and_modules();
|
|
the_sandbox_holds();
|
|
a_runaway_plugin_is_stopped_and_the_rest_go_on();
|
|
a_plugin_over_its_time_budget_is_stopped();
|
|
a_plugin_reloads_when_its_files_change();
|
|
level_events_reach_plugins();
|
|
timers_run_when_due();
|
|
a_package_with_a_program_or_on_the_wrong_side_is_not_loaded();
|
|
discovery_finds_the_plugins_among_the_mods_left_on();
|
|
std::filesystem::remove_all(scratch);
|
|
std::cout << "plugin tests passed\n";
|
|
return 0;
|
|
} catch (const std::exception& error) {
|
|
std::cerr << "plugin test failed: " << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|