@bongos/core 1.21.156 → 1.21.157
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 +40 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +159 -2
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +28 -22
- package/docs/copy-registry.json +77 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-readings.json +29 -24
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/modules.html +11 -0
- package/modules/hall-ui/public/modules.js +104 -0
- package/modules/hall-ui/records/catalog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-testing.js +73 -0
- package/src/bongos/project-settings.js +3 -0
- package/src/bongos/routes/modules.js +43 -0
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +18 -2
- package/tests/module_testing.mjs +171 -0
|
@@ -223,7 +223,7 @@ const PERMISSIONS = [
|
|
|
223
223
|
{ key: 'goal.create', system: false, floor: 'metic', guards: 'POST /goals (protected scope → Archon in-handler; ADR 0086)' },
|
|
224
224
|
{ key: 'goal.archive', system: false, floor: 'metic', guards: 'POST /goals/:id/{archive,reopen}' },
|
|
225
225
|
{ key: 'goal.scope.widen', system: false, floor: 'metic', guards: 'POST /goals/:id/scope (same-tier; cross-tier = goal.scope.manage, system)' },
|
|
226
|
-
{ key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable' },
|
|
226
|
+
{ key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable, POST /modules/testing and POST /modules/:key/general-only (whether this project tests new module versions first, and which modules it keeps on general — task 1003802, ADR 0344 D1)' },
|
|
227
227
|
{ key: 'module.submit', system: false, floor: 'metic', guards: 'POST /modules/:key/submit, GET /modules/submissions' },
|
|
228
228
|
// Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
|
|
229
229
|
// hand-set score: the owner ruled a person may ask for a re-check, not change the number.
|
|
@@ -74,6 +74,17 @@
|
|
|
74
74
|
<p class="ov-foot" id="submit-lede">A module built here can be submitted upstream for review, so it can join the shared catalog with your name and this project credited.</p>
|
|
75
75
|
</div>
|
|
76
76
|
|
|
77
|
+
<!-- Testing new versions (task 1003802, ADR 0344 D1): the PROJECT opts in,
|
|
78
|
+
once, by its admin; a module can be kept on general versions from its
|
|
79
|
+
own details. Hidden until GET /modules/testing answers. -->
|
|
80
|
+
<div class="ov-group" id="testing-group" hidden>
|
|
81
|
+
<div class="ov-group__head">
|
|
82
|
+
<span class="ov-group__name">Testing new versions</span>
|
|
83
|
+
</div>
|
|
84
|
+
<ul class="ov-rows" id="testing-grid"></ul>
|
|
85
|
+
<p class="ov-foot" id="testing-lede">A testing project gets each new version of its modules before everyone else, and what it finds feeds the module's score. It is never switched on for you. To keep one module on the versions everyone gets, open its details.</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
77
88
|
<!-- In the store — what GET /store/modules lists, each with what it is
|
|
78
89
|
strong and weak at (task 1003799, ADR 0343 D4), so two modules with
|
|
79
90
|
the same score can be told apart. Hidden until the store answers
|
|
@@ -171,9 +171,108 @@
|
|
|
171
171
|
if (mt.removeAfter) line('removable after core', `<span class="ov-mono">${escapeHtml(String(mt.removeAfter))}</span>`);
|
|
172
172
|
if (mt.note) line('note', escapeHtml(String(mt.note)));
|
|
173
173
|
if (m.default != null) line('ships', m.default ? 'default on' : 'default off');
|
|
174
|
+
const versions = testingLine(m);
|
|
175
|
+
if (versions) line('new versions', versions);
|
|
174
176
|
return lines.length ? `<ul class="ov-lines">${lines.join('')}</ul>` : '';
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
// ---- testing new versions (task 1003802, ADR 0344 D1) ----
|
|
180
|
+
//
|
|
181
|
+
// GET /modules/testing says whether this project tests new versions first and
|
|
182
|
+
// which modules it keeps on general. The server owns the answer; the page shows
|
|
183
|
+
// it and sends the two switches, which take the same permission as the
|
|
184
|
+
// enable toggle (canToggle mirrors it, the server decides).
|
|
185
|
+
let testing = null;
|
|
186
|
+
|
|
187
|
+
function generalOnly(key) {
|
|
188
|
+
return !!(testing && Array.isArray(testing.general_only) && testing.general_only.includes(key));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One module's line in its details: only while the project is testing.
|
|
192
|
+
function testingLine(m) {
|
|
193
|
+
if (!testing || !testing.tester) return '';
|
|
194
|
+
const kept = generalOnly(m.key);
|
|
195
|
+
const says = kept ? 'kept on the versions everyone gets' : 'gets new versions first';
|
|
196
|
+
if (!canToggle) return escapeHtml(says);
|
|
197
|
+
return `${escapeHtml(says)} <button type="button" class="btn-ghost btn--sm mod-general-only" data-key="${escapeHtml(m.key)}" data-on="${kept ? 'false' : 'true'}">${kept ? 'test new versions' : 'keep on general versions'}</button>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function testingRowHTML() {
|
|
201
|
+
const on = !!testing.tester;
|
|
202
|
+
const kept = (testing.general_only || [])
|
|
203
|
+
.map((k) => (allModules.find((m) => m.key === k) || {}).title || k);
|
|
204
|
+
const facts = [on
|
|
205
|
+
? '<span class="fact-pill fact-pill--info">testing</span>'
|
|
206
|
+
: '<span class="ov-fact">not testing</span>'];
|
|
207
|
+
if (on && kept.length) facts.push(`<span class="ov-fact">kept on general: ${kept.map((t) => escapeHtml(t)).join(', ')}</span>`);
|
|
208
|
+
const control = canToggle
|
|
209
|
+
? `<button type="button" class="ov-toggle mod-testing" aria-pressed="${on ? 'true' : 'false'}">${on ? 'on' : 'off'}</button>`
|
|
210
|
+
: `<span class="fact-pill ${on ? 'fact-pill--ok' : ''}" title="${escapeHtml(floor())} and above can switch this">${on ? 'on' : 'off'}</span>`;
|
|
211
|
+
return `
|
|
212
|
+
<li class="ov-row mod-row" data-key="testing">
|
|
213
|
+
<div class="ov-row__main">
|
|
214
|
+
<span class="ov-row__title">This project tests new versions first</span>
|
|
215
|
+
<div class="ov-row__facts">${facts.join('')}</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="ov-row__right mod-row__right">${control}</div>
|
|
218
|
+
</li>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderTesting() {
|
|
222
|
+
const group = $('#testing-group');
|
|
223
|
+
const grid = $('#testing-grid');
|
|
224
|
+
if (!group || !grid || !testing) return;
|
|
225
|
+
group.hidden = false;
|
|
226
|
+
grid.innerHTML = testingRowHTML();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Best-effort, like the store: a switch that cannot be read hides its group
|
|
230
|
+
// and costs nothing else on the page.
|
|
231
|
+
async function loadTesting() {
|
|
232
|
+
try {
|
|
233
|
+
const res = await api.request('GET', `${API}/modules/testing`);
|
|
234
|
+
if (!res.ok) return;
|
|
235
|
+
testing = res.data || null;
|
|
236
|
+
renderTesting();
|
|
237
|
+
renderRows();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.error('[modules] testing switch failed to load', err);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function postTesting(url, body, btn) {
|
|
244
|
+
btn.disabled = true;
|
|
245
|
+
try {
|
|
246
|
+
const res = await api.request('POST', url, { body });
|
|
247
|
+
const out = res.data || {};
|
|
248
|
+
if (!res.ok) throw new Error(out.message || `testing → ${res.status}`);
|
|
249
|
+
testing = { tester: !!out.tester, general_only: out.general_only || [] };
|
|
250
|
+
renderTesting();
|
|
251
|
+
renderRows();
|
|
252
|
+
return out;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error('[modules] testing change failed', err);
|
|
255
|
+
toast(err.message || 'Could not change that.', { duration: 3200 });
|
|
256
|
+
return null;
|
|
257
|
+
} finally {
|
|
258
|
+
btn.disabled = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function onTesterToggle(btn) {
|
|
263
|
+
const next = btn.getAttribute('aria-pressed') !== 'true';
|
|
264
|
+
const out = await postTesting(`${API}/modules/testing`, { tester: next }, btn);
|
|
265
|
+
if (out) toast(out.note || (next ? 'Testing is on.' : 'Testing is off.'), { duration: 4200 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function onGeneralOnly(btn) {
|
|
269
|
+
const key = btn.dataset.key;
|
|
270
|
+
if (!key) return;
|
|
271
|
+
const on = btn.dataset.on === 'true';
|
|
272
|
+
const out = await postTesting(`${API}/modules/${encodeURIComponent(key)}/general-only`, { general_only: on }, btn);
|
|
273
|
+
if (out) toast(on ? `${key} stays on the versions everyone gets.` : `${key} gets new versions first again.`, { duration: 3200 });
|
|
274
|
+
}
|
|
275
|
+
|
|
177
276
|
// The right column: ONE control. The family's grant toggle when the viewer
|
|
178
277
|
// can flip it (enabled in ink, disabled faint, the accent on hover); the
|
|
179
278
|
// state as a pill when they cannot. A module built here also carries its
|
|
@@ -386,6 +485,10 @@
|
|
|
386
485
|
panel.addEventListener('click', (ev) => {
|
|
387
486
|
const toggle = ev.target.closest('.mod-toggle');
|
|
388
487
|
if (toggle) { onToggle(toggle); return; }
|
|
488
|
+
const tester = ev.target.closest('.mod-testing');
|
|
489
|
+
if (tester) { onTesterToggle(tester); return; }
|
|
490
|
+
const keep = ev.target.closest('.mod-general-only');
|
|
491
|
+
if (keep) { onGeneralOnly(keep); return; }
|
|
389
492
|
const submit = ev.target.closest('.mod-submit');
|
|
390
493
|
if (submit) onSubmit(submit);
|
|
391
494
|
});
|
|
@@ -481,6 +584,7 @@
|
|
|
481
584
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
482
585
|
if (!res.ok) throw new Error(`modules → ${res.status}`);
|
|
483
586
|
render(res.data);
|
|
587
|
+
loadTesting();
|
|
484
588
|
loadStore();
|
|
485
589
|
} catch (err) {
|
|
486
590
|
console.error('[modules] failed to load', err);
|
|
@@ -14,3 +14,5 @@ Also gone, and the one rule in that list: `.ab-caption` takes the lede's `--meas
|
|
|
14
14
|
**The store's modules are a third group on the same panel, read as strengths and weaknesses (task 1003799, ADR 0343 D4).** `#store-group` ("In the store") lists what `GET /store/modules` lists, hidden until that read answers with something; it loads after the modules on disk and best-effort, so a store that cannot be read costs this group and nothing else. A row is the same `.ov-row`: the overall score as a plain fact (or the `new` info pill), `strong at Tests 96, How-to 88` as a plain fact, and `weak at …` as the warn pill, because a weakness is the exception a buyer must not miss. The sentence behind each part ("Tests scored 96 out of 100, from 40 test cases.") and the parts with no data yet sit in the row's `<details class="mod-more">`, the `splitCriterion` idea: short label in the list, the detail one click away. The reading is the server's (`readParts` in `src/bongos/module-listing.js`: strong at 80 or more, weak below the listing floor, unmeasured never weak); the page re-derives nothing. Harness fixture: `store__modules.json`.
|
|
15
15
|
|
|
16
16
|
**The buyer sorts the store by the part that matters to them (task 1003800, owner decision 2026-10-22).** A "Sort by" menu sits in the store group's head (`#store-sort`, `.ov-group__note`): Name, then the parts the server lists in `sort_parts`. Picking one re-reads `GET /store/modules?sort=<part>`, and the server orders the listed modules (`sortByPart` in `src/bongos/module-listing.js`: highest for that part first, a tie to the overall score then the key, a module with no score for the part last). The page never sorts the rows itself, and New modules keep name order because they are never ranked. While a part is picked, each listed row carries that part's score as a plain fact (`Reliability 91`, or `Reliability not measured yet`), so the order explains itself. The owner chose the buyer over a per-category "key part" because nothing records what a category is for, and a hidden weighting would break the score an author can check (ADR 0343 D3).
|
|
17
|
+
|
|
18
|
+
**Testing new versions is a fourth group, between this project's modules and the store (task 1003802, ADR 0344 D1).** `#testing-group` holds one `.ov-row`, "This project tests new versions first": a `testing` info pill (or the plain fact `not testing`), the modules kept on general as a plain fact, and on the right the family grant toggle (`.ov-toggle.mod-testing`) for a builder holding `module.enable`, a state pill for everyone else. It is hidden until `GET /modules/testing` answers and loads best-effort, like the store. The per-module control is not a second toggle on every row: while testing is on, each module's details gain a "new versions" line (`gets new versions first` / `kept on the versions everyone gets`) with a ghost button that flips it, so the one-control-per-row rule holds and a project that is not testing sees nothing new. The lede says it is never switched on for you, because the criterion turns on the tester having chosen it. Harness fixture: `modules__testing.json` (testing on, `economy` kept on general).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.157",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.157",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.157",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9970,5 +9970,11 @@
|
|
|
9970
9970
|
"id": "1004387",
|
|
9971
9971
|
"text": "The Studio's approval-queue button now carries the artist's new label, ready for the artist to approve before it goes live."
|
|
9972
9972
|
}
|
|
9973
|
+
],
|
|
9974
|
+
"1.21.157": [
|
|
9975
|
+
{
|
|
9976
|
+
"id": "1003802",
|
|
9977
|
+
"text": "A project can now volunteer to try new versions of its modules before everyone else, and keep any single module on the normal versions."
|
|
9978
|
+
}
|
|
9973
9979
|
]
|
|
9974
9980
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/bongos/module-testing.js — whether this project tests new module versions
|
|
2
|
+
// first (task 1003802, criterion wa5 staged rollout, ADR 0344 D1).
|
|
3
|
+
//
|
|
4
|
+
// A PROJECT opts in, once, and it is the project's own admin who does it: the
|
|
5
|
+
// project is what runs the module, so no builder can volunteer someone else's
|
|
6
|
+
// project. A tester project gets tester-channel versions of every module it has,
|
|
7
|
+
// except the modules it marked "general only". Both choices are reversible, and
|
|
8
|
+
// "willing" is load-bearing: a project is never a tester by default, and a choice
|
|
9
|
+
// that cannot be read reads as NOT a tester (an early version nobody chose is an
|
|
10
|
+
// early victim, not a tester).
|
|
11
|
+
//
|
|
12
|
+
// Storage is two project_settings rows (src/bongos/project-settings.js), the same
|
|
13
|
+
// durable, survives-a-deploy store the project's other knobs use, so no new table:
|
|
14
|
+
//
|
|
15
|
+
// modules.tester 'on' | 'off' absent = off
|
|
16
|
+
// modules.general_only JSON array of module keys absent = []
|
|
17
|
+
//
|
|
18
|
+
// This file records the choice and answers it. Handing a tester project tester
|
|
19
|
+
// versions is task 1003805, which asks testsVersionsOf(key).
|
|
20
|
+
|
|
21
|
+
const { getSetting, setSetting, SETTING_KEYS } = require('./project-settings');
|
|
22
|
+
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
23
|
+
|
|
24
|
+
function parseTester(raw) {
|
|
25
|
+
return raw === 'on';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A stored list that will not parse, or holds something that is not a key, keeps
|
|
29
|
+
// only the keys it can read: losing a "general only" mark would hand a project an
|
|
30
|
+
// early version of a module it asked to keep on general.
|
|
31
|
+
function parseGeneralOnly(raw) {
|
|
32
|
+
if (raw == null) return [];
|
|
33
|
+
let list;
|
|
34
|
+
try { list = JSON.parse(raw); } catch { return []; }
|
|
35
|
+
if (!Array.isArray(list)) return [];
|
|
36
|
+
return [...new Set(list.filter((k) => typeof k === 'string' && KEY_RE.test(k)))].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// { tester, general_only, updated_at, updated_by } — updated_* from the switch row.
|
|
40
|
+
async function readTesting(deps = {}) {
|
|
41
|
+
const get = deps.getSetting || getSetting;
|
|
42
|
+
const [t, g] = await Promise.all([get(SETTING_KEYS.MODULE_TESTER), get(SETTING_KEYS.MODULE_GENERAL_ONLY)]);
|
|
43
|
+
return {
|
|
44
|
+
tester: parseTester(t && t.value),
|
|
45
|
+
general_only: parseGeneralOnly(g && g.value),
|
|
46
|
+
updated_at: t ? t.updated_at : null,
|
|
47
|
+
updated_by: t && t.updated_by != null ? String(t.updated_by) : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function setTester(on, builderId = null, deps = {}) {
|
|
52
|
+
const set = deps.setSetting || setSetting;
|
|
53
|
+
await set(SETTING_KEYS.MODULE_TESTER, on ? 'on' : 'off', builderId);
|
|
54
|
+
return readTesting(deps);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function setGeneralOnly(moduleKey, on, builderId = null, deps = {}) {
|
|
58
|
+
if (!KEY_RE.test(String(moduleKey))) throw new Error(`not a module key: ${moduleKey}`);
|
|
59
|
+
const set = deps.setSetting || setSetting;
|
|
60
|
+
const now = await readTesting(deps);
|
|
61
|
+
const keys = new Set(now.general_only);
|
|
62
|
+
if (on) keys.add(moduleKey); else keys.delete(moduleKey);
|
|
63
|
+
await set(SETTING_KEYS.MODULE_GENERAL_ONLY, JSON.stringify([...keys].sort()), builderId);
|
|
64
|
+
return readTesting(deps);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Whether this project takes tester-channel versions of one module. Pure over a
|
|
68
|
+
// readTesting() result.
|
|
69
|
+
function testsVersionsOf(testing, moduleKey) {
|
|
70
|
+
return !!(testing && testing.tester && !testing.general_only.includes(moduleKey));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { readTesting, setTester, setGeneralOnly, testsVersionsOf, parseGeneralOnly };
|
|
@@ -53,6 +53,9 @@ const SETTING_KEYS = {
|
|
|
53
53
|
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
54
|
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
55
55
|
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
56
|
+
// Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
|
|
57
|
+
MODULE_TESTER: 'modules.tester',
|
|
58
|
+
MODULE_GENERAL_ONLY: 'modules.general_only',
|
|
56
59
|
};
|
|
57
60
|
|
|
58
61
|
// SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
|
|
@@ -111,6 +111,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
111
111
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
113
|
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
|
+
const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
|
|
114
115
|
|
|
115
116
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
116
117
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -333,6 +334,48 @@ module.exports = function buildModulesRouter() {
|
|
|
333
334
|
});
|
|
334
335
|
}));
|
|
335
336
|
|
|
337
|
+
// Testing new module versions first (task 1003802, ADR 0344 D1): whether THIS
|
|
338
|
+
// project gets tester-channel versions, and which modules it keeps on general.
|
|
339
|
+
// A read anyone signed in can make: the switch is meant to be visible.
|
|
340
|
+
//
|
|
341
|
+
// rank: any-builder — a read of this project's own setting; nothing changes.
|
|
342
|
+
router.get('/modules/testing', auth.requireBuilder,
|
|
343
|
+
asyncHandler('GET /modules/testing', async (_req, res) => {
|
|
344
|
+
res.set('Cache-Control', 'no-store');
|
|
345
|
+
res.json(await readTesting());
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
// rank: metic+archon — the project's admin volunteers the whole project, so it
|
|
349
|
+
// takes the same permission that chooses which modules this project runs
|
|
350
|
+
// (module.enable): a builder cannot opt in a project they cannot run modules for.
|
|
351
|
+
router.post('/modules/testing', auth.requireBuilder, auth.requirePermission('module.enable'),
|
|
352
|
+
asyncHandler('POST /modules/testing', async (req, res) => {
|
|
353
|
+
if (validateOrRespond(req, res, { tester: { type: 'boolean', required: true } })) return;
|
|
354
|
+
const out = await setTester(req.body.tester, req.builder && req.builder.id);
|
|
355
|
+
log.info({ tester: out.tester, builder: req.builder && req.builder.id }, 'module testing switched');
|
|
356
|
+
res.json({
|
|
357
|
+
ok: true,
|
|
358
|
+
...out,
|
|
359
|
+
note: out.tester
|
|
360
|
+
? 'This project now gets new module versions first, except the modules marked general only.'
|
|
361
|
+
: 'This project is back on general versions from its next update.',
|
|
362
|
+
});
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
// rank: metic+archon — same reasoning as POST /modules/testing: which versions
|
|
366
|
+
// a module arrives in is part of choosing the modules this project runs.
|
|
367
|
+
router.post('/modules/:key/general-only', auth.requireBuilder, auth.requirePermission('module.enable'),
|
|
368
|
+
asyncHandler('POST /modules/:key/general-only', async (req, res) => {
|
|
369
|
+
const { key } = req.params;
|
|
370
|
+
if (!KEY_RE.test(key)) return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
|
|
371
|
+
if (validateOrRespond(req, res, { general_only: { type: 'boolean', required: true } })) return;
|
|
372
|
+
if (!listModules().some((m) => m.key === key)) {
|
|
373
|
+
return res.fail('not_found', { status: 404, message: `This project has no module "${key}".` });
|
|
374
|
+
}
|
|
375
|
+
const out = await setGeneralOnly(key, req.body.general_only, req.builder && req.builder.id);
|
|
376
|
+
res.json({ ok: true, key, general_only_this: out.general_only.includes(key), ...out });
|
|
377
|
+
}));
|
|
378
|
+
|
|
336
379
|
// POST /api/bongos/modules/:key/submit — ADR 0107 §1 upstreaming submission.
|
|
337
380
|
//
|
|
338
381
|
// task 1770. Body: { signed_by?: "Name <email>" }. Re-runs the §4
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.157'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -39,7 +39,7 @@ const noAlias = (rawCss, where) => {
|
|
|
39
39
|
|
|
40
40
|
// ---- modules ----------------------------------------------------------------
|
|
41
41
|
|
|
42
|
-
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll (
|
|
42
|
+
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll (the store since task 1003799, testing since task 1003802)', () => {
|
|
43
43
|
const html = read('modules.html');
|
|
44
44
|
assert.match(html, /<link rel="stylesheet" href="\/builders\/oversight\.css">\s*<link rel="stylesheet" href="\/builders\/modules\.css">/, 'the family sheet loads before the page sheet');
|
|
45
45
|
assert.equal((html.match(/class="scroll[ "]/g) || []).length, 1, 'one panel, not three');
|
|
@@ -47,7 +47,7 @@ test('modules: ONE panel of family rows — the filter bar, the figure band and
|
|
|
47
47
|
assert.match(html, /class="ov-figs" id="mod-figs"/, 'the figure band is the family\'s');
|
|
48
48
|
assert.match(html, /<ul class="ov-rows" id="core-grid">/, 'the core catalog is a run of rows');
|
|
49
49
|
assert.match(html, /<ul class="ov-rows" id="own-grid">/, 'the project\'s own modules are a run of rows');
|
|
50
|
-
assert.equal((html.match(/class="ov-group__head"/g) || []).length,
|
|
50
|
+
assert.equal((html.match(/class="ov-group__head"/g) || []).length, 4, 'four group heads: the core, this project, testing, the store');
|
|
51
51
|
assert.doesNotMatch(decomment(html), /mod-grid|mod-card|mod-browse|submit-scroll|class="settings-status"/, 'the card grid, the boxed search and the third panel are gone');
|
|
52
52
|
assert.doesNotMatch(html, /\d+ module\(s\)/, 'the page head no longer says the numbers as prose');
|
|
53
53
|
});
|
|
@@ -106,6 +106,22 @@ test('modules: the store group reads strengths and weaknesses off the server row
|
|
|
106
106
|
assert.notDeepEqual(two[0].strengths.map((s) => s.part), two[1].strengths.map((s) => s.part), '…that are strong at different things');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('modules: the project opts in to testing new versions, and can keep a module on general (task 1003802)', () => {
|
|
110
|
+
const js = read('modules.js');
|
|
111
|
+
const html = read('modules.html');
|
|
112
|
+
assert.match(html, /<div class="ov-group" id="testing-group" hidden>/, 'hidden until the switch is read');
|
|
113
|
+
assert.match(html, /It is never switched on for you\./, 'the page says a project is never a tester by default');
|
|
114
|
+
assert.match(js, /api\.request\('GET', `\$\{API\}\/modules\/testing`\)/, 'it reads GET /modules/testing');
|
|
115
|
+
assert.match(js, /`\$\{API\}\/modules\/testing`, \{ tester: next \}/, 'the switch posts an explicit true or false');
|
|
116
|
+
assert.match(js, /\/modules\/\$\{encodeURIComponent\(key\)\}\/general-only`, \{ general_only: on \}/, 'a module is kept on general by its own key');
|
|
117
|
+
assert.match(js, /class="ov-toggle mod-testing"/, 'the switch is the family grant toggle');
|
|
118
|
+
assert.match(js, /if \(!testing \|\| !testing\.tester\) return '';/, 'the per-module line appears only while the project is testing');
|
|
119
|
+
assert.match(js, /if \(!canToggle\) return escapeHtml\(says\);/, 'a builder who cannot switch it reads the state, with no dead control');
|
|
120
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'modules__testing.json'), 'utf8'));
|
|
121
|
+
assert.equal(fixture.tester, true, 'the harness demos the switch on');
|
|
122
|
+
assert.ok(fixture.general_only.length >= 1, '…with a module kept on general');
|
|
123
|
+
});
|
|
124
|
+
|
|
109
125
|
test('modules: the buyer sorts the store by the part that matters to them (task 1003800)', () => {
|
|
110
126
|
const js = read('modules.js');
|
|
111
127
|
const html = read('modules.html');
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/module_testing.mjs — a project elects to test new module versions first
|
|
2
|
+
// (task 1003802, ADR 0344 D1). Two halves, no port and no real DB:
|
|
3
|
+
// - the rule: module-testing.js over an in-memory project_settings, through
|
|
4
|
+
// its deps seam — never a tester by default, reversible, "general only";
|
|
5
|
+
// - the routes: the real handlers off the real router, with pool.js replaced
|
|
6
|
+
// in the require cache (the tests/module_store_listing.mjs harness).
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/module_testing.mjs
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
// ---- an in-memory project_settings, behind the real setSetting/getSetting SQL ----
|
|
17
|
+
const rows = new Map();
|
|
18
|
+
const pool = {
|
|
19
|
+
async query(sql, params = []) {
|
|
20
|
+
if (/FROM project_settings WHERE key = \$1/.test(sql)) {
|
|
21
|
+
const r = rows.get(params[0]);
|
|
22
|
+
return { rows: r ? [r] : [] };
|
|
23
|
+
}
|
|
24
|
+
if (/INSERT INTO project_settings/.test(sql)) {
|
|
25
|
+
const r = { key: params[0], value: params[1], updated_by: params[2], updated_at: 't' };
|
|
26
|
+
rows.set(params[0], r);
|
|
27
|
+
return { rows: [r] };
|
|
28
|
+
}
|
|
29
|
+
return { rows: [] };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
33
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
34
|
+
|
|
35
|
+
const { readTesting, setTester, setGeneralOnly, testsVersionsOf, parseGeneralOnly } = require('../src/bongos/module-testing.js');
|
|
36
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
37
|
+
const { listModules } = buildModulesRouter;
|
|
38
|
+
|
|
39
|
+
function handlerFor(router, method, p) {
|
|
40
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
41
|
+
assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
|
|
42
|
+
const stack = layer.route.stack;
|
|
43
|
+
return { handle: stack[stack.length - 1].handle, names: stack.map((s) => s.name) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function invoke(handle, req) {
|
|
47
|
+
const res = {
|
|
48
|
+
statusCode: 200, body: undefined, headers: {},
|
|
49
|
+
json(b) { this.body = b; return this; },
|
|
50
|
+
status(c) { this.statusCode = c; return this; },
|
|
51
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
52
|
+
// Both shapes res.fail is called with: (code, { status, message }) and the
|
|
53
|
+
// validator's (code, 400, details).
|
|
54
|
+
fail(code, opts) {
|
|
55
|
+
const o = typeof opts === 'number' ? { status: opts } : opts;
|
|
56
|
+
this.statusCode = o.status; this.body = { error: code, ...o }; return this;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
let nextErr = null;
|
|
60
|
+
await handle({ params: {}, body: {}, builder: { id: 7 }, ...req }, res, (err) => { nextErr = err; });
|
|
61
|
+
if (nextErr) throw nextErr;
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// the rule
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test('a project is never a tester by default', async () => {
|
|
70
|
+
rows.clear();
|
|
71
|
+
const t = await readTesting();
|
|
72
|
+
assert.deepEqual(t, { tester: false, general_only: [], updated_at: null, updated_by: null });
|
|
73
|
+
assert.equal(testsVersionsOf(t, 'discord'), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('opting in is an act, and it is reversible', async () => {
|
|
77
|
+
rows.clear();
|
|
78
|
+
const on = await setTester(true, 7);
|
|
79
|
+
assert.equal(on.tester, true);
|
|
80
|
+
assert.equal(on.updated_by, '7', 'who switched it is recorded');
|
|
81
|
+
assert.equal(testsVersionsOf(on, 'discord'), true);
|
|
82
|
+
const off = await setTester(false, 7);
|
|
83
|
+
assert.equal(off.tester, false);
|
|
84
|
+
assert.equal(testsVersionsOf(off, 'discord'), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module marked general only keeps general versions while the rest test', async () => {
|
|
88
|
+
rows.clear();
|
|
89
|
+
await setTester(true, 7);
|
|
90
|
+
await setGeneralOnly('economy', true, 7);
|
|
91
|
+
const t = await setGeneralOnly('discord', true, 7);
|
|
92
|
+
assert.deepEqual(t.general_only, ['discord', 'economy']);
|
|
93
|
+
assert.equal(testsVersionsOf(t, 'economy'), false);
|
|
94
|
+
assert.equal(testsVersionsOf(t, 'memory'), true);
|
|
95
|
+
const back = await setGeneralOnly('economy', false, 7);
|
|
96
|
+
assert.deepEqual(back.general_only, ['discord']);
|
|
97
|
+
assert.equal(testsVersionsOf(back, 'economy'), true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a "general only" mark survives the switch going off and on', async () => {
|
|
101
|
+
rows.clear();
|
|
102
|
+
await setTester(true, 7);
|
|
103
|
+
await setGeneralOnly('economy', true, 7);
|
|
104
|
+
await setTester(false, 7);
|
|
105
|
+
const t = await setTester(true, 7);
|
|
106
|
+
assert.deepEqual(t.general_only, ['economy']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('an unreadable stored value reads as NOT a tester, and keeps the keys it can read', async () => {
|
|
110
|
+
rows.clear();
|
|
111
|
+
rows.set('modules.tester', { key: 'modules.tester', value: 'yes please', updated_by: null, updated_at: 't' });
|
|
112
|
+
assert.equal((await readTesting()).tester, false, 'only "on" is on');
|
|
113
|
+
assert.deepEqual(parseGeneralOnly('not json'), []);
|
|
114
|
+
assert.deepEqual(parseGeneralOnly('{"a":1}'), []);
|
|
115
|
+
assert.deepEqual(parseGeneralOnly('["economy", 4, "Bad Key", "economy", "discord"]'), ['discord', 'economy']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('setGeneralOnly refuses something that is not a module key', async () => {
|
|
119
|
+
await assert.rejects(() => setGeneralOnly('../etc', true, 7), /not a module key/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// the routes
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
test('GET /modules/testing: any signed-in builder can see the switch', async () => {
|
|
127
|
+
rows.clear();
|
|
128
|
+
const { handle, names } = handlerFor(buildModulesRouter(), 'get', '/modules/testing');
|
|
129
|
+
assert.equal(names.length, 2, `expected requireBuilder + handler, got ${names.join(', ')}`);
|
|
130
|
+
const res = await invoke(handle, {});
|
|
131
|
+
assert.equal(res.body.tester, false);
|
|
132
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('POST /modules/testing and /modules/:key/general-only take the module.enable permission', () => {
|
|
136
|
+
const router = buildModulesRouter();
|
|
137
|
+
for (const p of ['/modules/testing', '/modules/:key/general-only']) {
|
|
138
|
+
const { names } = handlerFor(router, 'post', p);
|
|
139
|
+
assert.equal(names.length, 3, `${p}: requireBuilder + requirePermission + handler, got ${names.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
const src = require('node:fs').readFileSync(require.resolve('../src/bongos/routes/modules.js'), 'utf8');
|
|
142
|
+
assert.match(src, /router\.post\('\/modules\/testing', auth\.requireBuilder, auth\.requirePermission\('module\.enable'\)/);
|
|
143
|
+
assert.match(src, /router\.post\('\/modules\/:key\/general-only', auth\.requireBuilder, auth\.requirePermission\('module\.enable'\)/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('POST /modules/testing: switches on and off, and says what it means', async () => {
|
|
147
|
+
rows.clear();
|
|
148
|
+
const { handle } = handlerFor(buildModulesRouter(), 'post', '/modules/testing');
|
|
149
|
+
const on = await invoke(handle, { body: { tester: true } });
|
|
150
|
+
assert.equal(on.body.tester, true);
|
|
151
|
+
assert.match(on.body.note, /new module versions first/);
|
|
152
|
+
const off = await invoke(handle, { body: { tester: false } });
|
|
153
|
+
assert.equal(off.body.tester, false);
|
|
154
|
+
assert.match(off.body.note, /back on general versions from its next update/);
|
|
155
|
+
const bad = await invoke(handle, { body: { tester: 'on' } });
|
|
156
|
+
assert.equal(bad.statusCode, 400, 'an affirmative act is a boolean, not a string that looks like one');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('POST /modules/:key/general-only: only a module this project has', async () => {
|
|
160
|
+
rows.clear();
|
|
161
|
+
const { handle } = handlerFor(buildModulesRouter(), 'post', '/modules/:key/general-only');
|
|
162
|
+
const known = listModules()[0].key;
|
|
163
|
+
const ok = await invoke(handle, { params: { key: known }, body: { general_only: true } });
|
|
164
|
+
assert.equal(ok.statusCode, 200);
|
|
165
|
+
assert.equal(ok.body.general_only_this, true);
|
|
166
|
+
assert.deepEqual(ok.body.general_only, [known]);
|
|
167
|
+
const missing = await invoke(handle, { params: { key: 'no-such-module-here' }, body: { general_only: true } });
|
|
168
|
+
assert.equal(missing.statusCode, 404);
|
|
169
|
+
const badKey = await invoke(handle, { params: { key: 'Not_A_Key' }, body: { general_only: true } });
|
|
170
|
+
assert.equal(badKey.statusCode, 400);
|
|
171
|
+
});
|