Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.
The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.
Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.
The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:
* The body import diffed its manifest against every family's rows. Phase 5 put
item and land art in the same table, and a body manifest never mentions
them, so all 313 item pictures were staged for deletion with a sentence
saying the shard had stopped offering them.
* An approved vanish unlinked the sprite and kept the row. The catalogue went
on counting a picture that was gone, the atlas could point a creature page at
a missing file, and the next forced import offered the same key for review
again -- reporting "nothing was changed" about a file it had deleted.
Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.
Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.
Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
253 lines
12 KiB
JavaScript
253 lines
12 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 })
|
|
|
|
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
|
|
// imported — this suite runs against the BUILT chunk with no core in the process
|
|
// — which means it is a claim about core that has to be re-read when core's list
|
|
// changes. That is the same trade the rest of this fake makes.
|
|
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
|
|
|
|
function fakeRg() {
|
|
const routes = { public: [], admin: [], player: [] }
|
|
const nav = { public: [], admin: [], player: [] }
|
|
const providers = new Map()
|
|
const extensions = new Map()
|
|
const declaredSlots = new Map()
|
|
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 and the
|
|
// contribution name, so the fake does too — a chunk that declared an
|
|
// unnamespaced slot, or asked for a contribution core does not offer, would
|
|
// pass here and throw in a browser.
|
|
declareModuleSlot(id, name, options = {}) {
|
|
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`)
|
|
const wants = options.core ?? null
|
|
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
|
|
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
|
|
}
|
|
declaredSlots.set(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 routes in all three areas, namespaced under the module id', () => {
|
|
const { routes } = registered
|
|
assert.equal(routes.public.length, 13)
|
|
assert.equal(routes.admin.length, 8)
|
|
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, each naming the core contribution it wants', () => {
|
|
// 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.
|
|
//
|
|
// The second argument is what actually gets core's content here. **Core offers
|
|
// a contribution and never names a slot** — the first cut of this reached only
|
|
// this module, because core filled the literal name `uo.guild.detail` and any
|
|
// other game's page went empty with no error.
|
|
assert.deepEqual([...registered.declaredSlots.entries()], [
|
|
['uo.guild.detail', 'team.activity'],
|
|
['uo.guild.forum', 'team.forum'],
|
|
['uo.guild.header', 'team.notify'],
|
|
])
|
|
})
|
|
|
|
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.keys()) {
|
|
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
|
|
}
|
|
})
|