MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
516 lines
23 KiB
JavaScript
516 lines
23 KiB
JavaScript
// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
|
||
//
|
||
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
|
||
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
|
||
// Every one of them was reachable only by `registerCore()` before this phase, and
|
||
// the plan is explicit about how to prove they are reachable now:
|
||
//
|
||
// > **Prove it with a throwaway module, not with module-uo.** A contract
|
||
// > validated only against the module it was carved out of has not been
|
||
// > validated, and P9 should be the *second* consumer of this seam.
|
||
//
|
||
// So every test below writes a real module to a real directory, points
|
||
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
|
||
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
|
||
// a test that staged directly would pass just as happily against the code before
|
||
// this phase, when `buildApi` forwarded none of these four names.
|
||
//
|
||
// **The half this file cares most about is the failure half.** §F's load-bearing
|
||
// envelope rule is that *no shape a failure can take may read as success* — a
|
||
// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
|
||
// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
|
||
// inverted, because the expensive mistake here is recording a world change that
|
||
// did not happen. Those five shapes are dispatched from a module below, not
|
||
// constructed as literals, so what is under test is the path a module actually
|
||
// takes.
|
||
//
|
||
// The pool points at a closed port before anything is required: the loader's
|
||
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
|
||
// query is ever run.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const fs = require('fs')
|
||
const os = require('os')
|
||
const path = require('path')
|
||
|
||
const { test, beforeEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const express = require('express')
|
||
|
||
const db = require('../src/utils/db')
|
||
const registries = require('../src/modules/registries')
|
||
const dispatch = require('../src/events/dispatch')
|
||
const authorize = require('../src/events/authorize')
|
||
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||
|
||
after(() => db.close())
|
||
|
||
let tmpRoot
|
||
|
||
const emptyTiers = () => ({
|
||
public: express.Router(),
|
||
admin: express.Router(),
|
||
player: express.Router(),
|
||
})
|
||
|
||
/**
|
||
* Write a module and let the real loader scan it.
|
||
*
|
||
* MODULES_DIR is read into a const at require time — it has to be, the scan is
|
||
* synchronous and happens while app.js is being required — so busting the cache
|
||
* is the only honest way to point the loader somewhere else.
|
||
*/
|
||
function loadModule(id, source, manifest = {}) {
|
||
const dir = path.join(tmpRoot, id)
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
fs.writeFileSync(
|
||
path.join(dir, 'module.json'),
|
||
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
|
||
)
|
||
fs.writeFileSync(path.join(dir, 'index.js'), source)
|
||
|
||
process.env.MODULES_DIR = tmpRoot
|
||
registries._reset()
|
||
delete require.cache[require.resolve('../src/modules/loader')]
|
||
// eslint-disable-next-line global-require
|
||
const loader = require('../src/modules/loader')
|
||
loader.load(emptyTiers())
|
||
return loader.list().find((m) => m.id === id)
|
||
}
|
||
|
||
/** The state a module that loaded cleanly is in, with its reason if it did not. */
|
||
const assertRegistered = (record) => {
|
||
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
|
||
}
|
||
|
||
const RUN = { id: 1, scope: '' }
|
||
const step = (actionId, params = {}) => ({
|
||
id: 1,
|
||
action_id: actionId,
|
||
params,
|
||
idempotency_key: 'k-1',
|
||
})
|
||
|
||
beforeEach(() => {
|
||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
|
||
})
|
||
|
||
// ── The seam is open ───────────────────────────────────────────────────────
|
||
|
||
test('the version a module declares against is 1.10.0', () => {
|
||
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
|
||
// these tests fail loudly rather than quietly if the bump is ever reverted —
|
||
// the loader would refuse the manifest and every assertion would become "the
|
||
// module did not register", which is the same failure the seam closing would
|
||
// produce. Asserting the number here says which of the two it was.
|
||
assert.equal(MODULE_API_VERSION, '1.10.0')
|
||
})
|
||
|
||
test('a module registers actions, budgets, leases and option sources', () => {
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([
|
||
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
|
||
])
|
||
api.registerEventActions([{
|
||
id: 'demo.wisp.summon',
|
||
label: 'Summon wisps',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
cost: (p) => ({ 'demo.wisps': p.count }),
|
||
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
|
||
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
|
||
async revert() { return { ok: true } },
|
||
}])
|
||
api.registerEventLeases([{
|
||
id: 'demo.rate.gain',
|
||
label: 'Gain rate',
|
||
type: 'float',
|
||
min: 0.5,
|
||
max: 5,
|
||
maxDurationMs: 3600000,
|
||
async read() { return { ok: true, value: 1 } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
api.registerEventOptionSources([{
|
||
id: 'demo.options.hues',
|
||
label: 'Hues',
|
||
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
|
||
}])
|
||
}`)
|
||
|
||
assertRegistered(record)
|
||
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
|
||
assert.deepEqual(registries.eventBudget('demo.wisps'), {
|
||
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
|
||
})
|
||
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
|
||
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
|
||
})
|
||
|
||
test('the catalog never carries a callable, whichever registration it came from', async () => {
|
||
// The rule every registry in this file already keeps, restated for four new
|
||
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
|
||
// relationship with any of them is naming one by id. A `perform` or a
|
||
// `resolve` riding out would make §F's "a module registers actions server-side
|
||
// and adds no routes for them" false in the one direction nobody would notice.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
api.registerEventLeases([{
|
||
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
|
||
async read() { return { ok: true } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
|
||
])
|
||
}`))
|
||
|
||
for (const a of registries.allEventActions()) {
|
||
assert.equal(a.perform, undefined)
|
||
assert.equal(a.revert, undefined)
|
||
assert.equal(a.cost, undefined)
|
||
}
|
||
for (const l of registries.allEventLeases()) {
|
||
assert.equal(l.read, undefined)
|
||
assert.equal(l.apply, undefined)
|
||
assert.equal(l.restore, undefined)
|
||
}
|
||
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
|
||
})
|
||
|
||
test('a second call is a module changing its mind, and it fails the module alone', () => {
|
||
// `once`, on all four, for the reason every batch registration takes it: a
|
||
// batch is a module's complete statement about what it declares. And the
|
||
// failure is the loader's §4.4 guarantee — recorded against the module, the
|
||
// site still up, nothing committed.
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
|
||
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
|
||
}`)
|
||
assert.equal(record.state, 'startup_failed')
|
||
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
|
||
// Validate-then-commit, per registrant: the FIRST batch is gone too.
|
||
assert.equal(registries.eventBudget('demo.a'), null)
|
||
})
|
||
|
||
test('a module may not name a budget outside its own prefix', () => {
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
|
||
}`)
|
||
assert.equal(record.state, 'startup_failed')
|
||
assert.match(record.reason, /not namespaced "demo\."/)
|
||
})
|
||
|
||
test('a module that registers nothing at all is a module, not a failure', () => {
|
||
// §F states it once because it governs every member: everything a module
|
||
// registers is optional, and core with none of it is still an event engine
|
||
// that can announce, wait, cue a human and publish results.
|
||
const record = loadModule('quiet', 'module.exports = () => {}')
|
||
assertRegistered(record)
|
||
assert.deepEqual(registries.allEventBudgets(), [])
|
||
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
|
||
})
|
||
|
||
// ── The envelope: no failure shape reads as success ────────────────────────
|
||
|
||
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
|
||
// The five §F names, answered by a real module through the real dispatcher.
|
||
// Written as one module with five verbs rather than five modules, because what
|
||
// is under test is the CLASSIFIER and a module per shape would be four extra
|
||
// loader scans saying nothing.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
const verb = (id, perform) => ({
|
||
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
|
||
})
|
||
api.registerEventActions([
|
||
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
|
||
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
|
||
verb('demo.hangs', () => new Promise(() => {})),
|
||
verb('demo.lies', async () => 'fine'),
|
||
verb('demo.forgets', async () => ({ resources: [] })),
|
||
])
|
||
}`))
|
||
|
||
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
|
||
const result = await dispatch.dispatchStep(step(id), { run: RUN })
|
||
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
|
||
assert.ok(result.error, `${id} must say what went wrong`)
|
||
}
|
||
})
|
||
|
||
test('a module that means "never" says so, and only then is it terminal', async () => {
|
||
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
|
||
// than into: an envelope that forgot to say anything gets the benefit of the
|
||
// doubt on the transient question, so only a module that states `retry: false`
|
||
// gets a step marked as never going to work.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([
|
||
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
|
||
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: false, error: 'the shard is restarting' } } },
|
||
])
|
||
}`))
|
||
|
||
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
|
||
assert.equal(never.outcome, 'terminal')
|
||
assert.equal(never.error, 'there is no such gate')
|
||
|
||
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
|
||
assert.equal(later.outcome, 'retry')
|
||
})
|
||
|
||
test('budgetMs is the contract’s deadline, and a wedged module does not hold the tick', async () => {
|
||
// Declared by the module, bounded by the registry, enforced by the dispatcher.
|
||
// Without it a `perform()` awaiting a socket that never answers holds the
|
||
// step's claim until the lease expires and the reclaim re-dispatches it, which
|
||
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
|
||
budgetMs: 120,
|
||
perform: () => new Promise(() => {}),
|
||
}])
|
||
}`))
|
||
|
||
const started = Date.now()
|
||
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
|
||
assert.equal(result.outcome, 'retry')
|
||
assert.match(result.error, /exceeded its 120ms budget/)
|
||
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
|
||
})
|
||
|
||
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
|
||
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
|
||
// members rather than special cases keyed on an action id, so the runner never
|
||
// names a verb — and a module's own long-running action gets `await: 'human'`
|
||
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([
|
||
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true, await: 'human' } } },
|
||
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true, holdFor: 300 } } },
|
||
])
|
||
}`))
|
||
|
||
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
|
||
assert.equal(parked.outcome, 'parked')
|
||
|
||
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
|
||
assert.equal(held.outcome, 'done')
|
||
assert.equal(held.holdSeconds, 300)
|
||
})
|
||
|
||
// ── verify: true is a parameter a module must honour ───────────────────────
|
||
|
||
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
|
||
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
|
||
// cannot enforce the first half — only the module knows what its own writes
|
||
// are — so what IS testable is that the flag arrives, and that it arrives down
|
||
// the same dispatcher the real run uses. A dry run down a second code path is
|
||
// a dry run of the second path.
|
||
const written = []
|
||
global.__rgDemoWrites = written
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
|
||
async perform({ verify, params }) {
|
||
if (verify) return { ok: true, wouldWrite: params.what }
|
||
global.__rgDemoWrites.push(params.what)
|
||
return { ok: true }
|
||
},
|
||
}])
|
||
}`))
|
||
|
||
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
|
||
run: RUN,
|
||
verify: true,
|
||
})
|
||
assert.equal(dry.outcome, 'done')
|
||
assert.deepEqual(written, [], 'a dry run wrote something')
|
||
|
||
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
|
||
assert.deepEqual(written, ['a gate'])
|
||
delete global.__rgDemoWrites
|
||
})
|
||
|
||
test('the whole envelope reaches the module, idempotency key included', async () => {
|
||
// The key is the module's half of a retry that is safe on the game side, and a
|
||
// module cannot pass it down its own wire if core does not hand it over. It is
|
||
// asserted here rather than in the runner's tests because THIS is the surface
|
||
// a module author reads.
|
||
global.__rgDemoEnvelope = null
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
|
||
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
|
||
const envelope = global.__rgDemoEnvelope
|
||
assert.deepEqual(envelope, {
|
||
runId: 7,
|
||
stepId: 1,
|
||
idempotencyKey: 'k-1',
|
||
scope: 'atlantic',
|
||
params: { a: 1 },
|
||
actor: null,
|
||
verify: false,
|
||
})
|
||
delete global.__rgDemoEnvelope
|
||
})
|
||
|
||
// ── Budgets: a module cannot spend what it did not declare ─────────────────
|
||
|
||
test('a cost naming a dimension the module never declared is refused', async () => {
|
||
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
|
||
// not exotic — it is one that declared two budgets and priced a third, which is
|
||
// what a rename looks like. The refusal has its own code because the fix is a
|
||
// module's declaration, not a deployment's cap.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
cost: () => ({ 'demo.wraiths': 2 }),
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
// `settings: null` because this file has no database: passing it explicitly is
|
||
// what tells `mayInvoke` not to go and read the switchboard row, and the
|
||
// enablement layer answers before the cap layer either way.
|
||
const verdict = await authorize.mayInvoke({
|
||
action: registries.eventAction('demo.summon'),
|
||
params: {},
|
||
settings: null,
|
||
})
|
||
assert.equal(verdict.ok, false)
|
||
assert.equal(verdict.code, 'undeclared')
|
||
assert.match(verdict.reason, /demo\.wraiths/)
|
||
})
|
||
|
||
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
cost: (p) => ({ 'demo.wisps': p.count }),
|
||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
// What the switchboard renders: the id the action prices, dressed with what the
|
||
// module called it. Discovered by pricing the declared example, which is why
|
||
// §F makes `example` required on every param.
|
||
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
|
||
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
|
||
])
|
||
|
||
// And with no settings row and no run, the answer is yes: a declared budget is
|
||
// a dimension that can be counted, not a bound that has been set.
|
||
const verdict = await authorize.mayInvoke({
|
||
action: registries.eventAction('demo.summon'),
|
||
params: { count: 4 },
|
||
settings: null,
|
||
})
|
||
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
|
||
})
|
||
|
||
// ── Option sources ─────────────────────────────────────────────────────────
|
||
|
||
test('a module answers its own option source, and core normalises what comes back', async () => {
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventOptionSources([{
|
||
id: 'demo.options.hues', label: 'Hues',
|
||
async resolve() {
|
||
return [
|
||
{ value: 1157, label: 'Blood', group: 'Reds' },
|
||
{ value: '2213', label: 'Ice' },
|
||
{ value: '', label: 'a blank nobody can pick' },
|
||
'not an option at all',
|
||
]
|
||
},
|
||
}])
|
||
}`))
|
||
|
||
const answer = await registries.resolveOptionSource('demo.options.hues')
|
||
assert.equal(answer.ok, true)
|
||
assert.equal(answer.owner, 'demo')
|
||
// Coerced to strings, because this array is rendered into a `<select>` and
|
||
// submitted back as text; and the two entries that could not become an option
|
||
// are dropped rather than becoming one that submits "undefined".
|
||
assert.deepEqual(answer.options, [
|
||
{ value: '1157', label: 'Blood', group: 'Reds' },
|
||
{ value: '2213', label: 'Ice' },
|
||
])
|
||
})
|
||
|
||
test('every way an option source can fail degrades the field rather than breaking it', async () => {
|
||
// §F: a refusal degrades the field to free text with a visible warning. So none
|
||
// of these throws, and each says something an operator can act on — an
|
||
// authoring form a shard outage can make unusable is a worse failure than the
|
||
// typo the dropdown exists to prevent.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
|
||
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
|
||
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
|
||
])
|
||
}`))
|
||
|
||
const threw = await registries.resolveOptionSource('demo.options.throws')
|
||
assert.equal(threw.ok, false)
|
||
assert.match(threw.reason, /could not be read/)
|
||
|
||
const lied = await registries.resolveOptionSource('demo.options.lies')
|
||
assert.equal(lied.ok, false)
|
||
assert.match(lied.reason, /no option list/)
|
||
|
||
// An EMPTY list is not a failure. A source that legitimately has nothing to
|
||
// offer today — no landmarks configured yet — must not be reported as broken,
|
||
// because the two have different fixes.
|
||
const empty = await registries.resolveOptionSource('demo.options.empty')
|
||
assert.equal(empty.ok, true)
|
||
assert.deepEqual(empty.options, [])
|
||
|
||
const missing = await registries.resolveOptionSource('demo.options.gone')
|
||
assert.equal(missing.ok, false)
|
||
assert.match(missing.reason, /no module registers/)
|
||
})
|
||
|
||
// ── Uninstall is dormancy, never an error ──────────────────────────────────
|
||
|
||
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
|
||
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
|
||
// and the run degrades — never a silent skip"*. The scenario is real and
|
||
// ordinary: a module was uninstalled between the publish that pinned the
|
||
// version and the run that dispatches it.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
assert.equal(registries.isEventAction('demo.summon'), true)
|
||
|
||
// The uninstall: a fresh scan of a directory the module is no longer in.
|
||
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
|
||
loadModule('other', 'module.exports = () => {}')
|
||
assert.equal(registries.isEventAction('demo.summon'), false)
|
||
|
||
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
|
||
assert.equal(result.outcome, 'terminal')
|
||
assert.equal(result.dormant, true)
|
||
assert.match(result.error, /no module registers "demo\.summon"/)
|
||
})
|