@bongos/core 1.19.99 → 1.19.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +36 -21
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0167-module-catalog-source-vs-provenance.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -0
- package/docs/file-map.md +2 -0
- package/docs/modules-contract.md +2 -0
- package/modules/hall-ui/public/modules.css +30 -1
- package/modules/hall-ui/public/modules.html +24 -13
- package/modules/hall-ui/public/modules.js +86 -25
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/module.js +135 -1
- package/src/bongos/routes/modules.js +23 -24
- package/src/module-api.js +3 -1
- package/src/module-loader/catalog.js +217 -0
- package/tests/module_catalog.mjs +304 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// tests/module_catalog.mjs — the browsable module catalog (ADR 0107 §2/§3,
|
|
2
|
+
// task 1001772). src/module-loader/catalog.js is the ONE model behind both the
|
|
3
|
+
// hall's Modules tab and `bongos module list --catalog`, so these tests pin the
|
|
4
|
+
// two axes that were conflated before it existed:
|
|
5
|
+
//
|
|
6
|
+
// source which TREE a module ships from (core package vs. this repo) —
|
|
7
|
+
// the catalog axis, read structurally off the discovery root.
|
|
8
|
+
// provenance WHO built it — the credit axis, read off manifest.origin.
|
|
9
|
+
//
|
|
10
|
+
// Pure + hermetic: every case injects its own loaded[]/roots, so nothing here
|
|
11
|
+
// touches the real modules/ tree or a running server. DB-free → picked up by
|
|
12
|
+
// scripts/gds/run-unit-tests.js (the `unit` gate).
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const catalog = require(path.join(ROOT, 'src', 'module-loader', 'catalog.js'));
|
|
22
|
+
const mod = require(path.join(ROOT, 'scripts', 'gds', 'module.js'));
|
|
23
|
+
|
|
24
|
+
const CORE = path.join(path.sep, 'srv', 'app', 'node_modules', '@bongos', 'core');
|
|
25
|
+
const INST = path.join(path.sep, 'srv', 'app');
|
|
26
|
+
|
|
27
|
+
const manifest = (over = {}) => ({
|
|
28
|
+
key: 'x', title: 'X', description: 'a module', coreVersion: '^1.0.0', contributes: {}, ...over,
|
|
29
|
+
});
|
|
30
|
+
const loadedIn = (root, key, over = {}) => ({
|
|
31
|
+
key, dir: path.join(root, 'modules', key), manifest: manifest({ key, title: key, ...over }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// deriveSource — the catalog axis
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
test('deriveSource: distinct roots → structural (core package tree = the catalog)', () => {
|
|
39
|
+
const opts = { coreRoot: CORE, instanceRoot: INST };
|
|
40
|
+
assert.equal(catalog.deriveSource(path.join(CORE, 'modules', 'economy'), opts), 'core');
|
|
41
|
+
assert.equal(catalog.deriveSource(path.join(INST, 'modules', 'game'), opts), 'instance');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('deriveSource: a sibling dir that merely starts with "modules" is not inside modules/', () => {
|
|
45
|
+
const opts = { coreRoot: CORE, instanceRoot: INST };
|
|
46
|
+
// path-segment aware — ".../modules-extra/economy" must not read as core.
|
|
47
|
+
assert.equal(catalog.deriveSource(path.join(CORE, 'modules-extra', 'economy'), opts), 'instance');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('deriveSource: unknown root (neither tree) is never claimed for the catalog', () => {
|
|
51
|
+
const opts = { coreRoot: CORE, instanceRoot: INST };
|
|
52
|
+
assert.equal(catalog.deriveSource(path.join(path.sep, 'elsewhere', 'modules', 'x'), opts), 'instance');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('deriveSource: coincident roots fall back to what the checkout says it is', () => {
|
|
56
|
+
const dir = path.join(INST, 'modules', 'economy');
|
|
57
|
+
// The core's own repo: modules/ IS the catalog.
|
|
58
|
+
assert.equal(catalog.deriveSource(dir, { coreRoot: INST, instanceRoot: INST, coreCheckout: true }), 'core');
|
|
59
|
+
// A single-checkout instance: its modules/ are its own, not a shared catalog.
|
|
60
|
+
assert.equal(catalog.deriveSource(dir, { coreRoot: INST, instanceRoot: INST, coreCheckout: false }), 'instance');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('isCorePackageName: recognises the published + source names, nothing else', () => {
|
|
64
|
+
assert.equal(catalog.isCorePackageName('@bongos/core'), true);
|
|
65
|
+
assert.equal(catalog.isCorePackageName('cloud-bongos'), true);
|
|
66
|
+
assert.equal(catalog.isCorePackageName(' cloud-bongos '), true, 'tolerates stray whitespace');
|
|
67
|
+
assert.equal(catalog.isCorePackageName('some-instance'), false);
|
|
68
|
+
assert.equal(catalog.isCorePackageName(undefined), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// creditFor — the ADR 0107 §2/§3 attribution record
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('creditFor: a declared author + origin is passed through untouched, not inferred', () => {
|
|
76
|
+
const c = catalog.creditFor({ author: 'Mercury', origin: 'Mercury (mercury.example)' }, { source: 'core' });
|
|
77
|
+
assert.deepEqual(c, { author: 'Mercury', origin: 'Mercury (mercury.example)', inferred: false });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('creditFor: a core module declaring nothing is credited to the platform, and marked inferred', () => {
|
|
81
|
+
const c = catalog.creditFor({}, { source: 'core' });
|
|
82
|
+
assert.equal(c.author, catalog.CORE_PROJECT_NAME);
|
|
83
|
+
assert.equal(c.origin, catalog.CORE_PROJECT_NAME);
|
|
84
|
+
assert.equal(c.inferred, true, 'an inferred credit MUST be marked — it is not a claim the author made');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('creditFor: an instance module declaring nothing stays uncredited rather than borrowing the platform name', () => {
|
|
88
|
+
const c = catalog.creditFor({}, { source: 'instance' });
|
|
89
|
+
assert.deepEqual(c, { author: null, origin: null, inferred: false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('creditFor: a half-declared credit still marks inferred (the filled half came from the fallback)', () => {
|
|
93
|
+
const c = catalog.creditFor({ author: 'Mercury' }, { source: 'core' });
|
|
94
|
+
assert.equal(c.author, 'Mercury');
|
|
95
|
+
assert.equal(c.origin, catalog.CORE_PROJECT_NAME);
|
|
96
|
+
assert.equal(c.inferred, true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('creditFor: whitespace-only attribution counts as undeclared', () => {
|
|
100
|
+
const c = catalog.creditFor({ author: ' ', origin: '' }, { source: 'core' });
|
|
101
|
+
assert.equal(c.author, catalog.CORE_PROJECT_NAME);
|
|
102
|
+
assert.equal(c.inferred, true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// buildCatalog — the row model both surfaces render
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
test('buildCatalog: keeps the DECLARED manifest fields beside the display credit', () => {
|
|
110
|
+
const [row] = catalog.buildCatalog({
|
|
111
|
+
loaded: [loadedIn(CORE, 'economy')],
|
|
112
|
+
coreRoot: CORE, instanceRoot: INST,
|
|
113
|
+
});
|
|
114
|
+
// The declared fields are what provenance is derived from; blanking them
|
|
115
|
+
// would silently reclassify every core module as core-loaded.
|
|
116
|
+
assert.equal(row.author, null);
|
|
117
|
+
assert.equal(row.origin, null);
|
|
118
|
+
assert.equal(row.credit.author, catalog.CORE_PROJECT_NAME);
|
|
119
|
+
assert.equal(row.provenance, 'own', 'no declared origin → own-built (ADR 0107 §9)');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('buildCatalog: source and provenance are independent axes', () => {
|
|
123
|
+
const rows = catalog.buildCatalog({
|
|
124
|
+
loaded: [loadedIn(CORE, 'provisioning', { origin: 'Off the Boats', author: 'Off the Boats' })],
|
|
125
|
+
coreRoot: CORE, instanceRoot: INST,
|
|
126
|
+
ownProjectName: 'Mercury',
|
|
127
|
+
});
|
|
128
|
+
// Ships from the core package (so: enable-able catalog) but was BUILT by
|
|
129
|
+
// another project (so: core-loaded credit). Exactly the case the catalog exists for.
|
|
130
|
+
assert.equal(rows[0].source, 'core');
|
|
131
|
+
assert.equal(rows[0].provenance, 'core');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('buildCatalog: enablement comes from the resolved map, and rows sort by key', () => {
|
|
135
|
+
const rows = catalog.buildCatalog({
|
|
136
|
+
loaded: [loadedIn(CORE, 'zeta'), loadedIn(CORE, 'alpha'), loadedIn(INST, 'mid')],
|
|
137
|
+
enabled: { alpha: true, mid: false },
|
|
138
|
+
coreRoot: CORE, instanceRoot: INST,
|
|
139
|
+
});
|
|
140
|
+
assert.deepEqual(rows.map((r) => r.key), ['alpha', 'mid', 'zeta']);
|
|
141
|
+
assert.deepEqual(rows.map((r) => r.enabled), [true, false, false]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('buildCatalog: survives a manifest with nothing but the required keys', () => {
|
|
145
|
+
const [row] = catalog.buildCatalog({ loaded: [loadedIn(INST, 'bare')], coreRoot: CORE, instanceRoot: INST });
|
|
146
|
+
assert.equal(row.title, 'bare');
|
|
147
|
+
assert.deepEqual(row.contributes, {});
|
|
148
|
+
assert.equal(row.maintenance, null);
|
|
149
|
+
assert.equal(row.default, false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// catalogRows / searchCatalog / summarize
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
const sample = () => catalog.buildCatalog({
|
|
157
|
+
loaded: [
|
|
158
|
+
loadedIn(CORE, 'economy', { description: 'drachmae and credits' }),
|
|
159
|
+
loadedIn(CORE, 'provisioning', { author: 'Off the Boats', origin: 'Off the Boats', license: 'AGPL-3.0' }),
|
|
160
|
+
loadedIn(INST, 'game', { description: 'the world' }),
|
|
161
|
+
],
|
|
162
|
+
enabled: { economy: true, game: true },
|
|
163
|
+
coreRoot: CORE, instanceRoot: INST,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('catalogRows / instanceRows: the catalog is exactly the core-shipped half', () => {
|
|
167
|
+
const rows = sample();
|
|
168
|
+
assert.deepEqual(catalog.catalogRows(rows).map((r) => r.key), ['economy', 'provisioning']);
|
|
169
|
+
assert.deepEqual(catalog.instanceRows(rows).map((r) => r.key), ['game']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('searchCatalog: matches key, description, author, origin and licence; empty query is a pass-through', () => {
|
|
173
|
+
const rows = sample();
|
|
174
|
+
assert.equal(catalog.searchCatalog(rows, '').length, 3);
|
|
175
|
+
assert.equal(catalog.searchCatalog(rows, ' ').length, 3);
|
|
176
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'DRACHMAE').map((r) => r.key), ['economy'], 'case-insensitive, over description');
|
|
177
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'off the boats').map((r) => r.key), ['provisioning'], 'over the credit');
|
|
178
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'agpl').map((r) => r.key), ['provisioning'], 'over the licence');
|
|
179
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'nothing-matches-this'), []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('searchCatalog: searches the INFERRED credit too, so core modules are findable by the platform name', () => {
|
|
183
|
+
const rows = sample();
|
|
184
|
+
const hits = catalog.searchCatalog(rows, catalog.CORE_PROJECT_NAME.toLowerCase()).map((r) => r.key);
|
|
185
|
+
assert.ok(hits.includes('economy'), 'a core module with no declared author is still findable by its shipper');
|
|
186
|
+
assert.ok(!hits.includes('game'), 'an instance module must not borrow the platform credit');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('summarize: counts the catalog half and the enabled half separately', () => {
|
|
190
|
+
assert.deepEqual(catalog.summarize(sample()), {
|
|
191
|
+
total: 3, catalog: 2, instance: 1, enabled: 2, catalog_enabled: 1,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// `bongos module list` — the CLI surface over the same model
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
test('formatCatalog: renders every row with its credit, and marks the inferred ones', () => {
|
|
200
|
+
const out = mod.formatCatalog(sample(), { core: '1.19.0' });
|
|
201
|
+
assert.match(out, /every module on disk/);
|
|
202
|
+
assert.match(out, /2 in the core catalog \(1 enabled\) · 1 built here · 3 total/);
|
|
203
|
+
assert.match(out, /\[x\]\s+economy/, 'an enabled module is marked [x]');
|
|
204
|
+
assert.match(out, /\[ \]\s+provisioning/, 'a disabled module is marked [ ]');
|
|
205
|
+
assert.match(out, /Off the Boats/, 'a declared credit is shown');
|
|
206
|
+
assert.match(out, /credit not declared in module\.json/, 'the inferred-credit footnote appears when one is shown');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('formatCatalog: --catalog narrows to the core-shipped modules only', () => {
|
|
210
|
+
const out = mod.formatCatalog(sample(), { catalogOnly: true });
|
|
211
|
+
assert.match(out, /core modules this instance can enable/);
|
|
212
|
+
assert.ok(!/\bgame\b/.test(out), 'an instance module is not part of the core catalog listing');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('formatCatalog: a search reports its own hit count and drops the rest', () => {
|
|
216
|
+
const out = mod.formatCatalog(sample(), { query: 'drachmae' });
|
|
217
|
+
assert.match(out, /filter "drachmae" — 1 match\(es\)/);
|
|
218
|
+
assert.match(out, /economy/);
|
|
219
|
+
assert.ok(!/provisioning/.test(out));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('formatCatalog: a search with no hits says so instead of printing an empty table', () => {
|
|
223
|
+
const out = mod.formatCatalog(sample(), { query: 'zzz-nothing' });
|
|
224
|
+
assert.match(out, /no module matches that filter/);
|
|
225
|
+
assert.ok(!/KEY\s+SOURCE/.test(out), 'no header row over an empty result');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('cmdList: --json emits the rows + summary, and honours --catalog/--search', () => {
|
|
229
|
+
const lines = [];
|
|
230
|
+
const code = mod.cmdList(['--json', '--catalog', '--search', 'off the boats'], {
|
|
231
|
+
log: (s) => lines.push(s),
|
|
232
|
+
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
233
|
+
});
|
|
234
|
+
assert.equal(code, 0);
|
|
235
|
+
const payload = JSON.parse(lines.join('\n'));
|
|
236
|
+
assert.equal(payload.core, '1.19.0');
|
|
237
|
+
assert.deepEqual(payload.summary, { total: 3, catalog: 2, instance: 1, enabled: 2, catalog_enabled: 1 },
|
|
238
|
+
'the summary describes the WHOLE catalog, not the filtered slice');
|
|
239
|
+
assert.deepEqual(payload.modules.map((m) => m.key), ['provisioning']);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('cmdList: --search with no value is a usage error, not a silent empty listing', () => {
|
|
243
|
+
const errs = [];
|
|
244
|
+
const code = mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
|
|
245
|
+
assert.equal(code, 2);
|
|
246
|
+
assert.match(errs.join('\n'), /usage: bongos module list/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', () => {
|
|
250
|
+
const errs = [];
|
|
251
|
+
const code = mod.cmdList([], {
|
|
252
|
+
log: () => {},
|
|
253
|
+
errlog: (s) => errs.push(s),
|
|
254
|
+
gather: () => ({ rows: sample(), skipped: [{ key: 'rotted', reason: 'coreVersion "^0.1.0" not satisfied by core 1.19.0' }], errors: [], core: '1.19.0' }),
|
|
255
|
+
});
|
|
256
|
+
assert.equal(code, 0);
|
|
257
|
+
assert.match(errs.join('\n'), /rotted: not listed — coreVersion/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// The live tree — the catalog must actually describe THIS checkout
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
test('gatherCatalog: this checkout is the core, so its modules are the catalog', () => {
|
|
265
|
+
const { rows } = mod.gatherCatalog();
|
|
266
|
+
assert.ok(rows.length >= 5, `expected a real module roster, found ${rows.length}`);
|
|
267
|
+
for (const r of rows) {
|
|
268
|
+
assert.ok(['core', 'instance'].includes(r.source), `${r.key}: source is core|instance`);
|
|
269
|
+
assert.equal(typeof r.enabled, 'boolean');
|
|
270
|
+
assert.ok(r.credit && 'inferred' in r.credit, `${r.key}: carries a credit record`);
|
|
271
|
+
}
|
|
272
|
+
// detectRoots() reads THIS package.json, which names the core — so every
|
|
273
|
+
// module discovered here belongs to the catalog. If this ever fails, the
|
|
274
|
+
// catalog is quietly telling instances they can't enable anything.
|
|
275
|
+
assert.equal(catalog.instanceRows(rows).length, 0, 'the core checkout has no instance-only modules');
|
|
276
|
+
assert.equal(catalog.catalogRows(rows).length, rows.length);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('detectRoots: identifies this repo as the core checkout', () => {
|
|
280
|
+
catalog._resetRoots();
|
|
281
|
+
const roots = catalog.detectRoots();
|
|
282
|
+
assert.equal(roots.coreCheckout, true);
|
|
283
|
+
assert.ok(catalog.isCorePackageName(roots.corePackageName));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('detectRoots: memoized — listModules() is on the GET /modules request path, so it must not re-read the disk per call', () => {
|
|
287
|
+
catalog._resetRoots();
|
|
288
|
+
const first = catalog.detectRoots();
|
|
289
|
+
assert.equal(catalog.detectRoots(), first, 'the no-arg result is the SAME object — computed once, not per call');
|
|
290
|
+
catalog._resetRoots();
|
|
291
|
+
assert.notEqual(catalog.detectRoots(), first, '_resetRoots() drops the memo');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('detectRoots: an explicit root bypasses the memo in both directions', () => {
|
|
295
|
+
catalog._resetRoots();
|
|
296
|
+
const cached = catalog.detectRoots();
|
|
297
|
+
const explicit = catalog.detectRoots({ coreRoot: CORE, instanceRoot: INST });
|
|
298
|
+
// Asking about a SPECIFIC tree must not return this process's memoized answer…
|
|
299
|
+
assert.equal(explicit.coreRoot, CORE);
|
|
300
|
+
assert.equal(explicit.instanceRoot, INST);
|
|
301
|
+
assert.notEqual(explicit, cached);
|
|
302
|
+
// …nor poison the memo for every later caller that asks about this process.
|
|
303
|
+
assert.equal(catalog.detectRoots(), cached);
|
|
304
|
+
});
|