@bongos/core 1.19.515 → 1.19.517

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.
Files changed (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +12 -3
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +237 -229
  8. package/docs/copy-registry.json +387 -303
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/hall-ui/public/drachmae.html +3 -0
  11. package/modules/hall-ui/public/drachmae.js +138 -2
  12. package/modules/hall-ui/public/hall-kit.js +45 -7
  13. package/modules/hall-ui/public/profile-charts.js +6 -13
  14. package/modules/hall-ui/public/profile.css +6 -35
  15. package/modules/hall-ui/public/style.css +35 -0
  16. package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
  17. package/modules/provisioning/provisioning.js +24 -4
  18. package/modules/provisioning/routes/provisioning.js +59 -2
  19. package/modules/provisioning/starter-bundles.js +91 -0
  20. package/modules/public-landing/public/assets/cosmos.css +1 -1
  21. package/modules/public-landing/public/projects.html +230 -35
  22. package/modules/public-landing/public/projects.probes.json +2 -2
  23. package/modules/public-landing/public/projects.states.json +5 -4
  24. package/modules/ui-design/kit/serve.js +32 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/hall_kit_charts.mjs +49 -0
  29. package/tests/hall_reference_world.mjs +129 -0
  30. package/tests/projects_hub.mjs +72 -7
  31. package/tests/projects_hub_module_picker.mjs +369 -0
  32. package/tests/provisioning_module_selection.mjs +286 -0
  33. package/tests/provisioning_starter_bundles.mjs +131 -0
@@ -0,0 +1,369 @@
1
+ // tests/projects_hub_module_picker.mjs — the create wizard's module picker,
2
+ // EXECUTED (BV1.R20, task 1002339; goal 1000046 criterion `proj-module-select`).
3
+ //
4
+ // tests/projects_hub.mjs pins this surface statically, on purpose (C7: "real
5
+ // markup, not JS-built"). But the picker is the one part of the wizard whose
6
+ // whole behaviour is decisions — what is preselected, what a toggle does, what a
7
+ // skip means — and a static regex cannot tell a preselect that works from one
8
+ // that silently paints nothing. So this file runs the picker's own source in
9
+ // `vm` under a small DOM stub and asserts what an owner would actually see.
10
+ //
11
+ // The precedent is tests/task_detail_ui.mjs (there is no jsdom in this repo).
12
+ // The difference: task-detail.js is a whole file, while the picker lives inside
13
+ // projects.html's one big inline <script> — so the block is sliced out by its
14
+ // own comment sentinels and run with the five things it closes over ($, root,
15
+ // esc, state, saveDraft/show) supplied by the harness. That slice is itself an
16
+ // assertion: rename the sentinels and this file fails loudly rather than
17
+ // quietly testing nothing.
18
+ //
19
+ // Run: node --test --test-reporter=tap tests/projects_hub_module_picker.mjs
20
+
21
+ import test from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import vm from 'node:vm';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
30
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
31
+
32
+ const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
33
+ const END = "$('skipModules').addEventListener('click', function () { show(5, true); });";
34
+ const from = HUB.indexOf(START);
35
+ const to = HUB.indexOf(END);
36
+ assert.ok(from > -1, 'the picker block opens with its sentinel comment');
37
+ assert.ok(to > from, 'the picker block closes with the skip wiring');
38
+ const PICKER = HUB.slice(from, to + END.length).replace(/\r\n/g, '\n');
39
+
40
+ // The payload GET /provisioning/starter-bundles serves (ADR 0237 + task 1002339).
41
+ // Deliberately a FIXTURE and not a live require: this asserts the picker reads
42
+ // the shape, and tests/provisioning_starter_bundles.mjs asserts the route emits
43
+ // it. Two files, one shape, neither trusting the other's word for it.
44
+ const PAYLOAD = {
45
+ core: ['autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
46
+ 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'public-landing',
47
+ 'security', 'sessions', 'status-ui', 'ui-design'],
48
+ bundles: [
49
+ { type: 'game', bundle: ['dev-box', 'discord'] },
50
+ { type: 'research', bundle: ['agents'] },
51
+ { type: 'business', bundle: ['agents', 'dev-box'] },
52
+ { type: 'non-profit', bundle: ['discord'] },
53
+ { type: 'not-sure', bundle: [] },
54
+ ],
55
+ optional: [
56
+ { key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.' },
57
+ { key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out first.' },
58
+ { key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.' },
59
+ ],
60
+ };
61
+
62
+ function makeEl(id) {
63
+ const classes = new Set();
64
+ const attrs = new Map();
65
+ return {
66
+ id, _html: '', textContent: '', hidden: false, _handlers: {},
67
+ set innerHTML(v) { this._html = String(v); },
68
+ get innerHTML() { return this._html; },
69
+ classList: {
70
+ add: (c) => classes.add(c),
71
+ remove: (c) => classes.delete(c),
72
+ toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
73
+ contains: (c) => classes.has(c),
74
+ },
75
+ setAttribute(k, v) { attrs.set(k, String(v)); },
76
+ getAttribute(k) { return attrs.has(k) ? attrs.get(k) : null; },
77
+ addEventListener(type, fn) { this._handlers[type] = fn; },
78
+ };
79
+ }
80
+
81
+ // Parse the rows the picker WROTE, so a selector resolves only when the markup
82
+ // is genuinely there — the point of the harness.
83
+ function rowsOf(html) {
84
+ const out = [];
85
+ const re = /<button type="button" class="tpill" data-mod="([^"]+)" aria-pressed="(true|false)"><span><b>([^<]*)<\/b><small>([^<]*)<\/small><\/span><\/button>/g;
86
+ let m;
87
+ while ((m = re.exec(html))) out.push({ key: m[1], pressed: m[2] === 'true', label: m[3], summary: m[4] });
88
+ return out;
89
+ }
90
+
91
+ // Boot the picker with a given draft state. `fetchOk: false` drives the
92
+ // couldn't-load branch. Returns the handles the assertions drive it through.
93
+ function boot({ state: seed = {}, fetchOk = true } = {}) {
94
+ const els = new Map();
95
+ const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
96
+ const form = makeEl('panel-modules-form');
97
+ const state = { step: 4, projType: null, modules: null, ...seed };
98
+ const shown = [];
99
+ let saves = 0;
100
+ let fetches = 0;
101
+ let reviews = 0;
102
+
103
+ const sandbox = {
104
+ $,
105
+ root: { querySelector: (sel) => (sel === '#panel-modules form' ? form : null) },
106
+ esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
107
+ state,
108
+ saveDraft: () => { saves++; },
109
+ show: (step) => { shown.push(step); },
110
+ // the block repaints the review when the read lands on step 7 (a resumed
111
+ // draft can reach it having never opened the picker)
112
+ renderReview: () => { reviews++; },
113
+ API: '/api/bongos',
114
+ fetch: () => { fetches++; return Promise.resolve(fetchOk
115
+ ? { ok: true, json: () => Promise.resolve(PAYLOAD) }
116
+ : { ok: false, json: () => Promise.resolve(null) }); },
117
+ Promise, JSON, Math, Object, Array, String, Number, console,
118
+ };
119
+ sandbox.globalThis = sandbox;
120
+ vm.createContext(sandbox);
121
+ vm.runInContext(PICKER, sandbox, { filename: 'projects.html#module-picker' });
122
+
123
+ const band = $('modCards');
124
+ return {
125
+ state, shown, sandbox, form,
126
+ saves: () => saves,
127
+ fetches: () => fetches,
128
+ reviews: () => reviews,
129
+ extrasRow: () => sandbox.extrasRow(),
130
+ // The read is LAZY: the first render kicks it, and its .then repaints when it
131
+ // lands. open() is that whole sequence, which is exactly what entering the
132
+ // step does — so a test never asserts against a paint the browser skips.
133
+ settle: async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); },
134
+ render: () => sandbox.renderModules(),
135
+ open: async function () { sandbox.renderModules(); for (let i = 0; i < 20; i++) await Promise.resolve(); },
136
+ rows: () => rowsOf(band.innerHTML),
137
+ coreNote: () => $('modCoreNote').textContent,
138
+ coreShown: () => $('modCoreNote').classList.contains('on'),
139
+ click: (key) => {
140
+ const target = {
141
+ closest: (sel) => (sel === '.tpill[data-mod]' ? { getAttribute: (k) => (k === 'data-mod' ? key : null) } : null),
142
+ };
143
+ band._handlers.click({ target });
144
+ },
145
+ next: () => form._handlers.submit({}),
146
+ skip: () => $('skipModules')._handlers.click(),
147
+ forCreate: () => sandbox.modulesForCreate(),
148
+ picked: () => sandbox.pickedModules(),
149
+ };
150
+ }
151
+
152
+ test('the declared project type preselects its bundle, and the rest of the catalog is offered switched off', async () => {
153
+ // Arrange — the owner said "game" on the Type step and has touched nothing here
154
+ const h = boot({ state: { projType: 'game' } });
155
+
156
+ // Act
157
+ await h.open();
158
+
159
+ // Assert — every opt-in module is a row; the bundle's two are on
160
+ const rows = h.rows();
161
+ assert.deepEqual(rows.map((r) => r.key), ['agents', 'dev-box', 'discord'], 'the whole opt-in catalog is offered');
162
+ assert.deepEqual(rows.filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord'], 'the game bundle is preselected');
163
+ assert.equal(rows.find((r) => r.key === 'agents').pressed, false, 'and nothing else is');
164
+ // each row carries the copy an owner can actually read
165
+ assert.equal(rows[1].label, 'Dev boxes');
166
+ assert.match(rows[1].summary, /cloud workspace/);
167
+ });
168
+
169
+ test('a different type preselects a different bundle, and "not sure" preselects nothing', async () => {
170
+ for (const [type, expected] of [['research', ['agents']], ['business', ['agents', 'dev-box']], ['non-profit', ['discord']], ['not-sure', []], [null, []]]) {
171
+ const h = boot({ state: { projType: type } });
172
+ await h.open();
173
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), expected, `type=${type}`);
174
+ }
175
+ });
176
+
177
+ test('the always-on core is stated, not rendered as sixteen toggles nobody may touch', async () => {
178
+ // A disabled control reads as a choice the owner failed to make. The core was
179
+ // never a choice, so it is a clause — visible, and outside the band.
180
+ const h = boot({ state: { projType: 'game' } });
181
+ await h.open();
182
+
183
+ assert.equal(h.coreShown(), true, 'the core is stated on the panel, not hidden');
184
+ assert.match(h.coreNote(), /16 core parts/, 'and it names how many');
185
+ assert.match(h.coreNote(), /always on/, 'and that they are not part of this choice');
186
+ for (const key of PAYLOAD.core) {
187
+ assert.ok(!h.rows().some((r) => r.key === key), `"${key}" is never a toggle`);
188
+ }
189
+ });
190
+
191
+ test('a toggle flips one module and leaves the rest of the preselect standing', async () => {
192
+ // Arrange
193
+ const h = boot({ state: { projType: 'game' } });
194
+ await h.open();
195
+
196
+ // Act — switch the bundle's discord off, switch agents on
197
+ h.click('discord');
198
+ h.click('agents');
199
+
200
+ // Assert
201
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'dev-box']);
202
+ assert.deepEqual(h.state.modules, ['agents', 'dev-box'], 'and the draft holds the real answer, in catalog order');
203
+ assert.ok(h.saves() >= 2, 'each toggle is written to the draft');
204
+ });
205
+
206
+ test('the first toggle is what turns "not answered" into an answer', async () => {
207
+ const h = boot({ state: { projType: 'game' } });
208
+ await h.open();
209
+
210
+ assert.equal(h.state.modules, null, 'before a touch, nothing has been said');
211
+ assert.equal(h.forCreate(), undefined, 'so the create request carries no modules key');
212
+
213
+ h.click('agents');
214
+ assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord'], 'after a touch it carries the whole set');
215
+ });
216
+
217
+ test('switching every module off is a real answer, not a reset to the bundle', async () => {
218
+ // The case a NOT NULL DEFAULT [] column could never have expressed: the owner
219
+ // said "none", and Next must not hand them the bundle back.
220
+ const h = boot({ state: { projType: 'game' } });
221
+ await h.open();
222
+ h.click('dev-box');
223
+ h.click('discord');
224
+
225
+ assert.deepEqual(h.state.modules, []);
226
+ assert.deepEqual(h.forCreate(), [], 'an empty answer travels — it is not the same as absent');
227
+ assert.deepEqual(h.rows().filter((r) => r.pressed), [], 'and the panel shows it');
228
+ });
229
+
230
+ test('Skip walks past WITHOUT answering, so the server applies the bundle the panel showed', async () => {
231
+ // Arrange
232
+ const h = boot({ state: { projType: 'game' } });
233
+ await h.open();
234
+
235
+ // Act
236
+ h.skip();
237
+
238
+ // Assert — skipping is the one path that must leave nothing said, because the
239
+ // preselect and the server's fallback are the same declaration (ADR 0237)
240
+ assert.deepEqual(h.shown, [5], 'it advances to the Home step');
241
+ assert.equal(h.state.modules, null, 'and answers nothing');
242
+ assert.equal(h.forCreate(), undefined);
243
+ assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'while the set they were shown is still the set they get');
244
+ });
245
+
246
+ test('Skip does not erase a toggle already made', async () => {
247
+ // The About step's rule: a visitor who changed something and then pressed Skip
248
+ // meant "I'm done here", not "throw that away".
249
+ const h = boot({ state: { projType: 'game' } });
250
+ await h.open();
251
+ h.click('agents');
252
+ h.skip();
253
+
254
+ assert.deepEqual(h.state.modules, ['agents', 'dev-box', 'discord']);
255
+ });
256
+
257
+ test('Next CONFIRMS the set on screen — looking at it and accepting it is not the same as never being asked', async () => {
258
+ const h = boot({ state: { projType: 'game' } });
259
+ await h.open();
260
+
261
+ h.next();
262
+
263
+ assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'the preselect becomes the answer');
264
+ assert.deepEqual(h.forCreate(), ['dev-box', 'discord'], 'and rides the create request');
265
+ });
266
+
267
+ test('changing the project type re-preselects while nothing has been said, and never after', async () => {
268
+ // The arrow runs type -> modules (ADR 0237 §2). It must not run backwards over
269
+ // a choice the owner already made.
270
+ const h = boot({ state: { projType: 'game' } });
271
+ await h.open();
272
+
273
+ h.state.projType = 'research';
274
+ h.render();
275
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'an untouched picker follows the type');
276
+
277
+ h.click('discord');
278
+ h.state.projType = 'non-profit';
279
+ h.render();
280
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'discord'],
281
+ 'an answered picker keeps the answer');
282
+ });
283
+
284
+ test('a draft resumed with a stored answer repaints that answer, not the bundle', async () => {
285
+ // sessionStorage survives a reload; the panel must not quietly re-apply the
286
+ // preselect over what the owner picked before they closed the tab.
287
+ const h = boot({ state: { projType: 'game', modules: ['agents'] } });
288
+ await h.open();
289
+
290
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents']);
291
+ });
292
+
293
+ test('a catalog that will not load degrades to a clause — it never blocks a project from being created', async () => {
294
+ // Arrange
295
+ const h = boot({ state: { projType: 'game' }, fetchOk: false });
296
+
297
+ // Act
298
+ await h.open();
299
+
300
+ // Assert
301
+ assert.deepEqual(h.rows(), [], 'no rows are invented');
302
+ assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
303
+ assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
304
+ h.skip();
305
+ assert.deepEqual(h.shown, [5], 'and the wizard still walks on — the server applies the bundle');
306
+ });
307
+
308
+ test('every rendered value goes through the page escaper', async () => {
309
+ // The rows are built with innerHTML (renderReview's existing idiom in this
310
+ // file), so the escaping is the safety property and it is asserted, not assumed.
311
+ const h = boot({ state: { projType: 'game' } });
312
+ await h.open();
313
+ h.sandbox.starterBundles = {
314
+ core: [], bundles: [],
315
+ optional: [{ key: 'x"><script>', label: '<b>bad</b>', summary: 'a & b' }],
316
+ };
317
+ h.render();
318
+
319
+ const html = h.sandbox.$('modCards').innerHTML;
320
+ assert.ok(!html.includes('<script>'), 'a hostile key cannot break out of the attribute');
321
+ assert.ok(html.includes('&lt;b&gt;bad&lt;/b&gt;'), 'a label is escaped');
322
+ assert.ok(html.includes('a &amp; b'), 'and so is a summary');
323
+ });
324
+
325
+ test('the catalog read is LAZY and asked once — /projects serves four views from one file', async () => {
326
+ // The map view has no business paying for the picker's read, and re-entering
327
+ // the step must not re-ask. fetchPlan() beside it memoises for the same reason.
328
+ // The counter is wired INTO the sandbox before the block runs, so a fetch at
329
+ // boot would be caught — a counter installed afterwards would prove nothing.
330
+ const h = boot({ state: { projType: 'game' } });
331
+
332
+ assert.equal(h.fetches(), 0, 'loading the page does not ask');
333
+ await h.open();
334
+ await h.open();
335
+ assert.equal(h.fetches(), 1, 'entering the step asks exactly once, however often it is entered');
336
+ });
337
+
338
+ test('the review names the set even when the picker was never opened (task 1002339)', async () => {
339
+ // A resumed draft lands straight on the review, so the catalog has not loaded
340
+ // and the module NAMES are not known. "None — just the core" would be a false
341
+ // promise on the last screen before Create: the server is about to apply the
342
+ // type's bundle. The row says that instead, then repaints when the read lands.
343
+ const h = boot({ state: { projType: 'game', step: 7 } });
344
+
345
+ // Arrange + Act — the review asks for the value before the read can answer
346
+ const first = h.extrasRow();
347
+
348
+ // Assert
349
+ assert.match(first, /suggested set/, 'it promises what the owner will actually get');
350
+ assert.equal(h.fetches(), 1, 'and asking for it is what starts the read');
351
+
352
+ await h.settle();
353
+ assert.equal(h.reviews(), 1, 'the review repaints itself once the names are known');
354
+ assert.equal(h.extrasRow(), 'Dev boxes, Discord', 'and then it names them');
355
+ });
356
+
357
+ test('the review reads an answered picker back, empty included', async () => {
358
+ const chosen = boot({ state: { projType: 'game', modules: ['agents'], step: 7 } });
359
+ // before the catalog lands the row says the true thing without the names —
360
+ // never the raw module keys, which are jargon on an owner-facing screen
361
+ assert.equal(chosen.extrasRow(), 'The extras you picked');
362
+ await chosen.settle();
363
+ assert.equal(chosen.extrasRow(), 'Extra agents', 'and names them once it has');
364
+
365
+ const none = boot({ state: { projType: 'game', modules: [], step: 7 } });
366
+ none.extrasRow();
367
+ await none.settle();
368
+ assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
369
+ });
@@ -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);