@bongos/core 1.19.514 → 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 +81 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +12 -0
- 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 +362 -6
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
- package/modules/government/catalog.js +6 -0
- package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
- package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
- package/modules/lifecycle/mingle.js +337 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/mingle.js +116 -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/government_seed.mjs +35 -0
- package/tests/mingle.mjs +370 -0
- 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,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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('<b>bad</b>'), 'a label is escaped');
|
|
322
|
+
assert.ok(html.includes('a & 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);
|