fix(core): the last fixes' core half (W1 W2 W5 W19)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's 2026-10-22 decisions. W19: one module per site at boot, as at install. The first module directory by name holds the site, whether or not it then loads; every other is recorded startup_failed at the manifest stage, before a line of its code is required, with a reason naming the module that holds the site. Module-uo and Module-Rust side by side used to fail the second with a slot collision. Loader, lifecycle, schema and event-contract tests that loaded several fixture modules now load each case alone. W1: a reconcile files a resource the game no longer has, and that is past its `until`, as expired rather than orphaned, with the same log line expired() writes. No MODULE_API bump: `until` and reconcile's answer are unchanged. W5: a dry run that passes on the draft is kept with the fingerprint of the validated spec (three nullable columns on event_definitions). Publish stamps the new version verified, with that dry run's reviewer and time, when the spec it freezes has the same fingerprint, and writes version.verified on the occurrences it re-pins. Verify answers draftRecorded and publish answers verified; OpenAPI regenerated. W2: the run console says "Phase p1: steps done, waiting to advance" and "Why this phase has not advanced", not "has not started". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -478,7 +478,7 @@ test('a resource the module no longer has becomes orphaned, never reverted', asy
|
||||
addResource({ ref: '0x2' })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()].find((r) => r.ref === '0x2').status, 'orphaned')
|
||||
assert.equal([...store.rows.values()].find((r) => r.ref === '0x1').status, 'confirmed')
|
||||
assert.ok(store.log.some((e) => e.kind === 'resource.orphaned'))
|
||||
@@ -510,7 +510,7 @@ test('a module with no reconcile is not broken; core keeps believing its ledger'
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource()
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 1 })
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 1 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
@@ -532,7 +532,7 @@ test('placeholders are not asked about, because there is nothing to ask yet', as
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: resourcesDb.STEP_KIND, ref: 'a'.repeat(40), status: 'pending' })
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'pending')
|
||||
})
|
||||
|
||||
@@ -556,7 +556,7 @@ test('a lease row had no reconcile path at all until core.lease grew one', async
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'orphaned')
|
||||
})
|
||||
|
||||
@@ -566,7 +566,7 @@ test('a lease the shard still has a record of stays put', async () => {
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
@@ -705,6 +705,61 @@ test('an expiry nobody ledgered, or a malformed one, is not an error', async ()
|
||||
assert.equal(store.log.length, 0)
|
||||
})
|
||||
|
||||
// W1 (Rust PLAN_LAST_FIXES): on a reconnect the module's reconcile can run
|
||||
// before the game's backlog reports an expiry. A row past its `until` that the
|
||||
// game no longer has is the deadline working, so it is filed expired at once
|
||||
// rather than orphaned and then expired five seconds later.
|
||||
|
||||
test('a reconcile files a row gone past its until as expired, not orphaned', async () => {
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
const now = new Date('2026-10-22T14:06:35Z')
|
||||
addResource({ kind: 'zone', ref: 'past', lease_until: new Date('2026-10-22T14:06:00Z') })
|
||||
addResource({ kind: 'zone', ref: 'future', lease_until: new Date('2026-10-22T14:30:00Z') })
|
||||
addResource({ kind: 'zone', ref: 'none' })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo', now)
|
||||
assert.deepEqual(summary, { asked: 3, inForce: 0, orphaned: 2, expired: 1, unanswered: 0 })
|
||||
const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status
|
||||
assert.equal(status('past'), 'expired')
|
||||
// Before its deadline, or with none, gone is still nobody-was-looking.
|
||||
assert.equal(status('future'), 'orphaned')
|
||||
assert.equal(status('none'), 'orphaned')
|
||||
assert.deepEqual(
|
||||
store.log.filter((e) => e.detail.resource === 'zone:past').map((e) => e.kind),
|
||||
['resource.expired'],
|
||||
)
|
||||
|
||||
// The game's own word arriving afterwards finds nothing left to close.
|
||||
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'past' }), { expired: 0 })
|
||||
assert.equal(store.log.filter((e) => e.detail.resource === 'zone:past').length, 1)
|
||||
})
|
||||
|
||||
test('a row past its until that the game still has stays in force', async () => {
|
||||
// A deadline is the game's promise, not core's clock: a zone the game has not
|
||||
// erased yet is still up, whatever core's time says.
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: ['late'] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: 'zone', ref: 'late', lease_until: new Date('2026-10-22T14:00:00Z') })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo', new Date('2026-10-22T14:06:35Z'))
|
||||
assert.equal(summary.inForce, 1)
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
test('a reconcile that expires the last row of a finished run completes its cleanup', async () => {
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: 'zone', ref: 'x', lease_until: new Date('2026-10-22T14:00:00Z') })
|
||||
store.cleanupStatus = 'incomplete'
|
||||
|
||||
await cleanup.reconcileModule('demo', new Date('2026-10-22T14:06:35Z'))
|
||||
assert.equal(store.cleanupStatus, 'complete')
|
||||
})
|
||||
|
||||
test('the last row of a finished run expiring completes an incomplete cleanup', async () => {
|
||||
stubExpiry()
|
||||
addResource({ kind: 'zone', ref: 'x' })
|
||||
|
||||
@@ -694,14 +694,24 @@ test('inForce() is optional, and a fourth question rather than a fourth spelling
|
||||
}])
|
||||
}`
|
||||
|
||||
//
|
||||
// Each in a modules directory of its own, because a site runs one module (W19):
|
||||
// side by side, `leaseb` and `leasec` would be refused for `leasea`'s sake.
|
||||
const fresh = () => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
|
||||
}
|
||||
|
||||
const without = loadModule('leasea', lease('leasea', ''))
|
||||
assertRegistered(without)
|
||||
assert.equal(registries.eventLease('leasea.rate.gain').inForce, null)
|
||||
|
||||
fresh()
|
||||
const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },'))
|
||||
assertRegistered(withIt)
|
||||
assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function')
|
||||
|
||||
fresh()
|
||||
|
||||
// And a declaration that is present but not callable fails the module rather
|
||||
// than being ignored -- the same posture the other three take. A module that
|
||||
// meant to answer and cannot is a module whose leases would silently never be
|
||||
|
||||
@@ -477,3 +477,20 @@ test('the size bounds hold', () => {
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`))
|
||||
})
|
||||
|
||||
test('a fingerprint is the plan, not the order its keys were written in', () => {
|
||||
// W5: publish compares the fingerprint of the spec it freezes with the one a
|
||||
// draft dry run kept. A spec that round-trips through a JSON column with its
|
||||
// keys reordered is the same plan and must carry its pass over.
|
||||
const a = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] }
|
||||
const b = { phases: [{ steps: [], label: 'Main', key: 'main' }], schedule: { kind: 'manual' } }
|
||||
assert.equal(spec.fingerprint(a), spec.fingerprint(b))
|
||||
assert.match(spec.fingerprint(a), /^[0-9a-f]{64}$/)
|
||||
|
||||
// Array order IS the plan: steps run in it.
|
||||
const c = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [1, 2] }] }
|
||||
const d = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [2, 1] }] }
|
||||
assert.notEqual(spec.fingerprint(c), spec.fingerprint(d))
|
||||
// And a key left undefined is the key left out, as JSON has it.
|
||||
assert.equal(spec.fingerprint({ ...a, starts: undefined }), spec.fingerprint(a))
|
||||
})
|
||||
|
||||
@@ -152,6 +152,12 @@ function installStubs() {
|
||||
Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId })
|
||||
}
|
||||
}
|
||||
definitionsDb.markDraftVerified = async (id, { at = new Date(), userId = null, hash }) => {
|
||||
const existing = store.definitions.get(id)
|
||||
if (existing) {
|
||||
Object.assign(existing, { draft_verified_at: at, draft_verified_by: userId, draft_verified_hash: hash })
|
||||
}
|
||||
}
|
||||
definitionsDb.archive = async (id, userId) => {
|
||||
const existing = store.definitions.get(id)
|
||||
if (existing) Object.assign(existing, { state: 'archived', updated_by: userId })
|
||||
@@ -998,6 +1004,106 @@ test('a draft is verified against its working spec, and the pass is not recorded
|
||||
assert.equal(res.body.recorded, false)
|
||||
assert.equal(res.body.report.ok, true)
|
||||
assert.equal(res.body.report.steps, 1)
|
||||
// Kept for publish to compare against instead (W5).
|
||||
assert.equal(res.body.draftRecorded, true)
|
||||
})
|
||||
|
||||
// W5 (Rust PLAN_LAST_FIXES): an author who dry-ran the draft and then published
|
||||
// found v1 "not dry run", and its scheduled and trigger starts held, until they
|
||||
// ran the same check again.
|
||||
|
||||
test('a dry run that passed on the draft counts for the version published from it', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
const passedAt = store.definitions.get(id).draft_verified_at
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.statusCode, 200)
|
||||
assert.equal(published.body.verified, true)
|
||||
|
||||
// The reviewer and time are the dry run's, not the publisher's and now.
|
||||
const version = store.versions.get(published.body.versionId)
|
||||
assert.equal(version.verified_by, EDITOR.id)
|
||||
assert.equal(version.verified_at, passedAt)
|
||||
const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.ok(after.body.event.currentVersionVerifiedAt)
|
||||
})
|
||||
|
||||
test('an edit after the draft dry run means nothing carries over', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
await call(ctrl.update, {
|
||||
user: EDITOR,
|
||||
params: { id: String(id) },
|
||||
body: draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [announceStep('The gates open at midnight.')] }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, false)
|
||||
assert.equal(store.versions.get(published.body.versionId).verified_at, null)
|
||||
})
|
||||
|
||||
test('a title edit after the draft dry run still carries it over, because the plan is the same', async () => {
|
||||
// The fingerprint is the spec's, and the title is not part of the plan a dry
|
||||
// run checks.
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
await call(ctrl.update, { user: EDITOR, params: { id: String(id) }, body: { title: 'Renamed' } })
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, true)
|
||||
})
|
||||
|
||||
test('a draft dry run with findings keeps nothing to carry over', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
||||
const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
assert.equal(res.body.report.ok, false)
|
||||
assert.equal(res.body.draftRecorded, false)
|
||||
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: true } })
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, false)
|
||||
})
|
||||
|
||||
test('the carried pass is written on the occurrences that publish re-pins', async () => {
|
||||
// v1 published and scheduled, then v2 published from a draft that was
|
||||
// dry-run first: the held occurrence moves to v2 and says why it may start.
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
await call(ctrl.startRun, { user: ADMIN, params: { id: String(id) }, body: { scheduledFor: '2099-11-01T18:00:00.000Z' } })
|
||||
// A ready definition dry-runs its published version, so the draft pass is
|
||||
// planted the way a dry run on the draft would have left it.
|
||||
const edited = draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [announceStep('Later.')] }],
|
||||
},
|
||||
})
|
||||
await call(ctrl.update, { user: EDITOR, params: { id: String(id) }, body: edited })
|
||||
const spec = require('../src/events/spec')
|
||||
const saved = store.definitions.get(id).spec
|
||||
const checked = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) })
|
||||
store.definitions.get(id).draft_verified_hash = spec.fingerprint(checked.spec)
|
||||
store.definitions.get(id).draft_verified_by = EDITOR.id
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.repinned, 1)
|
||||
assert.equal(published.body.verified, true)
|
||||
const line = store.log.find((e) => e.kind === 'version.verified')
|
||||
assert.ok(line, 'the re-pinned occurrence says it may now start')
|
||||
assert.equal(line.detail.fromDraft, true)
|
||||
assert.equal(line.detail.versionId, published.body.versionId)
|
||||
})
|
||||
|
||||
test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => {
|
||||
|
||||
@@ -143,10 +143,9 @@ beforeEach(() => {
|
||||
|
||||
// ── The happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
test('every module is recorded, booted with its ctx and marked started, in scan order', async () => {
|
||||
test('the site\'s module is recorded, booted with its ctx and marked started', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -155,15 +154,35 @@ test('every module is recorded, booted with its ctx and marked started, in scan
|
||||
// The reconcile order IS the design (§2.4): clear the last boot's outcomes
|
||||
// first, so what is on display afterwards is what this boot did.
|
||||
assert.equal(model.calls[0], 'beginBoot')
|
||||
assert.deepEqual(model.calls.slice(1, 3), ['recordInstalled:aaa', 'recordInstalled:bbb'])
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx'])
|
||||
assert.equal(model.calls[1], 'recordInstalled:aaa')
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'started')
|
||||
assert.equal(model.rows.get('bbb').state, 'started')
|
||||
assert.equal(model.rows.get('aaa').state, 'started')
|
||||
// §2.4's metadata refresh: the row carries what the admin screen shows.
|
||||
assert.equal(model.rows.get('aaa').name, 'Module aaa')
|
||||
assert.equal(model.rows.get('aaa').version, '1.2.3')
|
||||
})
|
||||
|
||||
test('a second module on the volume gets a failed row and is never booted', async () => {
|
||||
// W19. Hand-copied beside the site's module, it still needs a row: without one
|
||||
// Admin → Modules could not say why it is not running.
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(model.rows.get('aaa').state, 'started')
|
||||
const refused = model.rows.get('bbb')
|
||||
assert.equal(refused.state, 'startup_failed')
|
||||
assert.equal(refused.failureStage, 'manifest')
|
||||
assert.match(refused.failureReason, /already runs the module "aaa", and a site runs one module/)
|
||||
assert.equal(refused.name, 'Module bbb')
|
||||
})
|
||||
|
||||
test('a hand-placed directory gets a row with no provenance', async () => {
|
||||
// §2.5 keeps a directory dropped on the volume by hand a supported install.
|
||||
// Without a row it could never be disabled, and nothing could report it.
|
||||
@@ -193,23 +212,19 @@ test('a module with no onBoot still reaches started', async () => {
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('an onBoot that throws fails its own module and no one else', async () => {
|
||||
test('an onBoot that throws is a failed state, not a failed boot', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("cache warm-up failed")', log: file })
|
||||
writeModule('ccc', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
await assert.doesNotReject(() => lifecycle.boot({ modules: loader, model }))
|
||||
|
||||
assert.deepEqual(noted(file), ['boot:bbb:ctx'])
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'bbb').stage, 'boot')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /cache warm-up failed/)
|
||||
assert.equal(model.rows.get('bbb').failureStage, 'boot')
|
||||
// The one that matters: the module AFTER the failure still booted.
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx', 'boot:ccc:ctx'])
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'started')
|
||||
})
|
||||
|
||||
test('a module whose onBoot failed keeps its URLs and answers 503', async () => {
|
||||
@@ -333,25 +348,28 @@ test('a process that never scanned writes nothing at all', async () => {
|
||||
|
||||
// ── Shutdown ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('shutdown runs started modules in reverse order and skips the rest', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("no")', shutdown: '', log: file })
|
||||
writeModule('ccc', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('shutdown runs a started module and skips one whose boot failed', async () => {
|
||||
for (const [boot, expected] of [
|
||||
['', ['shutdown:aaa']],
|
||||
// Its onBoot threw, so it has a half-built world that its onShutdown was
|
||||
// never written to tear down.
|
||||
['throw new Error("no")', []],
|
||||
]) {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-lifecycle-'))
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot, shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '') // only the shutdown half is under test
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '') // only the shutdown half is under test
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
|
||||
// Reverse of boot order, and `bbb` absent: its onBoot threw, so it has a
|
||||
// half-built world that its onShutdown was never written to tear down.
|
||||
assert.deepEqual(noted(file), ['shutdown:ccc', 'shutdown:aaa'])
|
||||
assert.deepEqual(noted(file).filter(Boolean), expected)
|
||||
}
|
||||
})
|
||||
|
||||
test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'await new Promise(() => {})', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
@@ -361,15 +379,14 @@ test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
const started = Date.now()
|
||||
await lifecycle.shutdown({ modules: loader, budgetMs: 50 })
|
||||
|
||||
// zzz never returns; it is abandoned and aaa still gets its turn. The
|
||||
// alternative is a host where `systemctl stop` hangs until SIGKILL.
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
// zzz never returns, and is abandoned. The alternative is a host where
|
||||
// `systemctl stop` hangs until SIGKILL.
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz'])
|
||||
assert.ok(Date.now() - started < 2000, 'shutdown must not wait on a hung hook')
|
||||
})
|
||||
|
||||
test('a hook that throws does not stop the ones behind it', async () => {
|
||||
test('a hook that throws does not fail the shutdown', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'throw new Error("close failed")', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
@@ -377,7 +394,7 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
fs.writeFileSync(file, '')
|
||||
|
||||
await assert.doesNotReject(() => lifecycle.shutdown({ modules: loader }))
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz'])
|
||||
})
|
||||
|
||||
// ── stop(): the admin panel's Disable ──────────────────────────────────────
|
||||
@@ -385,10 +402,9 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
// Phase 4, §2.7.2 decision 3. Phase 2's disable moved a record and left the
|
||||
// module running; the whole point of these tests is that it no longer does.
|
||||
|
||||
test('stop runs that one module\'s onShutdown and leaves the others alone', async () => {
|
||||
test('stop runs the module\'s onShutdown and disables it', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -398,10 +414,8 @@ test('stop runs that one module\'s onShutdown and leaves the others alone', asyn
|
||||
const result = await lifecycle.stop('aaa', { modules: loader, model })
|
||||
|
||||
assert.deepEqual(result, { stopped: true, error: null })
|
||||
// Only aaa. This is the difference from shutdown(), which runs everything.
|
||||
assert.deepEqual(noted(file), ['shutdown:aaa'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'disabled')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'started', 'the other module keeps running')
|
||||
assert.equal(model.rows.get('aaa').state, 'disabled')
|
||||
})
|
||||
|
||||
|
||||
@@ -95,6 +95,17 @@ const oneRoute = (prefix, tier = 'public') => ({
|
||||
|
||||
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
||||
|
||||
/**
|
||||
* One module in a modules directory of its own, loaded. A site runs one module
|
||||
* (W19), so a test that checks several cases loads each case by itself rather
|
||||
* than side by side, where every one after the first would be refused.
|
||||
*/
|
||||
function alone(id, spec, tiers) {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
writeModule(id, spec)
|
||||
return freshLoader(tmpRoot, tiers)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
})
|
||||
@@ -140,23 +151,65 @@ test('load() is once-only, so a second call cannot double-mount', () => {
|
||||
assert.equal(loader.list().length, 1)
|
||||
})
|
||||
|
||||
test('modules load in alphabetical order, since nothing computes a precedence', () => {
|
||||
for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
|
||||
// ── One module per site (W19) ──────────────────────────────────────────────
|
||||
|
||||
test('the first module by name holds the site; every other is refused unrequired', () => {
|
||||
// Install refuses a second module, so this is the hand-copied case: Module-uo
|
||||
// and Module-Rust both fill `admin.users.detail`, and the second used to fail
|
||||
// with a slot collision that named neither the rule nor the fix.
|
||||
writeModule('ccc', { server: 'throw new Error("ccc should never be required")' })
|
||||
writeModule('aaa', { server: 'module.exports = () => {}' })
|
||||
writeModule('bbb', { manifest: { version: '2.1.0' }, server: 'throw new Error("bbb should never be required")' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
for (const id of ['bbb', 'ccc']) {
|
||||
const refused = stateOf(loader, id)
|
||||
assert.equal(refused.state, 'startup_failed')
|
||||
assert.equal(refused.stage, 'manifest')
|
||||
assert.match(refused.reason, /already runs the module "aaa", and a site runs one module/)
|
||||
assert.match(refused.reason, new RegExp(`Remove "${id}" or "aaa"`))
|
||||
}
|
||||
// The version still reaches the admin panel, read from module.json alone.
|
||||
assert.equal(stateOf(loader, 'bbb').version, '2.1.0')
|
||||
})
|
||||
|
||||
test('the first module holds the site even when it fails to load', () => {
|
||||
// Which module a site runs is decided by the directory, not by which one
|
||||
// happened to start: a broken first module does not hand the site to the
|
||||
// second, where an operator would be debugging a module they did not mean to run.
|
||||
writeModule('aaa', { server: 'throw new Error("boom")' })
|
||||
writeModule('bbb', { server: 'module.exports = () => {}' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
const bad = stateOf(loader, 'aaa')
|
||||
assert.equal(bad.state, 'startup_failed')
|
||||
assert.match(bad.reason, /boom/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already runs the module "aaa"/)
|
||||
})
|
||||
|
||||
test('a second module never mounts, registers or claims a table', () => {
|
||||
const tiers = emptyTiers()
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
writeModule('bbb', {
|
||||
...oneRoute('/other'),
|
||||
schema: 'CREATE TABLE IF NOT EXISTS bbb_x (id INT);',
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/thing'), true)
|
||||
assert.equal(claims('/other'), false)
|
||||
assert.deepEqual(loader.fragments().map((f) => f.id), [])
|
||||
assert.deepEqual(loader.bootable().map((b) => b.id), ['aaa'])
|
||||
})
|
||||
|
||||
// ── The failing module fails alone ─────────────────────────────────────────
|
||||
|
||||
test('a module whose entry point throws does not stop the others loading', () => {
|
||||
writeModule('aaa', { server: 'module.exports = () => {}' })
|
||||
writeModule('bbb', { server: 'throw new Error("boom")' })
|
||||
writeModule('ccc', { server: 'module.exports = () => {}' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
||||
|
||||
test('a module whose entry point throws is recorded, and the site still comes up', () => {
|
||||
const loader = alone('bbb', { server: 'throw new Error("boom")' })
|
||||
const bad = stateOf(loader, 'bbb')
|
||||
assert.equal(bad.state, 'startup_failed')
|
||||
assert.match(bad.reason, /boom/)
|
||||
@@ -204,15 +257,6 @@ test('an unknown extension slot is rejected; only core may declare a slot', () =
|
||||
|
||||
// ── Prefix ownership ───────────────────────────────────────────────────────
|
||||
|
||||
test('two modules cannot claim the same prefix; the first one wins', () => {
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
writeModule('bbb', oneRoute('/thing'))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/)
|
||||
})
|
||||
|
||||
test('a module cannot take a prefix core owns, asked of the live tier routers', () => {
|
||||
// Deliberately the REAL routers rather than a hardcoded prefix list. The spike
|
||||
// hardcoded core's ~24 prefixes and they were already stale; asking express
|
||||
@@ -226,41 +270,41 @@ test('a module cannot take a prefix core owns, asked of the live tier routers',
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
|
||||
writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
|
||||
assert.match(stateOf(alone('greedy', { manifest: { mounts: { admin: ['/users'] } } }, real), 'greedy').reason, /owned by core/)
|
||||
assert.match(
|
||||
stateOf(alone('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } }, real), 'alsogreedy').reason,
|
||||
/owned by core/,
|
||||
)
|
||||
// Free in every tier core actually mounts, so it must be allowed through.
|
||||
writeModule('polite', oneRoute('/widgets', 'player'))
|
||||
|
||||
const loader = freshLoader(tmpRoot, real)
|
||||
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
|
||||
assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
|
||||
assert.equal(stateOf(loader, 'polite').state, 'registered')
|
||||
assert.equal(stateOf(alone('polite', oneRoute('/widgets', 'player'), real), 'polite').state, 'registered')
|
||||
})
|
||||
|
||||
test('a root-mounted core layer does not make every prefix look taken', () => {
|
||||
// public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
|
||||
// router; both match every path. Counting them would report every prefix as
|
||||
// owned and no module could ever mount.
|
||||
const tiers = emptyTiers()
|
||||
tiers.public.use('/posts', express.Router())
|
||||
tiers.public.use('/', express.Router())
|
||||
const coreTiers = () => {
|
||||
const tiers = emptyTiers()
|
||||
tiers.public.use('/posts', express.Router())
|
||||
tiers.public.use('/', express.Router())
|
||||
return tiers
|
||||
}
|
||||
|
||||
writeModule('fine', oneRoute('/widgets'))
|
||||
writeModule('taken', oneRoute('/posts'))
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
assert.match(stateOf(loader, 'taken').reason, /owned by core/)
|
||||
assert.equal(stateOf(alone('fine', oneRoute('/widgets'), coreTiers()), 'fine').state, 'registered')
|
||||
assert.match(stateOf(alone('taken', oneRoute('/posts'), coreTiers()), 'taken').reason, /owned by core/)
|
||||
})
|
||||
|
||||
test('a prefix with a slash or a parameter in it is rejected', () => {
|
||||
// A prefix that could contain either would let a module reach outside the slot
|
||||
// it was given.
|
||||
writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
|
||||
writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
|
||||
assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
|
||||
assert.match(
|
||||
stateOf(alone('nested', { manifest: { mounts: { public: ['/a/b'] } } }), 'nested').reason,
|
||||
/bad prefix "\/a\/b"/,
|
||||
)
|
||||
assert.match(
|
||||
stateOf(alone('parameterised', { manifest: { mounts: { public: ['/:id'] } } }), 'parameterised').reason,
|
||||
/bad prefix "\/:id"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('registering a prefix that was never declared is rejected', () => {
|
||||
@@ -314,17 +358,6 @@ test('a schema fragment table must carry the module id as a prefix', () => {
|
||||
assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
|
||||
})
|
||||
|
||||
test('two modules cannot own the same table either', () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
|
||||
writeModule('bbb', {
|
||||
manifest: { id: 'bbb' },
|
||||
schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
|
||||
})
|
||||
|
||||
test('declaring a schema without a purge is rejected', () => {
|
||||
const dir = path.join(tmpRoot, 'noway')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
@@ -356,8 +389,7 @@ test('a fragment may only use the four verbs core\'s own schema.sql uses', () =>
|
||||
['wiper', 'DELETE FROM wiper_x;', 'DELETE'],
|
||||
['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'],
|
||||
]) {
|
||||
writeModule(id, { schema: sql })
|
||||
const reason = stateOf(freshLoader(tmpRoot), id).reason
|
||||
const reason = stateOf(alone(id, { schema: sql }), id).reason
|
||||
assert.match(reason, new RegExp(`starts with "${verb}"`))
|
||||
}
|
||||
})
|
||||
@@ -386,24 +418,20 @@ test('a CREATE TABLE without IF NOT EXISTS is rejected', () => {
|
||||
test('a fragment carrying an unreadable file fails the module, not the boot', () => {
|
||||
writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' })
|
||||
fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql'))
|
||||
writeModule('fine', oneRoute('/ok'))
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
})
|
||||
|
||||
test('fragments() lists only registered modules that have one', () => {
|
||||
writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
||||
writeModule('nodb', oneRoute('/plain'))
|
||||
writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' })
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('fragments() lists only a registered module that has one', () => {
|
||||
const loader = alone('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
||||
const frags = loader.fragments()
|
||||
|
||||
assert.deepEqual(frags.map((f) => f.id), ['withdb'])
|
||||
// An absolute path, so the replay never has to know how a module dir is laid out.
|
||||
assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql'))
|
||||
|
||||
assert.deepEqual(alone('nodb', oneRoute('/plain')).fragments(), [])
|
||||
assert.deepEqual(alone('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' }).fragments(), [])
|
||||
})
|
||||
|
||||
test('fragments() before load() throws, like every other accessor', () => {
|
||||
@@ -417,20 +445,20 @@ test('fragments() before load() throws, like every other accessor', () => {
|
||||
// ── Mounting and the dispatch guard ────────────────────────────────────────
|
||||
|
||||
test('a registered module answers on its prefix; a failed one is simply absent', async () => {
|
||||
writeModule('good', oneRoute('/widgets'))
|
||||
writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })
|
||||
|
||||
const tiers = emptyTiers()
|
||||
freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
for (const [id, spec, url, status] of [
|
||||
['good', oneRoute('/widgets'), '/public/widgets', 200],
|
||||
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
|
||||
// not exist at all (§4.4, left-hand column).
|
||||
assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
|
||||
} finally {
|
||||
await app.close()
|
||||
['bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' }, '/public/broken', 404],
|
||||
]) {
|
||||
const tiers = emptyTiers()
|
||||
alone(id, spec, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}${url}`)).status, status, id)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -601,24 +629,20 @@ test('a module with no hooks is bootable, and offers nothing to shut down', () =
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
})
|
||||
|
||||
test('shutdown hooks come back in reverse order, and only for started modules', () => {
|
||||
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
|
||||
writeModule('aaa', { server: hook })
|
||||
writeModule('bbb', { server: hook })
|
||||
writeModule('ccc', { server: hook })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('a shutdown hook comes back only for a started module', () => {
|
||||
const loader = alone('aaa', { server: 'module.exports = (ctx, api) => api.onShutdown(async () => {})' })
|
||||
|
||||
// Nothing has started yet, so there is nothing to tear down.
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
|
||||
loader.setState('aaa', 'started')
|
||||
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
||||
loader.setState('ccc', 'started')
|
||||
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['aaa'])
|
||||
|
||||
// Reverse registration order (§2.5), and `bbb` is absent: a module whose
|
||||
// onBoot threw is mid-way through a warm-up it never finished, and handing it
|
||||
// a half-built world to tear down is worse than not closing cleanly.
|
||||
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
|
||||
// A module whose onBoot threw is mid-way through a warm-up it never finished,
|
||||
// and handing it a half-built world to tear down is worse than not closing
|
||||
// cleanly.
|
||||
loader.setState('aaa', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
})
|
||||
|
||||
// ── Failure stages ─────────────────────────────────────────────────────────
|
||||
@@ -636,11 +660,8 @@ test('a failure is recorded against the §4.3 step that produced it', () => {
|
||||
['f-require', { server: 'throw new Error("boom")' }, 'require'],
|
||||
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
|
||||
]
|
||||
for (const [id, spec] of cases) writeModule(id, spec)
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
for (const [id, , stage] of cases) {
|
||||
const state = stateOf(loader, id)
|
||||
for (const [id, spec, stage] of cases) {
|
||||
const state = stateOf(alone(id, spec), id)
|
||||
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
|
||||
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
|
||||
}
|
||||
@@ -668,26 +689,30 @@ test('a module that fails AFTER registering leaves nothing in the registries', (
|
||||
assert.equal(registries.announceLeg('halfway.leg'), null)
|
||||
})
|
||||
|
||||
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
||||
test('a module colliding with a name core registered fails alone, unmounted', () => {
|
||||
// Core registers through the same staging area a module does, and may name
|
||||
// anything; with one module per site, core is the registrant a module can
|
||||
// collide with. Staged by hand between the reset and the load, which is the
|
||||
// order app.js runs registerCore() and modules.load() in.
|
||||
const tiers = emptyTiers()
|
||||
writeModule('first', {
|
||||
manifest: { mounts: { public: ['/first'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
||||
}`,
|
||||
})
|
||||
writeModule('second', {
|
||||
manifest: { mounts: { public: ['/second'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'second.shared', label: 'Mine' }])
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
registries._reset()
|
||||
const core = registries.stage('core')
|
||||
core.registerNotificationStreams([{ id: 'second.shared', label: 'Shared' }])
|
||||
registries.apply(core.staged)
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'first').state, 'registered')
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "core"/)
|
||||
// Not even the claim that did not collide.
|
||||
assert.equal(registries.isValidStream('second.ok'), false)
|
||||
// And the loser is not mounted at all. Asked of the live router the way the
|
||||
@@ -695,7 +720,6 @@ test('a module colliding with an already-registered name fails alone, unmounted'
|
||||
// produces two (the dispatch guard, then the module's router).
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/first'), true)
|
||||
assert.equal(claims('/second'), false)
|
||||
})
|
||||
|
||||
@@ -756,21 +780,23 @@ test('an entry that escapes the module directory is refused before anything else
|
||||
|
||||
test('an entry that is not a .js file, or is missing, is refused', () => {
|
||||
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'aaa').reason, /must name a \.js file/)
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'bbb').reason, /is missing/)
|
||||
})
|
||||
|
||||
test('a malformed client key is a loud failure, not an ignored setting', () => {
|
||||
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
|
||||
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
|
||||
writeModule('ccc', { manifest: { client: {} } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
|
||||
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
|
||||
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
|
||||
for (const [id, client, reason] of [
|
||||
['aaa', 'client/dist/entry.js', /client must be an object/],
|
||||
['bbb', { entry: 'x/e.js', chunks: ['a.js'] }, /unknown key "client\.chunks"/],
|
||||
['ccc', {}, /client\.entry must be a path/],
|
||||
]) {
|
||||
const state = stateOf(alone(id, { manifest: { client } }), id)
|
||||
assert.match(state.reason, reason)
|
||||
assert.equal(state.stage, 'manifest')
|
||||
}
|
||||
})
|
||||
|
||||
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
|
||||
|
||||
@@ -150,23 +150,18 @@ test('fragments are split exactly the way core schema.sql is', async () => {
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('a fragment that throws fails its own module and no one else', async () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' })
|
||||
test('a fragment that throws fails its module, not the boot', async () => {
|
||||
writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' })
|
||||
writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder('bbb_boom')
|
||||
|
||||
// Never throws — this is called on the boot path, between core's schema and
|
||||
// seedDefaults(), and one bad module must not stop the site coming up.
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
// seedDefaults(), and a bad module must not stop the site coming up.
|
||||
await assert.doesNotReject(() => replayFragments({ query: rec.query, modules: loader }))
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
|
||||
// The one that matters: the module AFTER the failure still got its tables.
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
||||
assert.equal(rec.ran.length, 3)
|
||||
assert.equal(rec.ran.length, 1)
|
||||
})
|
||||
|
||||
test('a fragment stops at its first failing statement', async () => {
|
||||
@@ -217,15 +212,13 @@ test('a module that failed validation is not replayed at all', async () => {
|
||||
// It is never going to run, so creating its tables would leave an operator
|
||||
// with rows belonging to a module that does not load.
|
||||
writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' })
|
||||
writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.equal(stateOf(loader, 'bad').state, 'startup_failed')
|
||||
assert.equal(rec.ran.length, 1)
|
||||
assert.match(rec.ran[0], /good_x/)
|
||||
assert.equal(rec.ran.length, 0)
|
||||
})
|
||||
|
||||
// ── The seed script ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user