import { test } from 'node:test' import assert from 'node:assert/strict' import { statusOf, actionsFor, declarationNoteFor, 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, declared: false, declaredVersion: null, declaredError: null, ...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), []) }) // ── the declaration (slice 3) ────────────────────────────────────────────── test('a declared module that has never installed says so, with the reason', () => { // No row, no directory, nothing mounted — invisible to the other three // sources, so without this branch the screen would describe a module it has // never had as a row gone stale. const s = statusOf(mod({ state: null, liveState: null, liveVersion: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0', declaredError: 'could not reach releases.example.com', })) assert.equal(s.label, 'Declared, not installed') assert.equal(s.tone, 'bad') assert.equal(s.pending, false, 'a restart will not fix an unreachable host') assert.match(s.detail, /could not reach releases.example.com/) }) test('a declared module waiting for its first resolution is not reported as failed', () => { const s = statusOf(mod({ state: null, liveState: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0' })) assert.match(s.detail, /installed when the server next starts/) }) test('a running module whose declared upgrade is failing is still Running', () => { // Both facts are true at once. The status is one label, so the declaration // gets its own line rather than overwriting it. const m = mod({ declared: true, declaredVersion: '2.0.0', declaredError: 'sha256 did not match' }) assert.equal(statusOf(m).label, 'Running') const note = declarationNoteFor(m) assert.equal(note.tone, 'warn') assert.match(note.text, /sha256 did not match/) }) test('uninstalling a declared module is told that its files come back', () => { // The sentence that saves an afternoon: MODULES owns what is on the volume, // the row owns whether it runs. const note = declarationNoteFor(mod({ state: 'disabled', liveState: null, onVolume: false, declared: true, declaredVersion: '1.0.0' })) assert.match(note.text, /come back when the server next starts/) assert.match(note.text, /switched off/) }) test('an ordinary declared module gets a quiet note, and an undeclared one none', () => { assert.equal(declarationNoteFor(mod()), null) const note = declarationNoteFor(mod({ declared: true, declaredVersion: '1.0.0' })) assert.equal(note.tone, 'idle') assert.match(note.text, /MODULES/) })