// ── 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; `` 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 // `` 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]) })