@bongos/core 1.19.515 → 1.19.516
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 +48 -28
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +12 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
- package/modules/provisioning/provisioning.js +24 -4
- package/modules/provisioning/routes/provisioning.js +59 -2
- package/modules/provisioning/starter-bundles.js +91 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +230 -35
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +5 -4
- package/modules/ui-design/kit/serve.js +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub.mjs +72 -7
- package/tests/projects_hub_module_picker.mjs +369 -0
- package/tests/provisioning_module_selection.mjs +286 -0
- package/tests/provisioning_starter_bundles.mjs +131 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// tests/provisioning_module_selection.mjs
|
|
2
|
+
//
|
|
3
|
+
// Where the creation picker's chosen module set LANDS (BV1.R20, task 1002339;
|
|
4
|
+
// goal 1000046 criterion `proj-module-select`, migration provisioning_014).
|
|
5
|
+
//
|
|
6
|
+
// R19 declared the bundles and served them; this is the half that stores an
|
|
7
|
+
// answer. The whole contract is three-valued and every test below exists to keep
|
|
8
|
+
// one of the three legible:
|
|
9
|
+
//
|
|
10
|
+
// absent the owner skipped the picker → the column stores NULL and the READ
|
|
11
|
+
// resolves the declared type's starter bundle, so a skip yields exactly
|
|
12
|
+
// the set the panel preselected.
|
|
13
|
+
// [] the owner said "none of them" → stored as an empty array, and it
|
|
14
|
+
// stays empty. A NOT NULL DEFAULT '[]' column could never have said this.
|
|
15
|
+
// [...] what they picked.
|
|
16
|
+
//
|
|
17
|
+
// Three layers, mirroring tests/provisioning_onboard_mode.mjs beside it:
|
|
18
|
+
// • createInstance — what actually reaches the INSERT params
|
|
19
|
+
// • POST /instances — validation (loud, never a silent drop) + threading
|
|
20
|
+
// • publicInstance — what a reader gets back, resolved rather than raw
|
|
21
|
+
//
|
|
22
|
+
// DB-free: the pool is a recording fake and the data-access fns the route needs
|
|
23
|
+
// are stubbed on the cached module object. No network beyond a loopback listener.
|
|
24
|
+
//
|
|
25
|
+
// Run: node --test --test-reporter=tap tests/provisioning_module_selection.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
// ESM named imports off the CJS module on purpose — knip cannot trace a
|
|
31
|
+
// createRequire() import, so these would read as dead exports (the repo's knip
|
|
32
|
+
// learning; tests/provisioning_starter_bundles.mjs does the same).
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType } from '../modules/provisioning/starter-bundles.js';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
37
|
+
|
|
38
|
+
let passed = 0, failed = 0;
|
|
39
|
+
function t(name, fn) {
|
|
40
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
41
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
42
|
+
}
|
|
43
|
+
async function ta(name, fn) {
|
|
44
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
45
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── createInstance: what reaches the column ─────────────────────────────────
|
|
49
|
+
// A recording pool: assert the STORED value, not just the returned row. A
|
|
50
|
+
// normalization bug that only shows up in the column is the one that would
|
|
51
|
+
// mislabel a project's modules forever.
|
|
52
|
+
function recordingDb(returnRow = {}) {
|
|
53
|
+
const calls = [];
|
|
54
|
+
return {
|
|
55
|
+
calls,
|
|
56
|
+
query: async (text, params) => {
|
|
57
|
+
calls.push({ text, params });
|
|
58
|
+
return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo', ...returnRow }] };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function insertParams(db) {
|
|
64
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
65
|
+
assert.ok(insert, 'issued an INSERT');
|
|
66
|
+
assert.match(insert.text, /modules/, 'the modules column is in the INSERT');
|
|
67
|
+
return insert.params;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await ta('createInstance: a chosen set is stored as a jsonb array, in catalog order', async () => {
|
|
71
|
+
// Arrange
|
|
72
|
+
const db = recordingDb();
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
await provisioning.createInstance(db, {
|
|
76
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game',
|
|
77
|
+
modules: ['discord', 'agents'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
const params = insertParams(db);
|
|
82
|
+
assert.ok(params.includes(JSON.stringify(['agents', 'discord'])),
|
|
83
|
+
`the normalized selection reached the INSERT (got ${JSON.stringify(params)})`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ta('createInstance: NO answer stores NULL — the skip has to stay readable as a skip', async () => {
|
|
87
|
+
// If this stored '[]' the row would claim the owner turned every module off,
|
|
88
|
+
// and the read-time bundle fallback could never fire again.
|
|
89
|
+
for (const modules of [undefined, null, 'discord', 42]) {
|
|
90
|
+
const db = recordingDb();
|
|
91
|
+
await provisioning.createInstance(db, {
|
|
92
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules,
|
|
93
|
+
});
|
|
94
|
+
const params = insertParams(db);
|
|
95
|
+
assert.equal(params[13], null, `modules=${JSON.stringify(modules)} stores NULL, not an empty list`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ta('createInstance: an explicit empty answer is stored, and is NOT the same as absent', async () => {
|
|
100
|
+
const db = recordingDb();
|
|
101
|
+
await provisioning.createInstance(db, {
|
|
102
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules: [],
|
|
103
|
+
});
|
|
104
|
+
assert.equal(insertParams(db)[13], '[]', 'the owner said none of them, and the row says so');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await ta('createInstance: the always-on core is never written into the selection', async () => {
|
|
108
|
+
// The core is the floor every project gets, not a pick. Storing it would make
|
|
109
|
+
// the column disagree with ALWAYS_ON_CORE the day a manifest flips its default.
|
|
110
|
+
const db = recordingDb();
|
|
111
|
+
await provisioning.createInstance(db, {
|
|
112
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', modules: ['memory', 'discord', 'provisioning'],
|
|
113
|
+
});
|
|
114
|
+
assert.equal(insertParams(db)[13], JSON.stringify(['discord']));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── publicInstance: what a reader gets back ────────────────────────────────
|
|
118
|
+
|
|
119
|
+
t('publicInstance: a skipped picker reads as the declared type\'s bundle', () => {
|
|
120
|
+
// Arrange — the row a skip writes
|
|
121
|
+
const row = { id: 1, slug: 'demo', status: 'requested', type: 'game', modules: null, detail: {} };
|
|
122
|
+
|
|
123
|
+
// Act
|
|
124
|
+
const out = provisioning.publicInstance(row);
|
|
125
|
+
|
|
126
|
+
// Assert — resolved, never raw: no caller should have to re-derive the rule,
|
|
127
|
+
// which is the one way two surfaces come to name different modules.
|
|
128
|
+
assert.deepEqual(out.modules.selected, bundleForProjectType('game').bundle);
|
|
129
|
+
assert.deepEqual(out.modules.core, [...ALWAYS_ON_CORE]);
|
|
130
|
+
assert.equal(out.modules.source, 'bundle');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
t('publicInstance: a chosen set reads back as chosen, empty included', () => {
|
|
134
|
+
const chosen = provisioning.publicInstance({ id: 1, type: 'game', modules: ['agents'], detail: {} });
|
|
135
|
+
assert.deepEqual(chosen.modules.selected, ['agents']);
|
|
136
|
+
assert.equal(chosen.modules.source, 'chosen');
|
|
137
|
+
|
|
138
|
+
const none = provisioning.publicInstance({ id: 1, type: 'game', modules: [], detail: {} });
|
|
139
|
+
assert.deepEqual(none.modules.selected, []);
|
|
140
|
+
assert.equal(none.modules.source, 'chosen', 'a project that switched the bundle off does not get it back');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
t('publicInstance: a row written before provisioning_014 reads as the bundle, not as an error', () => {
|
|
144
|
+
// Every existing row has no `modules` key at all. Nullable + read-time
|
|
145
|
+
// resolution is what makes the migration a no-op for them.
|
|
146
|
+
const out = provisioning.publicInstance({ id: 1, slug: 'old', status: 'active', type: 'research', detail: {} });
|
|
147
|
+
assert.deepEqual(out.modules.selected, ['agents']);
|
|
148
|
+
assert.equal(out.modules.source, 'bundle');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── POST /instances, over a real mounted router ────────────────────────────
|
|
152
|
+
|
|
153
|
+
let lastCreateArgs = null;
|
|
154
|
+
let existingRow = null;
|
|
155
|
+
provisioning.getInstanceBySlug = async () => existingRow;
|
|
156
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
157
|
+
provisioning.getInstanceById = async () => null;
|
|
158
|
+
provisioning.createInstance = async (_db, args) => {
|
|
159
|
+
lastCreateArgs = args;
|
|
160
|
+
return existingRow
|
|
161
|
+
? { instance: existingRow, created: false }
|
|
162
|
+
: { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'standalone', type: args.type || null, detail: {} }, created: true };
|
|
163
|
+
};
|
|
164
|
+
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
165
|
+
provisioning.getOpenIntent = async () => null;
|
|
166
|
+
provisioning.recordEvent = async () => {};
|
|
167
|
+
|
|
168
|
+
const api = require('../src/module-api.js');
|
|
169
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
170
|
+
|
|
171
|
+
const express = require('express');
|
|
172
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
173
|
+
const app = express();
|
|
174
|
+
app.use(express.json());
|
|
175
|
+
app.use((_req, res, next) => {
|
|
176
|
+
res.fail = (code, opts, details) => {
|
|
177
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
178
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
179
|
+
};
|
|
180
|
+
next();
|
|
181
|
+
});
|
|
182
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
183
|
+
|
|
184
|
+
const server = app.listen(0);
|
|
185
|
+
await new Promise((r) => server.once('listening', r));
|
|
186
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
187
|
+
|
|
188
|
+
function post(path, body) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const payload = JSON.stringify(body);
|
|
191
|
+
const req = http.request(`${base}${path}`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
194
|
+
}, (res) => {
|
|
195
|
+
let d = '';
|
|
196
|
+
res.on('data', (c) => { d += c; });
|
|
197
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
198
|
+
});
|
|
199
|
+
req.on('error', reject);
|
|
200
|
+
req.end(payload);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await ta('POST /instances: the picker\'s ids ride the create request through to storage', async () => {
|
|
205
|
+
// Arrange
|
|
206
|
+
lastCreateArgs = null; existingRow = null;
|
|
207
|
+
|
|
208
|
+
// Act — the body the wizard files
|
|
209
|
+
const res = await post('/provisioning/instances', {
|
|
210
|
+
slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['dev-box', 'discord'],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Assert
|
|
214
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
215
|
+
assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['dev-box', 'discord']);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
|
|
219
|
+
lastCreateArgs = null; existingRow = null;
|
|
220
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', type: 'game' });
|
|
221
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
222
|
+
assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
|
|
223
|
+
// …and the receipt already carries the set they will actually get.
|
|
224
|
+
assert.deepEqual(res.body.instance.modules.selected, bundleForProjectType('game').bundle);
|
|
225
|
+
assert.equal(res.body.instance.modules.source, 'bundle');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
|
|
229
|
+
lastCreateArgs = null; existingRow = null;
|
|
230
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: [] });
|
|
231
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
232
|
+
assert.deepEqual(lastCreateArgs.modules, []);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await ta('POST /instances: an unknown module is REFUSED and named, never silently dropped', async () => {
|
|
236
|
+
// The normalizer would drop it on the way to storage and the caller would be
|
|
237
|
+
// told nothing — the exact silent-drop failure the strict validator exists to
|
|
238
|
+
// prevent, and the reason badProjectDetail was written beside this.
|
|
239
|
+
existingRow = null;
|
|
240
|
+
const res = await post('/provisioning/instances', {
|
|
241
|
+
slug: 'mercury', hosting_shape: 'standalone', modules: ['discrod'],
|
|
242
|
+
});
|
|
243
|
+
assert.equal(res.status, 400, res.raw);
|
|
244
|
+
assert.equal(res.body.error.code, 'bad_modules');
|
|
245
|
+
assert.match(res.body.error.message, /discrod/, 'the message names what was wrong');
|
|
246
|
+
assert.match(res.body.error.message, /agents/, 'and what the choices are');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
|
|
250
|
+
existingRow = null;
|
|
251
|
+
const res = await post('/provisioning/instances', {
|
|
252
|
+
slug: 'mercury', hosting_shape: 'standalone', modules: ['memory'],
|
|
253
|
+
});
|
|
254
|
+
assert.equal(res.status, 400, res.raw);
|
|
255
|
+
assert.equal(res.body.error.code, 'bad_modules');
|
|
256
|
+
assert.match(res.body.error.message, /always on/i, '"memory" exists — it just was never a choice');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await ta('POST /instances: modules must be an array of strings', async () => {
|
|
260
|
+
existingRow = null;
|
|
261
|
+
const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', modules: 'discord' });
|
|
262
|
+
assert.equal(notArray.status, 400, notArray.raw);
|
|
263
|
+
assert.equal(notArray.body.error.code, 'validation_failed');
|
|
264
|
+
|
|
265
|
+
const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'standalone', modules: [7] });
|
|
266
|
+
assert.equal(notStrings.status, 400, notStrings.raw);
|
|
267
|
+
assert.equal(notStrings.body.error.code, 'validation_failed');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await ta('POST /instances: a re-request never changes an existing project\'s modules — and SAYS so', async () => {
|
|
271
|
+
// Same honesty as type_ignored (task 1002772). There is no mutation route yet
|
|
272
|
+
// (R23, task 1002342 is "add modules later"), so the receipt does not name one.
|
|
273
|
+
existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
|
|
274
|
+
const res = await post('/provisioning/instances', {
|
|
275
|
+
slug: 'mercury', hosting_shape: 'standalone', modules: ['discord'],
|
|
276
|
+
});
|
|
277
|
+
assert.equal(res.status, 200, res.raw);
|
|
278
|
+
assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
|
|
279
|
+
assert.match(res.body.modules_ignored.hint, /never changes/);
|
|
280
|
+
assert.deepEqual(res.body.instance.modules.selected, ['agents'], 'and the stored answer is what reads back');
|
|
281
|
+
existingRow = null;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
server.close();
|
|
285
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
286
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -34,10 +34,13 @@ import { createRequire } from 'node:module';
|
|
|
34
34
|
// the knip ratchet (see the repo's knip learning).
|
|
35
35
|
import {
|
|
36
36
|
ALWAYS_ON_CORE,
|
|
37
|
+
OPTIONAL_MODULES,
|
|
37
38
|
STARTER_BUNDLES,
|
|
38
39
|
HUB_ONLY_MODULES,
|
|
39
40
|
bundleForProjectType,
|
|
40
41
|
allStarterBundles,
|
|
42
|
+
normalizeModuleSelection,
|
|
43
|
+
effectiveModules,
|
|
41
44
|
} from '../modules/provisioning/starter-bundles.js';
|
|
42
45
|
|
|
43
46
|
const require = createRequire(import.meta.url);
|
|
@@ -266,3 +269,131 @@ test('the route never reaches the database — the bundles are a declaration, no
|
|
|
266
269
|
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
267
270
|
assert.equal(res.status, 200);
|
|
268
271
|
});
|
|
272
|
+
|
|
273
|
+
// ── the opt-in catalog + the selection rules (BV1.R20, task 1002339) ─────────
|
|
274
|
+
|
|
275
|
+
test('the opt-in catalog declared for the picker IS the on-disk opt-in set', () => {
|
|
276
|
+
// Arrange — the manifests are the catalog; OPTIONAL_MODULES is the declaration
|
|
277
|
+
// the picker renders. This is the same link the bundle<->catalog tests above
|
|
278
|
+
// make, applied to the choices: a new default:false module cannot ship without
|
|
279
|
+
// owner-facing copy, and a declared choice cannot outlive its module.
|
|
280
|
+
const onDisk = manifestsOnDisk();
|
|
281
|
+
const optInOnDisk = [...onDisk.values()]
|
|
282
|
+
.filter((m) => m.default !== true && !HUB_ONLY_MODULES.includes(m.key))
|
|
283
|
+
.map((m) => m.key)
|
|
284
|
+
.sort();
|
|
285
|
+
|
|
286
|
+
// Act
|
|
287
|
+
const declared = OPTIONAL_MODULES.map((m) => m.key).sort();
|
|
288
|
+
|
|
289
|
+
// Assert
|
|
290
|
+
assert.deepEqual(declared, optInOnDisk,
|
|
291
|
+
'the picker\'s catalog has drifted from the manifests — a module was added, removed, or flipped its default');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('every opt-in module carries owner-facing copy, and no module jargon rides along', () => {
|
|
295
|
+
for (const row of OPTIONAL_MODULES) {
|
|
296
|
+
assert.ok(row.label && row.label.length <= 40, `"${row.key}" needs a short label`);
|
|
297
|
+
assert.ok(row.summary && row.summary.length > 20, `"${row.key}" needs a real one-line summary`);
|
|
298
|
+
// The owner choosing here has met none of these modules; a manifest sentence
|
|
299
|
+
// ("Mirror only, never authority (ADR 0033)") is true and unusable.
|
|
300
|
+
assert.doesNotMatch(`${row.label} ${row.summary}`, /\bADR\b|\bjsonb\b|\bAPI\b|\bwebhook\b|_/i,
|
|
301
|
+
`"${row.key}" reads like a manifest, not like a choice`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('every module a bundle preselects is a row the picker can actually render', () => {
|
|
306
|
+
// A preselect with no catalog row would light up nothing — the panel would
|
|
307
|
+
// silently ship a choice the owner cannot see or switch off.
|
|
308
|
+
const offered = OPTIONAL_MODULES.map((m) => m.key);
|
|
309
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
310
|
+
for (const key of bundle.modules) {
|
|
311
|
+
assert.ok(offered.includes(key), `bundle "${type}" preselects "${key}", which the picker does not offer`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('normalizeModuleSelection: absent is null, an empty answer is [], and order is the catalog\'s', () => {
|
|
317
|
+
// Arrange + Act + Assert — the three-valued contract the nullable column exists for
|
|
318
|
+
assert.equal(normalizeModuleSelection(undefined), null, 'never answered');
|
|
319
|
+
assert.equal(normalizeModuleSelection(null), null, 'never answered');
|
|
320
|
+
assert.equal(normalizeModuleSelection('discord'), null, 'a non-array is not an answer');
|
|
321
|
+
assert.deepEqual(normalizeModuleSelection([]), [], 'the owner said none — a real answer');
|
|
322
|
+
// catalog order, not the caller's
|
|
323
|
+
assert.deepEqual(normalizeModuleSelection(['discord', 'agents']), ['agents', 'discord']);
|
|
324
|
+
assert.deepEqual(normalizeModuleSelection(['discord', 'discord']), ['discord'], 'deduped');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('normalizeModuleSelection drops what was never a choice — always-on, hub-only, unknown', () => {
|
|
328
|
+
assert.deepEqual(normalizeModuleSelection(['discord', 'memory']), ['discord'], 'always-on is the floor, not a pick');
|
|
329
|
+
assert.deepEqual(normalizeModuleSelection(['provisioning']), [], 'the control plane is not a project module');
|
|
330
|
+
assert.deepEqual(normalizeModuleSelection(['nope', 42, null]), [], 'nothing is invented from junk');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('effectiveModules: a skipped picker yields exactly the declared type\'s bundle', () => {
|
|
334
|
+
// Arrange — the row a skip writes: type declared, modules NULL
|
|
335
|
+
const row = { type: 'game', modules: null };
|
|
336
|
+
|
|
337
|
+
// Act
|
|
338
|
+
const eff = effectiveModules(row);
|
|
339
|
+
|
|
340
|
+
// Assert — the preselect and the fallback are the SAME declaration, which is
|
|
341
|
+
// what makes "skip = what the panel showed you" true rather than a promise.
|
|
342
|
+
assert.deepEqual(eff.selected, bundleForProjectType('game').bundle);
|
|
343
|
+
assert.deepEqual(eff.modules, bundleForProjectType('game').modules);
|
|
344
|
+
assert.equal(eff.source, 'bundle', 'and a reader can tell nobody answered');
|
|
345
|
+
assert.deepEqual(eff.core, [...ALWAYS_ON_CORE]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('effectiveModules: an answered picker wins over the bundle, including "none of them"', () => {
|
|
349
|
+
assert.deepEqual(effectiveModules({ type: 'game', modules: ['agents'] }).selected, ['agents']);
|
|
350
|
+
assert.equal(effectiveModules({ type: 'game', modules: ['agents'] }).source, 'chosen');
|
|
351
|
+
// the case a NOT NULL DEFAULT '[]' column could never have expressed
|
|
352
|
+
const none = effectiveModules({ type: 'game', modules: [] });
|
|
353
|
+
assert.deepEqual(none.selected, [], 'the owner switched the bundle off, and it stays off');
|
|
354
|
+
assert.deepEqual(none.modules, [...ALWAYS_ON_CORE]);
|
|
355
|
+
assert.equal(none.source, 'chosen');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('effectiveModules: an undeclared type resolves to the core, never a guess', () => {
|
|
359
|
+
for (const row of [{ type: null, modules: null }, { type: 'not-sure', modules: null }, {}]) {
|
|
360
|
+
const eff = effectiveModules(row);
|
|
361
|
+
assert.deepEqual(eff.selected, [], `${JSON.stringify(row)} preselects nothing`);
|
|
362
|
+
assert.deepEqual(eff.modules, [...ALWAYS_ON_CORE]);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('GET /provisioning/starter-bundles carries the opt-in catalog the picker renders', async () => {
|
|
367
|
+
// Act
|
|
368
|
+
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
369
|
+
|
|
370
|
+
// Assert — one read gives the picker both halves: what is preselected, and
|
|
371
|
+
// what may be chosen. A second round trip is what lets them disagree.
|
|
372
|
+
assert.equal(res.status, 200, res.raw);
|
|
373
|
+
assert.deepEqual(res.body.optional, OPTIONAL_MODULES.map((m) => ({ ...m })));
|
|
374
|
+
for (const row of res.body.optional) {
|
|
375
|
+
assert.deepEqual(Object.keys(row).sort(), ['key', 'label', 'summary']);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('the design kit\'s stub fixture is the same payload this route serves', () => {
|
|
380
|
+
// modules/ui-design/kit/serve.js answers the picker's read with a hand-written
|
|
381
|
+
// fixture (the stub imports no module, by design — see modules/public-landing/
|
|
382
|
+
// CLAUDE.md's "the kit's stub is not the server" trap). This is the pin that
|
|
383
|
+
// stops the picture the kit renders from drifting away from the real payload:
|
|
384
|
+
// task 1003352 shipped a body the real route would have 400'd on, past 47
|
|
385
|
+
// green probes and a clean render, because nothing compared the two.
|
|
386
|
+
const stub = fs.readFileSync(path.join(fileURLToPath(new URL('..', import.meta.url)), 'modules/ui-design/kit/serve.js'), 'utf8');
|
|
387
|
+
const src = (stub.match(/const STARTER_BUNDLES_FIXTURE = (\{[\s\S]*?\n\};)/) || [, ''])[1];
|
|
388
|
+
assert.ok(src, 'the fixture is extractable');
|
|
389
|
+
// eslint-disable-next-line no-new-func -- reading a literal out of our own repo file
|
|
390
|
+
const fixture = new Function(`return ${src.replace(/;$/, '')}`)();
|
|
391
|
+
|
|
392
|
+
assert.deepEqual(fixture.core, [...ALWAYS_ON_CORE], 'the core the panel counts');
|
|
393
|
+
assert.deepEqual(fixture.optional, OPTIONAL_MODULES.map((m) => ({ ...m })), 'the catalog the panel renders');
|
|
394
|
+
assert.deepEqual(
|
|
395
|
+
fixture.bundles.map((b) => ({ type: b.type, bundle: b.bundle })),
|
|
396
|
+
allStarterBundles().map((b) => ({ type: b.type, bundle: b.bundle })),
|
|
397
|
+
'and what each type preselects',
|
|
398
|
+
);
|
|
399
|
+
});
|