Files
Module-Rust/client/test/registration.test.js
wtclaude 862c328176 feat: the module skeleton and every bundle seam
module-rust, id 'rust', built from the Integration Kit's template. Phase 1's job
is the kit's own argument: get every seam working at once with almost nothing in
them, so that afterwards you break exactly one at a time.

What is here:

* /rust on all three tiers, because the loader holds module.json's mounts against
  what is registered in BOTH directions -- so the declaration and the
  registration land together or not at all. The player tier is honestly thin: it
  answers the server list on the authenticated tier, delegating to the same model
  the public tier uses so the two cannot drift while they are meant to be the
  same. It is the address the app will call, registered now rather than moved
  later.
* Two tables. rust_servers is configuration an operator writes; rust_server_state
  is what a sidecar reported. Separate tables because they have different
  writers, lifetimes and audiences -- and because purging observed state while
  keeping the configuration is a thing an operator will want.
* Per-server sidecar tokens through ctx.secretBox, write-only in the API. The
  admin list reports hasToken and never the credential, and an empty token on a
  save leaves the stored one alone -- a form that posts its own blank field would
  otherwise erase a credential every time somebody renamed a server.
* A real sidecar client. It never throws: every call answers {ok, status, data},
  and the status is what tells a wrong URL from a wrong token from a mismatched
  protocol -- all three present as 'the site says my server is offline' and each
  has a different fix.
* The five guards, green: check:imports, check:swagger, check:externals, and both
  suites.

What is deliberately NOT registered: the Team provider, triggers, audiences,
engagement seeds, notification streams, the four event catalogues, and the two
extension slots. Each arrives with the phase that has something real to put in
it, and a test asserts their absence so that removing it is deliberate. A
declared trigger nothing emits and a declared slot nothing fills are both
surfaces an operator can configure and then wait on, which is worse than an
absent one because the absence is visible.

Two corrections to the kit's template, both feedback for a later phase:

* registration.test.js read one page BY NAME to check declared slots are
  rendered, so a module declaring none dies on ENOENT before reaching the loop
  that would have been empty. It now scans every file under src/routes.
* test/_fakes.js supplied validator: {}. An admin router that builds validation
  chains at file scope cannot be required with that, so the fake holds the real
  express-validator -- for the same reason it holds a real express Router.

The kit was right about noGameConnection.test.js: its header predicts that a
module adding a sidecar client will see the check go red, names sidecarClient.js
as the file to allow, and says narrow it rather than delete it. That is exactly
what happened on the first run, and the fix was the one line the header names.

Installed into a real core and verified: the module reaches 'started', publishes
its capability, serves its chunk, and renders a server whose server.hello
originated in a live Rust server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:54:08 -05:00

239 lines
11 KiB
JavaScript

