import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { registry, declareSlot, declareModuleSlot, fillModuleSlot, applyCoreFills, registerExtension, extensionFor, registeredIds, _reset, } from '../src/modules/registry.js' // Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of // the server's declareSlot/registerExtension. // // The registry half only. `` itself renders, and there is no DOM in this // runner, so what it does with what these functions return — including the error // boundary — is proved by the §7.7 browser smoke instead. Everything below is a // rule that can be stated without rendering anything, and every one of them can // be got wrong in a way a browser check would not obviously catch. beforeEach(() => _reset()) const Fake = () => null const Other = () => null test('an unfilled slot reads as nothing', () => { // The guarantee core's layouts rest on: place a slot, install no module, and // the page renders what it rendered before. declareSlot('site.footer.status') assert.equal(extensionFor('site.footer.status'), null) }) test('an undeclared slot reads as nothing rather than throwing', () => { // Reading is core's side and stays fail-safe: a typo in a layout costs that // spot, not the page. Only WRITING is strict, which is the next test. assert.equal(extensionFor('nope'), null) }) test('a module fills a declared slot and core reads it back', () => { declareSlot('admin.users.detail') registerExtension('uo', 'admin.users.detail', Fake) assert.equal(extensionFor('admin.users.detail'), Fake) assert.deepEqual(registeredIds(), ['uo']) }) test('filling an unknown slot throws, naming the slot', () => { // This is the one place the client registry is NOT fail-open, and the reason // is asymmetry of consequence: a dropped nav row costs a link the viewer can // reach another way, a silently dropped extension is invisible to everyone // including its author. Declaration structurally precedes filling (§3.1), so // this can only ever be a typo or a version skew. assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/) }) test('a non-component fill throws', () => { declareSlot('site.footer.status') assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/) }) test('a second module cannot take a filled slot, and the first keeps it', () => { // Matches the server's rule exactly (registries.js): first fill wins, second // is an error. The second half of the assertion is the one that matters — a // rejected fill must not have half-replaced the incumbent. declareSlot('admin.users.detail') registerExtension('uo', 'admin.users.detail', Fake) assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/) assert.equal(extensionFor('admin.users.detail'), Fake) }) test('declaring a slot twice throws', () => { // Core-side programming error: two owners for one position means whichever // module registered first wins by file order. declareSlot('site.footer.status') assert.throws(() => declareSlot('site.footer.status'), /already declared/) }) test('core fills a slot through the same seam a module uses', () => { // The client twin of registries.registerCore(). Core is a registrant with an // id like any other, which is what makes slice 3 a deletion: the module // registers the same slot and core drops its line. declareSlot('site.footer.status') registerExtension('core', 'site.footer.status', Fake) assert.deepEqual(registeredIds(), ['core']) }) test('declareSlot and extensionFor are not on the module-facing registry', () => { // Declaring is core's alone (§3.7), and reading who filled a slot is core's // too — the same line featureProviders() draws. registerExtension IS on the // object, because filling is the whole point. assert.equal(registry.declareSlot, undefined) assert.equal(registry.extensionFor, undefined) assert.equal(typeof registry.registerExtension, 'function') }) // ── The INVERTED direction: the module declares, core fills ──────────────── // // Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no // core surface — core owns the tables and the activity feed, the module owns the // page and the word "guild" — so the content flows the other way for the first // time. The rules below are the ones that direction gets wrong. const Feed = () => null test('a module-declared slot must be namespaced under the declaring module', () => { // Enforced rather than conventional: this is the only thing keeping two // modules from claiming the same slot name. assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/) assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail')) }) test('core fills a module slot only after the module has declared it', () => { // The ordering that makes this a separate call: core's bundle evaluates BEFORE // any module chunk, so at the moment core registers its fill the slot does not // exist yet. Filling eagerly would silently do nothing. fillModuleSlot('uo.guild.detail', Feed) assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it') declareModuleSlot('uo', 'uo.guild.detail') assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied') applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), Feed) }) test('a fill for a slot nobody declared is not an error', () => { // The module is not installed. Core offering content for a page that does not // exist is the ordinary case on any deployment, not a misconfiguration — the // mirror of an unfilled slot rendering nothing. fillModuleSlot('rust.clan.detail', Feed) assert.doesNotThrow(() => applyCoreFills()) assert.equal(extensionFor('rust.clan.detail'), null) }) test('a module that fills its own slot first keeps it', () => { const Own = () => null declareModuleSlot('uo', 'uo.guild.detail') registerExtension('uo', 'uo.guild.detail', Own) fillModuleSlot('uo.guild.detail', Feed) applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else') }) test('a module-declared slot cannot be declared twice', () => { declareModuleSlot('uo', 'uo.guild.detail') assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/) }) test('applying the fills twice does not re-fill or throw', () => { declareModuleSlot('uo', 'uo.guild.detail') fillModuleSlot('uo.guild.detail', Feed) applyCoreFills() assert.doesNotThrow(() => applyCoreFills()) assert.equal(extensionFor('uo.guild.detail'), Feed) }) test('a non-component fill is refused at the call site, not at render', () => { assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/) }) test('_reset clears pending fills, so one test cannot leak into the next', () => { fillModuleSlot('uo.guild.detail', Feed) _reset() declareModuleSlot('uo', 'uo.guild.detail') applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), null) })