import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { registry, declareSlot, declareModuleSlot, offerCoreFill, CORE_CONTRIBUTIONS, 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 offers a contribution and the module says where it goes', () => { // The ordering that makes this two calls: core's bundle evaluates BEFORE any // module chunk, so at the moment core offers, no module-declared slot exists. offerCoreFill('team.activity', Feed) declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' }) assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied') applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), Feed) }) test('core names no slot, so a second game gets the same content in its own words', () => { // The defect this replaced: core used to fill three literal `uo.guild.*` names, // which reached exactly one module. Every other game declared a place under its // own id and got an empty page with no error, because a fill nobody declared is // deliberately not an error — the rule that makes an unknown name invisible. offerCoreFill('team.activity', Feed) declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' }) applyCoreFills() assert.equal(extensionFor('examplegame.clan.detail'), Feed) }) test('two modules can ask for the same contribution, and both get it', () => { // Core has no reason to care how many places want its feed, and refusing the // second would be core making a layout decision on a page it does not own. offerCoreFill('team.activity', Feed) declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' }) declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' }) applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), Feed) assert.equal(extensionFor('uo.guild.summary'), Feed) }) test('a slot that asks for nothing stays empty', () => { // Optional on purpose: a module may declare a place it fills itself, or one it // is keeping for later. Neither is core's business. offerCoreFill('team.activity', Feed) declareModuleSlot('uo', 'uo.guild.detail') applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), null) }) test('asking for a contribution core does not offer THROWS', () => { // The asymmetry with an unfilled slot, and it is deliberate. An unknown // contribution is always a typo or a version skew — core's list is fixed at // build time and the module's coreApi range has already been checked — and the // alternative failure is a page that renders empty forever with nothing logged. assert.throws( () => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }), /does not offer/, ) assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it') }) test('a contribution nothing asks for is not an error', () => { // No game module installed. Core offering content for a page that does not // exist is the ordinary case on any deployment, not a misconfiguration. offerCoreFill('team.forum', Feed) assert.doesNotThrow(() => applyCoreFills()) }) test('a module that fills its own slot first keeps it', () => { const Own = () => null declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' }) registerExtension('uo', 'uo.guild.detail', Own) offerCoreFill('team.activity', 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', { core: 'team.activity' }) offerCoreFill('team.activity', Feed) applyCoreFills() assert.doesNotThrow(() => applyCoreFills()) assert.equal(extensionFor('uo.guild.detail'), Feed) }) test('a non-component contribution is refused at the call site, not at render', () => { assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/) }) test('_reset clears pending fills, so one test cannot leak into the next', () => { offerCoreFill('team.activity', Feed) _reset() declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' }) applyCoreFills() assert.equal(extensionFor('uo.guild.detail'), null) })