Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.
Client:
- modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
with the URL namespace written by core, never by the module
- modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
react/jsx-runtime, the registry, the seven-member UI kit and the request
primitive, frozen
- App.jsx reads routesFor for all three areas; nav consumption is PR 8
- main.jsx publishes the global, then mounts on DOMContentLoaded
Server:
- the loader validates client.entry and publishes clientChunks() and
clientEntryUrls(); an entry in the module root is rejected, because the
directory it sits in is what gets served
- app.js mounts each chunk at /modules/<id>/ behind the module's state guard
with no-cache; anything else under /modules is a 404, not the SPA shell
- htmlShell injects the tag before </body>, so core's bundle runs first
wherever a bundler puts it
Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.
933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
165 lines
6.2 KiB
JavaScript
165 lines
6.2 KiB
JavaScript
import { test, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import {
|
|
registry,
|
|
registerRoutes,
|
|
registerNav,
|
|
registerFeatureProvider,
|
|
routesFor,
|
|
navFor,
|
|
featureProviderFor,
|
|
registeredIds,
|
|
_reset,
|
|
} from '../src/modules/registry.js'
|
|
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
|
|
|
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
|
// isolation from React, like the nav-override merge next door, because the
|
|
// property worth proving has nothing to do with rendering: a module gets exactly
|
|
// the URL namespace core gave it, however it spells the paths it registers.
|
|
//
|
|
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
|
// there is no DOM in this runner. What it publishes is React, the router and
|
|
// core components: a wiring test would assert that an import statement imported
|
|
// something. Phase 1's spike proved the half that can actually fail, which is a
|
|
// real chunk resolving its externals against the global in a browser under an
|
|
// enforced CSP.
|
|
|
|
beforeEach(() => _reset())
|
|
|
|
test('a module route is namespaced under the module id', () => {
|
|
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
|
assert.deepEqual(
|
|
routesFor('public').map((r) => r.path),
|
|
['uo/atlas'],
|
|
)
|
|
})
|
|
|
|
test('a module cannot spell its way out of its namespace', () => {
|
|
// Whatever the module writes, the segment it lands under is core's to choose:
|
|
// leading slashes, several of them, a trailing one, or nothing at all.
|
|
registerRoutes('uo', {
|
|
public: [
|
|
{ path: '/atlas' },
|
|
{ path: '//atlas/creatures' },
|
|
{ path: 'atlas/' },
|
|
{ path: '' },
|
|
],
|
|
})
|
|
assert.deepEqual(
|
|
routesFor('public').map((r) => r.path),
|
|
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
|
)
|
|
})
|
|
|
|
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
|
// `..` is not stripped, and does not need to be: React Router matches path
|
|
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
|
// than a route that resolves somewhere else. Asserted so that a future
|
|
// "cleanup" that starts resolving these knows it changed a behaviour.
|
|
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
|
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
|
})
|
|
|
|
test('routes keep their gate and carry the owning module id', () => {
|
|
registerRoutes('uo', {
|
|
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
|
})
|
|
const [route] = routesFor('admin')
|
|
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
|
assert.equal(route.moduleId, 'uo')
|
|
assert.equal(route.element, 'OPS')
|
|
})
|
|
|
|
test('the three areas are kept apart', () => {
|
|
registerRoutes('uo', {
|
|
public: [{ path: 'atlas' }],
|
|
admin: [{ path: 'link' }],
|
|
player: [{ path: 'chars' }],
|
|
})
|
|
assert.equal(routesFor('public').length, 1)
|
|
assert.equal(routesFor('admin').length, 1)
|
|
assert.equal(routesFor('player').length, 1)
|
|
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
|
// over all three unconditionally.
|
|
_reset()
|
|
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
|
})
|
|
|
|
test('an unknown area throws rather than being dropped', () => {
|
|
// Loudly, because the alternative is a module whose pages simply never appear
|
|
// and no indication anywhere of why.
|
|
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
|
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
|
assert.equal(registeredIds().length, 0)
|
|
})
|
|
|
|
test('nav items sort by order, and equal orders keep load order', () => {
|
|
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
|
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
|
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
|
assert.deepEqual(
|
|
navFor('admin').map((i) => i.label),
|
|
['First', 'Second', 'Third'],
|
|
)
|
|
})
|
|
|
|
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
|
registerNav('uo', {
|
|
area: 'public',
|
|
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
|
})
|
|
assert.deepEqual(
|
|
navFor('public').map((i) => i.label),
|
|
['Early', 'Unordered'],
|
|
)
|
|
})
|
|
|
|
test('a feature provider is stored under its namespace, with its owner', () => {
|
|
const hook = () => ({ atlas: true })
|
|
registerFeatureProvider('uo', 'shard', hook)
|
|
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
|
assert.equal(featureProviderFor('nothing'), undefined)
|
|
})
|
|
|
|
test('every registration marks the module registered', () => {
|
|
registerRoutes('a', { public: [{ path: 'x' }] })
|
|
registerNav('b', { area: 'public', items: [] })
|
|
registerFeatureProvider('c', 'ns', () => {})
|
|
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
|
})
|
|
|
|
test('the registry object handed to modules exposes the whole surface', () => {
|
|
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
|
// member missing from the object is a member that does not exist.
|
|
assert.deepEqual(Object.keys(registry).sort(), [
|
|
'featureProviderFor',
|
|
'navFor',
|
|
'registerFeatureProvider',
|
|
'registerNav',
|
|
'registerRoutes',
|
|
'registeredIds',
|
|
'routesFor',
|
|
])
|
|
})
|
|
|
|
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
|
// The value is duplicated because it has to be on window.__rg before the first
|
|
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
|
// the test that pays for the copy: a bump that edits one file fails here
|
|
// instead of shipping a core whose two halves disagree about the contract they
|
|
// implement.
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const server = fs.readFileSync(
|
|
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
|
'utf8',
|
|
)
|
|
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
|
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
|
assert.equal(MODULE_API_VERSION, match[1])
|
|
})
|