Two lines only core cannot supply for itself.
`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.
`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.
Co-Authored-By: Claude <noreply@anthropic.com>
237 lines
11 KiB
JavaScript
237 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. 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.
|
|
//
|
|
// THREE slots rather than one because a slot holds one component: stacking the
|
|
// feed, the forum and the notification control into a single fill would take
|
|
// away this module's ability to place them separately on its own page — and it
|
|
// does place them separately, the control above the roster and the other two
|
|
// below it.
|
|
assert.deepEqual([...registered.declaredSlots], [
|
|
'uo.guild.detail',
|
|
'uo.guild.forum',
|
|
'uo.guild.header',
|
|
])
|
|
})
|
|
|
|
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, '\.')}"`))
|
|
}
|
|
})
|