Files
bbassieandClaude Opus 5.5 afc1d35668 Load datastores with SubscribableDatastore.fromProvider
clusterio#1129 replaced DatastoreProvider.bootstrap with async factories
and Instance now takes its FactorioServer in the constructor, so the
instance tests pass their server stub there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:43:25 +00:00

135 lines
5.0 KiB
JavaScript

import t from "tap";
import * as lib from "@clusterio/lib";
import { Instance } from "@clusterio/host";
import entrypoint from "../dist/node/instance.js";
import { plugin as pluginDeclaration } from "../dist/node/index.js";
import * as messages from "../dist/node/messages.js";
// The instance validates message classes against the link registry
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
class TestConnector extends lib.BaseConnector {
constructor() {
super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 }));
this.valid = true;
this.connected = true;
this.hasSession = true;
}
send() {}
}
/** Run the plugin on a real instance with spies on what leaves it, returns the IPC handlers it registered. */
async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {}) {
const instanceConfig = new lib.InstanceConfig("host");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "test");
const state = { sent: [], rcons: [], warnings: [] };
const ipc = new Map();
const server = {
on: () => {},
handle: (name, handler) => ipc.set(name, handler),
};
const instance = new Instance(
{ assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), instanceConfig, server
);
instance.sendRcon = async command => {
state.rcons.push(command);
return "";
};
instance.sendTo = async (dst, message) => {
state.sent.push(message);
if (message instanceof messages.StatisticsGetRequest) {
return new messages.PlayerStatistics(message.name, stats);
}
return undefined;
};
instance.notifyStatus(status);
state.sent.length = 0;
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
await entrypoint({ plugin: { name: "exp_player_statistics" }, instance, host: {}, logger: pluginLogger });
return { instance, state, updateIPC: event => ipc.get("exp_player_statistics:update")(event) };
}
/** The payload of a recorded receive_totals command. */
function decodeTotals(command) {
const pattern = /^\/sc exp_player_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s;
const match = command.match(pattern);
return JSON.parse(match[2]);
}
t.test("instance plugin", t2 => {
t2.test("exp_player_statistics:update is forwarded to the controller", async t3 => {
const { state, updateIPC } = await startPlugin(t3);
await updateIPC({ players: [{ name: "alice", stats: { Kills: 1 } }, { name: "bob", stats: { Deaths: 2 } }] });
t3.equal(state.sent.length, 1, "one event");
const event = state.sent[0];
t3.ok(event instanceof messages.StatisticsUpdateEvent);
t3.strictSame(event.deltas.map(delta => [delta.name, delta.stats]), [
["alice", { Kills: 1 }],
["bob", { Deaths: 2 }],
]);
});
t2.test("a player joining is sent their totals", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
const request = state.sent[0];
t3.ok(request instanceof messages.StatisticsGetRequest);
t3.equal(request.name, "alice");
t3.equal(state.rcons.length, 1);
t3.strictSame(decodeTotals(state.rcons[0]), { name: "alice", stats: { Kills: 3 } });
});
t2.test("the long bracket is not in the json", async t3 => {
const { instance, state } = await startPlugin(t3, { stats: { "]=]": 1 } });
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.ok(state.rcons[0].includes("[==["), "a longer bracket is used");
t3.strictSame(decodeTotals(state.rcons[0]).stats, { "]=]": 1 });
});
t2.test("leaving and other player events send nothing", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.playerEvent.invoke({ type: "leave", name: "alice", reason: "quit" });
await instance.hooks.playerEvent.invoke({ type: "promote", name: "alice" });
t3.strictSame(state.sent, []);
t3.strictSame(state.rcons, []);
});
t2.test("totals are not sent once the instance is no longer running", async t3 => {
const { instance, state } = await startPlugin(t3, { status: "stopping" });
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.strictSame(state.rcons, []);
});
t2.test("a failed request is logged", async t3 => {
const { instance, state } = await startPlugin(t3);
instance.sendTo = async () => { throw new Error("controller gone"); };
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.strictSame(state.rcons, []);
t3.match(state.warnings[0], /alice: controller gone/);
});
t2.test("start drops what was unsent and stop sends the rest", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.start.invoke();
await instance.hooks.stop.invoke();
t3.strictSame(state.rcons, [
"/sc exp_player_statistics.discard_unsent()",
"/sc exp_player_statistics.send_all()",
]);
});
t2.end();
});