feat(admin): the Modules screen (phase 4, slice 2)
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>
This commit is contained in:
@@ -140,3 +140,48 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||
})
|
||||
|
||||
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
||||
//
|
||||
// These pin the URLs, because the destructive one differs from the harmless one
|
||||
// by a query parameter and nothing else.
|
||||
|
||||
test('module actions hit the right paths and methods', async () => {
|
||||
const cases = [
|
||||
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
||||
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
||||
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
||||
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
||||
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
||||
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
||||
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
||||
]
|
||||
for (const [call, method, url] of cases) {
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await call()
|
||||
assert.equal(calls[0].url, url)
|
||||
assert.equal(calls[0].opts.method || 'GET', method)
|
||||
}
|
||||
})
|
||||
|
||||
test('uninstall only asks for a purge when it is told to', async () => {
|
||||
// The difference between "remove the module" and "remove the module and drop
|
||||
// every table it owns" is this query parameter, so a default that leaned the
|
||||
// wrong way would be irreversible.
|
||||
willReply({ body: {} })
|
||||
await api.admin.uninstallModule('uo')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await api.admin.uninstallModule('uo', { purge: true })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
||||
})
|
||||
|
||||
test('a module id is URL-encoded on the way into the path', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user