// A check is worth what it catches, so this breaks it thirteen ways. // // The case that matters most is the last one: `checkPlugin.js` finds hooks with a // deliberately narrow regex, and a regex that silently matches NOTHING passes // every check in this file and every check in CI while asserting nothing at all. // So the real plugin source is read here too, and the parse is asserted against // hooks that are known to be in it. // // node --test scripts/checkPlugin.test.js // // Named individually rather than `node --test scripts/`: directory mode is not // portable across the Node versions this project runs on. const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { const body = methods ?? ` private void OnPlayerDeath(BasePlayer player, HitInfo info) { }` return `namespace Oxide.Plugins { internal class RunicGateway : RustPlugin { private const int ProtocolVersion = ${version}; private static readonly string[] ExpectedHooks = { ${expected.map((e) => `"${e}"`).join(', ')} }; ${body} } }` } const toml = (version = 2) => `protocol = ${version}\n` test('a plugin that follows the rules passes', () => { assert.deepEqual(check(source(), toml()), []) }) test('a hook missing from ExpectedHooks is caught, because rg.hooks could not report it', () => { const problems = check(source({ expected: [] }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /OnPlayerDeath is implemented but missing from ExpectedHooks/) }) test('a hook that can answer is caught — the rule the read path depends on', () => { const methods = ` private object OnPlayerDeath(BasePlayer player, HitInfo info) { return null; }` const problems = check(source({ methods }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /returns object, not void/) }) test('returning null is not good enough — the signature is the rule', () => { // `return null` today is one edit away from `return true` tomorrow, and the // edit that breaks it looks harmless in a diff. A void method cannot be // changed into a veto without changing its signature, which is visible. const methods = ` private bool CanUserLogin(string name, string id, string ip) { return true; }` const problems = check(source({ expected: ['CanUserLogin'], methods }), toml()) assert.match(problems[0], /CanUserLogin returns bool, not void/) }) test('a name listed but never implemented is caught, because it reports silent for ever', () => { const problems = check(source({ expected: ['OnPlayerDeath', 'OnNewSave'] }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /ExpectedHooks lists OnNewSave, but no method/) }) test('a protocol version that disagrees with overlay.toml is caught', () => { const problems = check(source({ version: 3 }), toml(2)) assert.equal(problems.length, 1) assert.match(problems[0], /speaks protocol 3 and overlay\.toml declares 2/) }) test('a method that is not shaped like a hook is left alone', () => { // `Cadence`, `Frame`, `Flatten` and friends are ours, return real types, and // must not be dragged into the void rule. const methods = ` private Dictionary Frame(string kind, string type) { return null; } private static string Column(int index) { return null; }` assert.deepEqual(check(source({ expected: [], methods }), toml()), []) assert.ok(!HOOK_NAME.test('Cadence')) assert.ok(!HOOK_NAME.test('Frame')) assert.ok(HOOK_NAME.test('OnPlayerDeath')) assert.ok(HOOK_NAME.test('CanUserLogin')) }) test('the parser actually reads the real plugin, rather than quietly matching nothing', () => { const real = fs.readFileSync( path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), 'utf8' ) const methods = readMethods(real) const names = new Set(methods.map((m) => m.name)) // A narrow regex that matches nothing passes every other test in this file. assert.ok(methods.length > 20, `only found ${methods.length} methods in the real plugin`) for (const hook of ['OnPlayerDeath', 'OnPlayerConnected', 'CanUserLogin', 'OnNewSave']) { assert.ok(names.has(hook), `${hook} was not found by the method parser`) } const expected = readExpectedHooks(real) assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`) }) // ── Chat commands (protocol 3) ───────────────────────────────────────────── // // A chat command binds by reflection on an exact signature, and a wrong one is // never invoked and never logged: the player types `/link` and nothing happens. // That is the same failure mode the hook checks exist for, one surface along. /** A plugin whose only body is one chat command. */ function withChat(attribute, signature, returns = 'void') { return source({ expected: [], methods: ` [ChatCommand("${attribute}")] private ${returns} CmdThing(${signature}) { }`, }) } const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' test('a correctly shaped chat command passes', () => { assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), []) }) test('a chat command taking the wrong player type is caught', () => { // The plausible mistake: Covalence's `IPlayer` is what a Universal plugin // takes, and this plugin is not one. It compiles and binds nothing. const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) }) test('a chat command missing the trailing args is caught', () => { const problems = check(withChat('link', 'BasePlayer player, string command'), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /nothing happens at all/) }) test('a chat command that returns something is caught', () => { const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /returns object, not void/) }) test('two chat commands answering to one name is caught', () => { const both = source({ expected: [], methods: ` [ChatCommand("link")] private void CmdOne(${GOOD_SIGNATURE}) { } [ChatCommand("link")] private void CmdTwo(${GOOD_SIGNATURE}) { }`, }) const problems = check(both, toml()) assert.equal(problems.length, 1) assert.match(problems[0], /declared twice/) }) test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => { // The same trap the method parser has, and the reason this file's last test // exists: a regex that matches NOTHING passes every case above. const real = fs.readFileSync( path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), 'utf8' ) const commands = readChatCommands(real) const names = new Set(commands.map((c) => c.command)) assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`) assert.ok(names.has('link'), '/link was not found by the chat-command parser') assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') })