import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { registry, declareSlot, 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') })