// A check is worth what it catches, so this breaks it every way it claims to catch. // // The cases that matter most are the last ones: `checkPlugin.js` finds hooks and // API calls with deliberately narrow regexes, and a regex that silently matches // NOTHING passes every other check in this file and every check in CI while // asserting nothing at all. So the real plugin source is read too, and the parse // is asserted against names 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, readRequires, readTomlRequires, HOOK_NAME, API_NAME, } = require('./checkPlugin') const INFO_LINE = ' [Info("RunicNPC", "Runic Gateway", "0.0.0")]' /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnServerInitialized'], methods, api = 1, requires = '// Requires: Kits', info = INFO_LINE, } = {}) { const body = methods ?? ` private void OnServerInitialized() { } private int RunicNpc_ApiVersion() => ApiVersion;` return `${requires} namespace Oxide.Plugins { ${info} internal class RunicNPC : RustPlugin { private const int ApiVersion = ${api}; private static readonly string[] ExpectedHooks = { ${expected.map((e) => `"${e}"`).join(', ')} }; ${body} } }` } const toml = ({ api = 1, requires = '["Kits"]' } = {}) => `api = ${api}\nmin_oxide_version = "2.0.7585"\nrequires_plugins = ${requires}\n` test('a plugin that follows the rules passes', () => { assert.deepEqual(check(source(), toml()), []) }) // ── Hooks ────────────────────────────────────────────────────────────────── test('a hook missing from ExpectedHooks is caught, because rnpc.status could not report it', () => { const problems = check(source({ expected: [] }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /OnServerInitialized is implemented but missing from ExpectedHooks/) }) test('a name listed but never implemented is caught, because it reports silent for ever', () => { const problems = check(source({ expected: ['OnServerInitialized', 'OnEntityDeath'] }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /ExpectedHooks lists OnEntityDeath, but no method/) }) test('a hook that answers is caught unless it is written down', () => { const methods = ` private object OnNpcTarget(BaseEntity npc, BaseEntity target) { return null; }` const problems = check(source({ expected: ['OnNpcTarget'], methods }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /OnNpcTarget returns object, not void/) assert.match(problems[0], /ANSWERS_DELIBERATELY/) }) 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 turned // into a veto without changing its signature, which is visible. const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret) { return true; }` const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) assert.match(problems[0], /CanBeTargeted returns bool, not void/) }) test('a method that is not shaped like a hook is left alone', () => { const methods = ` private Dictionary Describe(BasePlayer npc) { return null; } private static string Column(int index) { return null; }` assert.deepEqual(check(source({ expected: [], methods }), toml()), []) assert.ok(!HOOK_NAME.test('Describe')) assert.ok(HOOK_NAME.test('OnServerInitialized')) assert.ok(HOOK_NAME.test('CanBeTargeted')) }) test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => { const methods = ` private void OnServerInitialized() { } public class RunicNpcPlayer : ScientistNPC { public override void OnDied(HitInfo info) { } protected override void OnStateChanged() { } }` assert.deepEqual(check(source({ methods }), toml()), []) }) // ── The API ──────────────────────────────────────────────────────────────── test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { // The state the spike found HumanNPC's RefreshNPC and RemoveNPC in (PLAN.md §1.2). const methods = ` private void OnServerInitialized() { } public int RunicNpc_ApiVersion() => ApiVersion;` const problems = check(source({ methods }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /RunicNpc_ApiVersion is public without \[HookMethod\]/) }) test('a public API call WITH [HookMethod] is reachable and passes', () => { const methods = ` private void OnServerInitialized() { } [HookMethod("RunicNpc_ApiVersion")] public int RunicNpc_ApiVersion() => ApiVersion;` assert.deepEqual(check(source({ methods }), toml()), []) }) test('[HookMethod] is still seen in a CRLF checkout', () => { // A Windows clone has CRLF endings; an attribute regex that only knew `\n` // stopped seeing [HookMethod] there and failed a reachable call. const methods = ` private void OnServerInitialized() { } [HookMethod("RunicNpc_ApiVersion")] public int RunicNpc_ApiVersion() => ApiVersion;` assert.deepEqual(check(source({ methods }).replace(/\n/g, '\r\n'), toml()), []) }) test('an API version that disagrees with plugin.toml is caught', () => { const problems = check(source({ api: 2 }), toml({ api: 1 })) assert.equal(problems.length, 1) assert.match(problems[0], /answers API 2 and plugin\.toml declares 1/) }) test('a missing api key in plugin.toml is caught', () => { const problems = check(source(), 'requires_plugins = ["Kits"]\n') assert.equal(problems.length, 1) assert.match(problems[0], /could not read `api` from plugin\.toml/) }) // ── Required plugins (D217) ──────────────────────────────────────────────── test('a plugin without // Requires: Kits is caught, even when the toml agrees', () => { const problems = check(source({ requires: '' }), toml({ requires: '[]' })) assert.equal(problems.length, 1) assert.match(problems[0], /does not declare \/\/ Requires: Kits/) }) test('// Requires: and requires_plugins that disagree are caught', () => { const problems = check( source({ requires: '// Requires: Kits, ZoneManager' }), toml({ requires: '["Kits"]' }) ) assert.equal(problems.length, 1) assert.match(problems[0], /name \[Kits, ZoneManager\] and plugin\.toml's requires_plugins names \[Kits\]/) }) test('a requires_plugins that is not a one-line array of strings is caught', () => { const problems = check(source(), toml({ requires: 'Kits' })) assert.equal(problems.length, 1) assert.match(problems[0], /could not read `requires_plugins`/) assert.equal(readTomlRequires('requires_plugins = [1]'), null) }) test('several // Requires: lines and comma lists are all read', () => { assert.deepEqual(readRequires('// Requires: Kits\n// Requires: ZoneManager, Economics\n'), [ 'Kits', 'ZoneManager', 'Economics', ]) }) // ── The stamped line ─────────────────────────────────────────────────────── test('a missing [Info] line is caught, because the release has nothing to stamp', () => { const problems = check(source({ info: ' [Info("RunicNpc", "Runic Gateway", "0.0.0")]' }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /exactly one \[Info\("RunicNPC", "Runic Gateway", "…"\)\] attribute, found 0/) }) test('two [Info] lines are caught', () => { const problems = check(source({ info: `${INFO_LINE}\n${INFO_LINE}` }), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /found 2/) }) // ── Chat commands ────────────────────────────────────────────────────────── // // None exist until stage 3 (`/rnpc`), so these run on fixtures only. Stage 3 // adds the real-plugin assertion below, as the bridge's checker has. const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' function withChat(name, signature, returns = 'void') { return source({ expected: [], methods: ` [ChatCommand("${name}")] private ${returns} CmdThing(${signature}) { }`, }) } test('a correctly shaped chat command passes', () => { assert.deepEqual(check(withChat('rnpc', GOOD_SIGNATURE), toml()), []) }) test('a chat command taking the wrong player type is caught', () => { const problems = check(withChat('rnpc', 'IPlayer player, string command, string[] args'), toml()) assert.equal(problems.length, 1) assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) }) test('a chat command that returns something is caught', () => { const problems = check(withChat('rnpc', 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("rnpc")] private void CmdOne(${GOOD_SIGNATURE}) { } [ChatCommand("rnpc")] private void CmdTwo(${GOOD_SIGNATURE}) { }`, }) const problems = check(both, toml()) assert.equal(problems.length, 1) assert.match(problems[0], /declared twice/) }) // ── The real plugin ──────────────────────────────────────────────────────── const ROOT = path.resolve(__dirname, '..') const real = fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8') const realToml = fs.readFileSync(path.join(ROOT, 'plugin.toml'), 'utf8') test('the parser actually reads the real plugin, rather than quietly matching nothing', () => { const methods = readMethods(real) const names = new Set(methods.map((m) => m.name)) // Raise these floors as the plugin grows; they are what stops a regex that // matches nothing from passing every case above. assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`) assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call') assert.ok(readExpectedHooks(real).length >= 1) assert.deepEqual(readRequires(real), ['Kits']) }) test('the real plugin and plugin.toml pass', () => { assert.deepEqual(check(real, realToml), []) })