// ── What the chunk registers, checked without a browser ───────────────────
//
// `build.test.js` says the honest thing about this half: its real failures are
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
// §7.7's browser smoke is what proves the client half works, and nothing here
// replaces it.
//
// What a test CAN do is read back what the chunk asked for. Registration is the
// one thing the chunk does at evaluation time, and it does it through an object
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
// the real React behind it, import the BUILT artifact, and inspect the result. No
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
// an object, and the route table is full of them by design.
//
// It catches a page that silently stops being routed, a nav row whose `to` drifts
// from its route's path, and the whole registration surface disappearing because
// something threw halfway down entry.jsx.
//
// **It runs against `dist/entry.js`, so build before you test.** The skip below
// is deliberate — `npm test` has to be runnable before `npm run build` — which
// means a CI job that tests without building is a job asking nothing at all. Ours
// builds first, on purpose.
import test 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 * as react from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import * as router from 'react-router-dom'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
// Core's contribution catalogue, as of MODULE_API 1.6.0 (§3.7a). Written down
// rather than imported: this suite runs against the BUILT chunk with no core in
// the process, so it is a claim about core that has to be re-read when core's list
// changes — the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
// A component, as far as the registry cares. The kit's real members are core's;
// nothing renders here, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = []
return {
version: manifest.coreApi.replace(/^\D+/, ''),
react,
jsxRuntime,
router,
// `react-dom/client` is imported for the identity check in core.js and never
// called — `createRoot` in a DOM-less process would throw. The shim reads
// this object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
registry: {
registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
}
},
registerNav(id, { area, items }) {
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
},
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
registerExtension(id, slot, Component) {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (1.6.0): the module declares, core fills. Core
// enforces the namespace AND the contribution name at this call, which is why
// the fake does too — either one core would reject is a slot that renders
// nothing on a real install and everything in a suite that shrugged.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`"${name}" is not namespaced under "${id}"`)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`"${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.push({ id, name, wants })
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
// Loaded once: an ES module is evaluated a single time per process however many
// times it is imported, so every test below reads the same registration pass —
// which is also how it behaves in a browser.
let registered = null
let skip = false
if (!fs.existsSync(CHUNK)) {
skip = true
} else {
const rg = fakeRg()
globalThis.window = { __rg: rg }
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
registered = rg._read()
}
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
it('registers at least one route, namespaced under the module id', () => {
const all = Object.values(registered.routes).flat()
assert.ok(all.length > 0, 'the chunk registered no routes at all')
for (const [area, list] of Object.entries(registered.routes)) {
for (const r of list) {
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
assert.ok(r.element, `${area} route "${r.path}" has no element`)
}
}
})
it('every route path is distinct within its area', () => {
// Two routes on one path is a page that can never be reached, and React
// renders the first one without complaint.
for (const [area, list] of Object.entries(registered.routes)) {
const paths = list.map((r) => r.path)
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
}
})
it('every nav row points at a route this module actually registered', () => {
// The agreement that matters, and the one that rots quietly: a row survives a
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
// the FULL rendered path (`/rust/servers`); routes carry the namespaced
// one (`rust/servers`). Reconciling the two is the whole test.
const rendered = {
public: (p) => `/${p}`,
admin: (p) => `/admin/${p}`,
player: (p) => `/player/${p}`,
}
for (const [area, rows] of Object.entries(registered.nav)) {
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
for (const row of rows) {
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
}
}
})
it('every admin and player nav row carries an icon', () => {
// Both of those navs draw a glyph on every core row, so a row without one reads
// as breakage rather than as a design — and core's player portal used to render
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
// the first time a module registered a row without one. Core guards it now; a
// missing icon there is still a visible defect and this is the cheap place to
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
for (const area of ['admin', 'player']) {
for (const row of registered.nav[area]) {
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
}
}
})
it('a nav row that gates on a feature has a provider to resolve it', () => {
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
// So a row carrying a `feature` from a module that registered no provider is a
// row that always shows — which re-advertises a surface an operator hid.
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
if (gated.length === 0) return
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
})
it('every slot module.json declares is one the chunk fills', () => {
// `module.json` declares SERVER slots, and the loader validates those before
// the chunk is ever served. Client slots cannot be declared there — the server
// knows nothing about them — so this is the one place the two halves meet.
for (const slot of manifest.extensions || []) {
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
}
})
/** The source of every page under `src/routes`, so a slot can be looked for in all of them. */
function pageSources(dir = path.resolve(HERE, '..', 'src', 'routes'), out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) pageSources(full, out)
else if (/\.jsx?$/.test(entry.name)) out.push(fs.readFileSync(full, 'utf8'))
}
return out
}
it('every declared slot is namespaced under this module and rendered by a page', () => {
// Two halves that nothing else holds together. The namespace is core's rule and
// the fake enforces it at the call; what a test has to check is the OTHER end —
// a slot declared and never rendered is a promise to core that no page keeps,
// and it fails silently, because an unrendered slot looks exactly like an
// unfilled one.
// Every page, not one named file. The kit's template reads its single slot-
// bearing page by name, which works until a module either renames that page or
// — as this one does in phase 1 — declares no slots at all: the `readFileSync`
// runs before the loop that would have been empty, and the suite dies on a
// missing file rather than passing with nothing to check.
const pages = pageSources().join('\n')
for (const { id, name } of registered.declaredSlots) {
assert.equal(id, manifest.id)
assert.ok(name.startsWith(`${manifest.id}.`), `slot "${name}" is not under the module namespace`)
assert.ok(pages.includes(`name="${name}"`), `slot "${name}" is declared and never rendered`)
}
})
it('every declared slot names a core contribution core actually offers', () => {
// The fake throws on an unknown one, exactly as core does, so this asserts the
// other half: that the slots asked for something at all. A slot with no `core`
// is legal and stays empty — which is right for a place you fill yourself and
// wrong for one you are waiting on core for, and only you know which it is.
for (const { name, wants } of registered.declaredSlots) {
assert.ok(wants, `slot "${name}" asks for no core contribution, so nothing will ever fill it`)
assert.ok(CORE_CONTRIBUTIONS.includes(wants))
}
})
it('registers under exactly one module id, matching the manifest', () => {
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
...Object.values(registered.nav).flat().map((r) => r.moduleId),
...[...registered.extensions.values()].map((e) => e.id),
...[...registered.providers.values()].map((p) => p.id),
...registered.declaredSlots.map((s) => s.id),
])
assert.deepEqual([...owners], [manifest.id])
})