feat: stage 0, an empty RunicNPC that releases through CI
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
The repository RunicNPC is built in (docs/runicnpc/PLAN.md §9, stage 0): - plugin/RunicNPC.cs: `// Requires: Kits` (D217), `[Info]` with the 0.0.0 placeholder the release stamps, `RunicNpc_ApiVersion()` (API 1), and `rnpc.status`, which reports the version and which hooks have fired. It spawns nothing. - plugin.toml: the API version, the framework floors it was loaded on (Oxide 2.0.7726, Carbon 2.0.259) and requires_plugins = ["Kits"]. - scripts/checkPlugin.js, adapted from Rust-Plugins': every hook listed and void unless written down; chat-command signatures; every RunicNpc_ call reachable by Call (the HumanNPC trap, PLAN.md §1.2); ApiVersion, `// Requires:` and [Info] agreeing with plugin.toml. 23 self-tests, including the real plugin and a CRLF checkout. - PR Checks on PRs into main and edge; the release workflow on main, with Rust-Plugins' release engine unchanged and an adapter that ships runicnpc-<ver>.tar.gz (runicnpc/RunicNPC.cs + manifest.json) and SHA256SUMS. No bundle dispatch until stage 4. - tools/: the rig panel scripts, with the panel and server ids moved into a git-ignored tools/rigs.json. `con.js` became `console.js`: CON is a reserved device name on Windows, and git there cannot open the file. - README, CONTRIBUTING (edge-based flow, AI disclosure, borrow-not-copy), SECURITY, the code of conduct, issue and PR templates. `feat:` so the cutover to main cuts the first release, 0.1.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
303
scripts/checkPlugin.test.js
Normal file
303
scripts/checkPlugin.test.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// 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<string, object> 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'))
|
||||
})
|
||||
|
||||
// ── 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 >= 5, `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(readExpectedHooks(real).length >= 1)
|
||||
assert.deepEqual(readRequires(real), ['Kits'])
|
||||
})
|
||||
|
||||
test('the real plugin and plugin.toml pass', () => {
|
||||
assert.deepEqual(check(real, realToml), [])
|
||||
})
|
||||
Reference in New Issue
Block a user