The screen slice 1's API was written for: install from a release URL, enable, disable, uninstall, purge, and restart. Admin-only, matching the server, and core's own screen because it is how a module reaches the volume at all. 182 client tests (+21), manifest and OpenAPI unchanged. Everything that decides what a row SAYS and which buttons it offers is in `lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the same reason `lib/adminNav.js` is. The JSX renders what it returns. Three sources of truth, and they are allowed to disagree -------------------------------------------------------- The row records what the operator decided and what the last boot did; the loader says what is mounted and answering; the volume says whether there is a directory at all. Picking one and rendering it is simpler and lies. The case that makes it concrete is the one decision 3 creates on purpose: disable a module (its onShutdown runs) and enable it again, and the row says `enabled` while the loader still says `disabled` because nothing can start it before a restart. Neither "Running" nor "Disabled" is true; "Restart to start" is. Two shapes that are deliberately unlike the rest of the panel: the restart is a BANNER, because a restart is a property of the server rather than of a module and an operator who installed three modules should restart once; and purge is offered inside the uninstall flow as a second confirm, because purge.sql lives inside the directory being deleted and there is no later. What the browser found that no test could ----------------------------------------- Installing over a row the previous boot had left `startup_failed` rendered "Failed at the require stage: module directory not present on the volume" one second after the files had been written to the volume -- and, because that branch is not pending, it suppressed the restart banner the install had just told the operator to use. Every unit test passed, because none of them had modelled a stale row plus a fresh install. The fix is a derivation rather than a special case: the loader scans the volume once at require time, so a module that is on the volume now and has no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. The same class, one place further on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (slice 1) lets the screen say "Restart to finish upgrading" instead of reporting the new version as running. Verified against a live server and the real published release: pasted the v0.3.0 install-manifest URL, restarted, watched the module register its five mounts and seven streams and its own nav rows appear in the sidebar. Disable ran its onShutdown for real -- the uo-link WebSocket closed, its routes went to 404, and it left /public/modules -- and enable then showed the decision-3 state with the banner. The restart button itself was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. Co-Authored-By: Claude <noreply@anthropic.com>
227 lines
10 KiB
JavaScript
227 lines
10 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { statusOf, actionsFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
|
|
|
|
// lib/moduleAdmin.js — what the Modules screen says about a module and what it
|
|
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
|
|
//
|
|
// This is the part of the screen worth testing, and it is plain JS so this
|
|
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
|
|
// that the row, the loader and the volume are three sources of truth which are
|
|
// ALLOWED to disagree — so most of these cases are combinations that a screen
|
|
// picking one source would render as a lie.
|
|
|
|
/** A module as GET /admin/modules returns it, with the running case as default. */
|
|
const mod = (over = {}) => ({
|
|
id: 'uo',
|
|
name: 'Ultima Online',
|
|
version: '1.0.0',
|
|
state: 'started',
|
|
failureStage: null,
|
|
failureReason: null,
|
|
source: 'https://gitea.example.com/x/uo.json',
|
|
sha256: 'a'.repeat(64),
|
|
installedAt: null,
|
|
startedAt: null,
|
|
liveState: 'started',
|
|
liveVersion: '1.0.0',
|
|
capabilities: [],
|
|
onVolume: true,
|
|
canPurge: true,
|
|
...over,
|
|
})
|
|
|
|
// ── statusOf ───────────────────────────────────────────────────────────────
|
|
|
|
test('a mounted, started module is Running and needs nothing', () => {
|
|
const s = statusOf(mod())
|
|
assert.equal(s.label, 'Running')
|
|
assert.equal(s.tone, 'ok')
|
|
assert.equal(s.pending, false)
|
|
})
|
|
|
|
test('enabled in the row but disabled in the loader is "Restart to start"', () => {
|
|
// THE case decision 3 creates on purpose: disable ran the module's onShutdown,
|
|
// then the operator enabled it again. The row says enabled; nothing can start
|
|
// it before a restart. Showing either "Running" or "Disabled" would be false.
|
|
const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.tone, 'warn')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /cannot be restarted in place/)
|
|
})
|
|
|
|
test('freshly installed and never booted into is also "Restart to start"', () => {
|
|
const s = statusOf(mod({ state: 'installed', liveState: null }))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /mounts when the server next starts/)
|
|
})
|
|
|
|
test('a disabled module is Disabled, and that is not pending anything', () => {
|
|
// Disable takes effect immediately — it is the one action that does — so there
|
|
// is nothing for a restart banner to be about.
|
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
|
|
assert.equal(s.label, 'Disabled')
|
|
assert.equal(s.pending, false)
|
|
})
|
|
|
|
test('a fresh install over a failed row is pending, not failed', () => {
|
|
// THE defect the §7.7 browser smoke found, and one no test here had modelled.
|
|
// Installing over a row the previous boot left `startup_failed` rendered
|
|
// "Failed at the require stage: module directory not present on the volume" a
|
|
// second after the files had been written — and suppressed the restart banner
|
|
// the install had just told the operator to use.
|
|
//
|
|
// `liveState === null` with the module on the volume means the loader's scan
|
|
// never saw it, so it arrived after boot and everything the row says predates
|
|
// it.
|
|
const s = statusOf(mod({
|
|
state: 'startup_failed',
|
|
liveState: null,
|
|
failureStage: 'require',
|
|
failureReason: 'module directory not present on the volume',
|
|
}))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.pending, true)
|
|
assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
|
|
})
|
|
|
|
test('the restart banner appears for that install', () => {
|
|
// The second half of the same defect: the banner is driven by `pending`, so a
|
|
// row wrongly classified as failed silently removed the only way to act on it.
|
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
|
|
})
|
|
|
|
test('an upgrade that has not been restarted into says so', () => {
|
|
// Same class as the stale-failure defect: the row is a promise about the next
|
|
// boot, not a description of this one. Reporting "Running v2.0.0" while the
|
|
// process is serving v1.0.0 would hide the only action that fixes it.
|
|
const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
|
|
assert.equal(s.label, 'Restart to finish upgrading')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
|
|
})
|
|
|
|
test('reinstalling the SAME version is not an upgrade in progress', () => {
|
|
assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
|
|
})
|
|
|
|
test('a failed module reports the stage and the reason it recorded', () => {
|
|
const s = statusOf(mod({
|
|
state: 'startup_failed',
|
|
liveState: 'startup_failed',
|
|
failureStage: 'schema',
|
|
failureReason: "Unknown column 'x' in 'field list'",
|
|
}))
|
|
assert.equal(s.label, 'Failed to start')
|
|
assert.equal(s.tone, 'bad')
|
|
assert.match(s.detail, /schema stage/)
|
|
assert.match(s.detail, /Unknown column/)
|
|
})
|
|
|
|
test('a failure with no recorded reason says so rather than showing a blank', () => {
|
|
const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
|
|
assert.match(s.detail, /recorded no reason/)
|
|
})
|
|
|
|
test('a row whose directory is gone by hand is bad, not merely disabled', () => {
|
|
// The boot reconcile marks this `startup_failed` because a row claiming to be
|
|
// enabled for a module that is not on the volume is simply untrue.
|
|
const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
|
|
assert.equal(s.label, 'Missing from the volume')
|
|
assert.equal(s.tone, 'bad')
|
|
})
|
|
|
|
test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
|
|
// Uninstall leaves the row `disabled` and the data alone — which is the whole
|
|
// point of keeping the row, so the screen has to say it.
|
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
|
|
assert.equal(s.label, 'Uninstalled')
|
|
assert.equal(s.tone, 'idle')
|
|
assert.match(s.detail, /data was kept/i)
|
|
})
|
|
|
|
test('missing-from-the-volume beats every other status', () => {
|
|
// Ordering: a module with no files is described that way whatever its row
|
|
// still claims, because there is nothing there to be running.
|
|
for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
|
|
assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
|
|
}
|
|
})
|
|
|
|
// ── actionsFor ─────────────────────────────────────────────────────────────
|
|
|
|
test('a running module offers disable, uninstall and a blocked purge', () => {
|
|
const a = actionsFor(mod())
|
|
assert.equal(a.disable.shown, true)
|
|
assert.equal(a.enable.shown, false)
|
|
assert.equal(a.uninstall.shown, true)
|
|
assert.equal(a.purge.shown, true)
|
|
// Shown but not clickable: the server refuses a standalone purge on anything
|
|
// that is not disabled, so offering the click would only produce a 409.
|
|
assert.equal(a.purge.enabled, false)
|
|
assert.match(a.purge.reason, /Disable it first/)
|
|
})
|
|
|
|
test('a disabled module offers enable, and purge is now live', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
|
|
assert.equal(a.enable.shown, true)
|
|
assert.equal(a.disable.shown, false)
|
|
assert.equal(a.purge.enabled, true)
|
|
})
|
|
|
|
test('a module with no purge.sql never offers purge, and says why', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
|
|
assert.equal(a.purge.shown, false)
|
|
assert.match(a.purge.reason, /ships no purge.sql/)
|
|
})
|
|
|
|
test('a module with no files offers only clearing the row', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
|
|
assert.equal(a.uninstall.shown, false)
|
|
assert.equal(a.disable.shown, false)
|
|
assert.equal(a.enable.shown, false)
|
|
assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
|
|
assert.equal(a.forget.shown, true)
|
|
})
|
|
|
|
test('a directory with no row yet is actionable, and offers nothing to forget', () => {
|
|
// A hand-placed install before its first boot: it has no row, so `state` is
|
|
// null. Its routes are already being served, so it must be disableable.
|
|
const a = actionsFor(mod({ state: null, liveState: 'started' }))
|
|
assert.equal(a.disable.shown, true)
|
|
assert.equal(a.uninstall.shown, true)
|
|
assert.equal(a.forget.shown, false)
|
|
})
|
|
|
|
// ── needsRestart ───────────────────────────────────────────────────────────
|
|
|
|
test('the restart banner is driven by the list, not by any one module', () => {
|
|
// A restart is a property of the SERVER. One pending module is enough, and
|
|
// three do not mean three restarts.
|
|
assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
|
|
assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
|
|
assert.equal(needsRestart([]), false)
|
|
})
|
|
|
|
test('a disabled module does not ask for a restart', () => {
|
|
// Disable is immediate; a banner here would be asking for a restart that
|
|
// would change nothing.
|
|
assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
|
|
})
|
|
|
|
test('a failed module does not ask for a restart either', () => {
|
|
// It is retried on every boot anyway, and the operator has to fix the cause
|
|
// first — a banner would suggest restarting is the remedy.
|
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
|
|
})
|
|
|
|
// ── parseHosts ─────────────────────────────────────────────────────────────
|
|
|
|
test('parseHosts previews exactly what the server will store', () => {
|
|
assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
|
|
assert.deepEqual(parseHosts(' '), [])
|
|
assert.deepEqual(parseHosts(undefined), [])
|
|
})
|