Files
Module-uo/client/test/registration.test.js
wtclaude 9d0a197008
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m40s
feat(guilds): declare a second place on the guild page, for core's forum
The mirror of the activity feed, one phase later. Core owns the Team forum —
membership, manual grants and the member/guest split are all core's rules, and a
module reimplementing any of them would be reimplementing a security boundary — but
core publishes no Team page, because it does not own the word "guild". So this
module declares the place and core puts the forum in it.

TWO declarations rather than one, and that is the interesting part. A slot holds one
component and the first fill wins, so folding the forum into `uo.guild.detail`
alongside the feed would hand core the decision about where each of its two
contributions sits on a page this module owns. Separate slots also keep them
independent: with the forum switched off, the feed renders exactly as before.

The registration test now asserts the set of declared slots and that EVERY one of
them is rendered by the page that owns it, rather than naming a single slot twice.
A slot nothing renders is a slot core fills into the void.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:37 -05:00

231 lines
10 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. That is still
// true, and MODULE_API.md §7.7's browser smoke is still what proves the module
// works. But it left a gap worth closing, and slice 3 is when it started to
// matter: nothing checked *what* the chunk registers.
//
// It can be checked, because registration is the one thing this 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 read back what it asked for. No DOM is needed
// because nothing renders — `<Shard />` is `jsx(Shard)`, an object, and the
// route table is full of them by design.
//
// What this catches that review does not: a page that silently stops being
// routed, a nav row whose `to` drifts from its route's path, a slot fill that
// was renamed on one side, and the whole registration surface disappearing
// because an exception was thrown halfway down entry.jsx.
//
// What it deliberately does NOT do is re-assert the paths as a literal list.
// The interesting property is that the nav and the routes AGREE, and a test that
// restates both is a second copy of the thing it is checking.
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')
// A component, as far as the registry cares. The kit's real members are core's;
// nothing here renders, 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 = new Set()
return {
version: '1.3.0',
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 (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace, so the fake
// does too — a chunk that declared an unnamespaced slot would pass here and
// throw in a browser.
declareModuleSlot(id, name) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
declaredSlots.add(name)
},
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 routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
for (const r of routes[area]) {
assert.match(r.path, /^uo\//, `${area} route "${r.path}" is not under the module 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 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 (`/uo/shard`), routes carry the namespaced one
// (`uo/shard`), and reconciling them 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 render a glyph on every core row, so a row without one
// reads as breakage rather than as a design. The PUBLIC header is text
// buttons and is deliberately excluded.
//
// The player half of this assertion is not symmetry for its own sake. Core's
// PlayerPortalLayout rendered `<n.icon />` UNGUARDED — fine for as long as
// every row in it was core's own and had one, and React error #130 with a
// blank portal the moment a module registered one without. Core is guarded
// now, but a missing icon there is still a visible defect and this is the
// cheap place to catch it.
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 is gated by a namespace this module provides', () => {
// Resolution is by the REGISTERING module (§3.3), so a `feature` on a row from
// a module that registered no provider resolves against nothing — and
// everything fails open, which would re-advertise surfaces an operator hid.
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
assert.ok(gated.length > 0)
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
assert.equal(typeof Component, 'function', `${slot} was not filled with a component`)
}
})
it('the manifest\'s declared server slot is one this module fills', () => {
// module.json declares SERVER slots and the loader validates them before the
// chunk is ever served. Client slots cannot be declared there — the server has
// no knowledge of them — so this is the one place the two halves are compared.
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
for (const slot of manifest.extensions || []) {
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
}
})
it('registers under exactly one module id, matching the manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
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),
])
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, for core to fill', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
//
// TWO slots rather than one because a slot holds one component: stacking the
// feed and the forum into a single fill would take away this module's ability
// to place them separately on its own page.
assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail', 'uo.guild.forum'])
})
it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})