@bongos/core 1.19.528 → 1.19.529

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.
@@ -25,6 +25,17 @@ import path from 'node:path';
25
25
  import vm from 'node:vm';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
+ // ESM named imports off the CJS module on purpose (the knip learning): the
29
+ // cross-check test at the bottom runs the REAL declaration the route serves, so a
30
+ // rule change cannot pass this file while breaking the panel.
31
+ import {
32
+ ALWAYS_ON_CORE,
33
+ RULED_TEAM_SHAPES,
34
+ starterBundleAnswerTable,
35
+ optionalModules,
36
+ effectiveModules,
37
+ } from '../modules/provisioning/starter-bundles.js';
38
+
28
39
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
29
40
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
30
41
  const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
@@ -59,6 +70,39 @@ const PAYLOAD = {
59
70
  ],
60
71
  };
61
72
 
73
+ // The `byTeamShape` half of the same payload (BV1.R22, task 1002341): the bundle
74
+ // re-resolved for each About-step answer, with the adjustments that got it there.
75
+ //
76
+ // Deliberately only TWO types carry it. The rest are left without so the
77
+ // degradation path is exercised by the same fixture — a payload from a server that
78
+ // predates R22, or a team shape no rule fires on, must fall through to the
79
+ // unadjusted bundle rather than preselecting nothing. The real table for every
80
+ // type is pinned against the real module further down ("the picker and the server
81
+ // resolve the same set").
82
+ const TAILORED = {
83
+ ...PAYLOAD,
84
+ bundles: PAYLOAD.bundles.map((b) => {
85
+ if (b.type === 'game') {
86
+ return { ...b, byTeamShape: {
87
+ // solo: the shared environment comes off
88
+ solo: { bundle: ['discord'], adjustments: [{ rule: 'building-alone-needs-no-shared-environment', effect: 'remove', module: 'dev-box', reason: 'You are building this alone, so the shared cloud environment is off.' }] },
89
+ // small-team: the rule wants dev-box, the bundle already has it — a rule
90
+ // that changed nothing claims nothing
91
+ 'small-team': { bundle: ['dev-box', 'discord'], adjustments: [] },
92
+ community: { bundle: ['dev-box', 'discord'], adjustments: [] },
93
+ } };
94
+ }
95
+ if (b.type === 'research') {
96
+ return { ...b, byTeamShape: {
97
+ solo: { bundle: ['agents'], adjustments: [] },
98
+ 'small-team': { bundle: ['agents', 'dev-box'], adjustments: [{ rule: 'a-team-shares-one-environment', effect: 'add', module: 'dev-box', reason: 'A small team gets one shared environment to build in.' }] },
99
+ community: { bundle: ['agents', 'discord'], adjustments: [{ rule: 'a-community-needs-somewhere-to-gather', effect: 'add', module: 'discord', reason: 'You said an open community is building this, so somewhere for it to gather is switched on.' }] },
100
+ } };
101
+ }
102
+ return b;
103
+ }),
104
+ };
105
+
62
106
  function makeEl(id) {
63
107
  const classes = new Set();
64
108
  const attrs = new Map();
@@ -88,13 +132,26 @@ function rowsOf(html) {
88
132
  return out;
89
133
  }
90
134
 
135
+ // The reason lines the panel painted, in order (task 1002341). Parsed out of the
136
+ // markup for the same reason rowsOf is: a selector that resolves against markup
137
+ // nobody wrote proves nothing.
138
+ function whyOf(html) {
139
+ const out = [];
140
+ const re = /<span>([^<]*)<\/span>/g;
141
+ let m;
142
+ while ((m = re.exec(html))) out.push(m[1]);
143
+ return out;
144
+ }
145
+
91
146
  // 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 } = {}) {
147
+ // couldn't-load branch; `payload` swaps in a different server answer (TAILORED
148
+ // carries the R22 byTeamShape half). Returns the handles the assertions drive it
149
+ // through.
150
+ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
94
151
  const els = new Map();
95
152
  const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
96
153
  const form = makeEl('panel-modules-form');
97
- const state = { step: 4, projType: null, modules: null, ...seed };
154
+ const state = { step: 4, projType: null, teamShape: null, modules: null, ...seed };
98
155
  const shown = [];
99
156
  let saves = 0;
100
157
  let fetches = 0;
@@ -112,7 +169,7 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
112
169
  renderReview: () => { reviews++; },
113
170
  API: '/api/bongos',
114
171
  fetch: () => { fetches++; return Promise.resolve(fetchOk
115
- ? { ok: true, json: () => Promise.resolve(PAYLOAD) }
172
+ ? { ok: true, json: () => Promise.resolve(payload) }
116
173
  : { ok: false, json: () => Promise.resolve(null) }); },
117
174
  Promise, JSON, Math, Object, Array, String, Number, console,
118
175
  };
@@ -134,6 +191,8 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
134
191
  render: () => sandbox.renderModules(),
135
192
  open: async function () { sandbox.renderModules(); for (let i = 0; i < 20; i++) await Promise.resolve(); },
136
193
  rows: () => rowsOf(band.innerHTML),
194
+ why: () => whyOf($('modWhy').innerHTML),
195
+ whyShown: () => $('modWhy').classList.contains('on'),
137
196
  coreNote: () => $('modCoreNote').textContent,
138
197
  coreShown: () => $('modCoreNote').classList.contains('on'),
139
198
  click: (key) => {
@@ -367,3 +426,157 @@ test('the review reads an answered picker back, empty included', async () => {
367
426
  await none.settle();
368
427
  assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
369
428
  });
429
+
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+ // BV1.R22 (task 1002341) — the About step's answer reaches the preselect, and
432
+ // the panel says why. ADR 0243.
433
+ // ─────────────────────────────────────────────────────────────────────────────
434
+
435
+ test('the team shape adjusts the type bundle, and the panel says why in the owner\'s words', async () => {
436
+ // Arrange — "a game", then "just me" one panel later
437
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
438
+
439
+ // Act
440
+ await h.open();
441
+
442
+ // Assert — the shared environment came off, and the reason is on screen
443
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['discord'],
444
+ 'solo drops the dev box the game bundle would have preselected');
445
+ assert.equal(h.whyShown(), true);
446
+ assert.deepEqual(h.why(), ['You are building this alone, so the shared cloud environment is off.']);
447
+ });
448
+
449
+ test('a rule that changed nothing claims nothing', async () => {
450
+ // small-team ADDS dev-box, and the game bundle already carries it. A reason
451
+ // shown for a change that did not happen is a claim the owner cannot check.
452
+ const h = boot({ state: { projType: 'game', teamShape: 'small-team' }, payload: TAILORED });
453
+ await h.open();
454
+
455
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
456
+ assert.deepEqual(h.why(), [], 'no adjustment, no sentence');
457
+ assert.equal(h.whyShown(), false, 'and no empty block sitting on the panel');
458
+ });
459
+
460
+ test('an added module is preselected and explained', async () => {
461
+ const h = boot({ state: { projType: 'research', teamShape: 'community' }, payload: TAILORED });
462
+ await h.open();
463
+
464
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'discord']);
465
+ assert.deepEqual(h.why(), ['You said an open community is building this, so somewhere for it to gather is switched on.']);
466
+ });
467
+
468
+ test('answering the About step later re-preselects, with no second read', async () => {
469
+ // The two questions live on two earlier panels and are both changeable. The
470
+ // whole table arrived in one read precisely so going back costs nothing.
471
+ const h = boot({ state: { projType: 'research' }, payload: TAILORED });
472
+ await h.open();
473
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'unanswered: the raw bundle');
474
+
475
+ h.state.teamShape = 'small-team';
476
+ h.render();
477
+
478
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'dev-box']);
479
+ assert.equal(h.fetches(), 1, 'and the answer cost no round trip');
480
+ });
481
+
482
+ test('Skip after an answered About step still yields the set the panel showed', async () => {
483
+ // ADR 0240 §2's property, extended to the interview's answer (ADR 0243): the
484
+ // preselect and the server's fallback are the same declaration, so skipping
485
+ // must still answer NOTHING and still land the tailored set.
486
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
487
+ await h.open();
488
+
489
+ h.skip();
490
+
491
+ assert.deepEqual(h.shown, [5]);
492
+ assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
493
+ assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
494
+ assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
495
+ });
496
+
497
+ test('once the owner has answered, the reasons go — the panel stops arguing', async () => {
498
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
499
+ await h.open();
500
+ assert.equal(h.why().length, 1);
501
+
502
+ h.click('dev-box');
503
+
504
+ assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'their toggle stands over the rule');
505
+ assert.deepEqual(h.why(), [], 'and the rule stops explaining a set that is no longer its own');
506
+ assert.equal(h.whyShown(), false);
507
+ });
508
+
509
+ test('a payload with no byTeamShape degrades to the untailored bundle, never to nothing', async () => {
510
+ // A server that predates R22 still serves `bundles[].bundle`. The panel must
511
+ // preselect that rather than empty out — untailored is never wrong, only less.
512
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: PAYLOAD });
513
+ await h.open();
514
+
515
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
516
+ assert.deepEqual(h.why(), []);
517
+ });
518
+
519
+ test('an off-vocabulary team shape adjusts nothing', async () => {
520
+ // A value that was valid when the draft was stored and has since left the
521
+ // vocabulary reads as no answer, exactly as normalizeProjectDetail drops one.
522
+ const h = boot({ state: { projType: 'game', teamShape: 'a-huge-corporation' }, payload: TAILORED });
523
+ await h.open();
524
+
525
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
526
+ assert.deepEqual(h.why(), []);
527
+ });
528
+
529
+ test('a couldn\'t-load read clears any reasons it had already painted', async () => {
530
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
531
+ await h.open();
532
+ assert.equal(h.why().length, 1);
533
+
534
+ h.sandbox.starterBundles = null;
535
+ h.render();
536
+
537
+ assert.deepEqual(h.why(), [], 'a stale reason for a set no longer on screen is a lie');
538
+ assert.equal(h.whyShown(), false);
539
+ });
540
+
541
+ test('a reason goes through the page escaper like every other rendered value', async () => {
542
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
543
+ await h.open();
544
+ h.sandbox.starterBundles = {
545
+ core: [], optional: [],
546
+ bundles: [{ type: 'game', bundle: [], byTeamShape: { solo: { bundle: [], adjustments: [{ reason: '<img src=x onerror=alert(1)>' }] } } }],
547
+ };
548
+ h.render();
549
+
550
+ const html = h.sandbox.$('modWhy').innerHTML;
551
+ assert.ok(!html.includes('<img'), 'a hostile reason cannot inject markup');
552
+ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'), 'it is shown escaped');
553
+ });
554
+
555
+ // The pin that makes the two ends of ADR 0243 unable to drift. Everything above
556
+ // runs the picker against a FIXTURE; this one runs it against the REAL payload the
557
+ // route serves, and asserts the panel's preselect is the same set the server would
558
+ // resolve for a row with those answers and a skipped picker. If a rule is ever
559
+ // added, changed or moved, one of these twenty combinations fails here rather than
560
+ // silently handing an owner a project that is not the one they were shown.
561
+ test('the picker and the server resolve the same set for every answer an owner can give', async () => {
562
+ const LIVE = { core: [...ALWAYS_ON_CORE], bundles: starterBundleAnswerTable(), optional: optionalModules() };
563
+ const shapes = [null, ...RULED_TEAM_SHAPES];
564
+
565
+ for (const preset of LIVE.bundles) {
566
+ for (const teamShape of shapes) {
567
+ const h = boot({ state: { projType: preset.type, teamShape }, payload: LIVE });
568
+ await h.open();
569
+
570
+ // what the server gives a project that answered the interview and then
571
+ // skipped the picker — `modules: null`, resolved at read time
572
+ const server = effectiveModules({ type: preset.type, modules: null, detail: teamShape ? { team_shape: teamShape } : {} });
573
+
574
+ assert.deepEqual(h.picked(), server.selected,
575
+ `type=${preset.type} team_shape=${teamShape}: the panel preselects what the server resolves`);
576
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), server.selected,
577
+ `type=${preset.type} team_shape=${teamShape}: and the toggles show it`);
578
+ assert.deepEqual(h.why().length, server.adjustments.length,
579
+ `type=${preset.type} team_shape=${teamShape}: one sentence per adjustment the server made`);
580
+ }
581
+ }
582
+ });
@@ -37,8 +37,11 @@ import {
37
37
  OPTIONAL_MODULES,
38
38
  STARTER_BUNDLES,
39
39
  HUB_ONLY_MODULES,
40
+ RULED_TEAM_SHAPES,
40
41
  bundleForProjectType,
41
42
  allStarterBundles,
43
+ adjustBundle,
44
+ starterBundleAnswerTable,
42
45
  normalizeModuleSelection,
43
46
  effectiveModules,
44
47
  } from '../modules/provisioning/starter-bundles.js';
@@ -259,8 +262,60 @@ test('GET /provisioning/starter-bundles is public and serves the resolved bundle
259
262
  // Assert
260
263
  assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
261
264
  assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
262
- assert.deepEqual(res.body.bundles, allStarterBundles());
265
+ // `starterBundleAnswerTable` since R22 (task 1002341): every bundle, plus the
266
+ // same bundle re-resolved for each About-step answer. It is a superset of
267
+ // `allStarterBundles()` — the untailored keys are byte-for-byte what they were.
268
+ assert.deepEqual(res.body.bundles, starterBundleAnswerTable());
263
269
  assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
270
+ for (const [i, plain] of allStarterBundles().entries()) {
271
+ const { byTeamShape, ...rest } = res.body.bundles[i];
272
+ assert.deepEqual(rest, plain, `bundle ${plain.type} kept its pre-R22 shape`);
273
+ assert.ok(byTeamShape, `bundle ${plain.type} carries the tailored table`);
274
+ }
275
+ });
276
+
277
+ test('every bundle carries a resolution for every team shape a rule can fire on', async () => {
278
+ // The picker preselects straight out of this table with no second read (ADR
279
+ // 0243), so a missing row is a panel that silently preselects the untailored
280
+ // set for an answer the owner did give.
281
+ const res = await get('/api/bongos/provisioning/starter-bundles');
282
+
283
+ for (const row of res.body.bundles) {
284
+ assert.deepEqual(Object.keys(row.byTeamShape).sort(), [...RULED_TEAM_SHAPES].sort(),
285
+ `${row.type} answers every team shape`);
286
+ for (const [shape, tailored] of Object.entries(row.byTeamShape)) {
287
+ assert.ok(Array.isArray(tailored.bundle), `${row.type}/${shape} names a module list`);
288
+ assert.ok(Array.isArray(tailored.adjustments), `${row.type}/${shape} names its adjustments`);
289
+ // never the always-on core, never a key off the catalog
290
+ for (const key of tailored.bundle) {
291
+ assert.ok(OPTIONAL_MODULES.some((m) => m.key === key),
292
+ `${row.type}/${shape} preselects "${key}", which is not a choice the picker offers`);
293
+ }
294
+ // an adjustment is a DELTA: it only claims what it actually changed
295
+ for (const adj of tailored.adjustments) {
296
+ const inBase = row.bundle.includes(adj.module);
297
+ const inNow = tailored.bundle.includes(adj.module);
298
+ assert.notEqual(inBase, inNow, `${row.type}/${shape}: "${adj.rule}" claims a change it did not make`);
299
+ assert.ok(adj.reason && adj.reason.length > 20, `${row.type}/${shape}: "${adj.rule}" needs an owner-facing reason`);
300
+ }
301
+ }
302
+ }
303
+ });
304
+
305
+ test('an adjusted bundle is in CATALOG order, like every other module list', () => {
306
+ // normalizeModuleSelection stores a chosen set in catalog order and the picker
307
+ // re-derives its toggles that way, so an appended adjustment would make one set
308
+ // of modules read as two different lists depending on whether the owner
309
+ // answered the picker or skipped it.
310
+ const order = OPTIONAL_MODULES.map((m) => m.key);
311
+ const sorted = (list) => [...list].sort((a, b) => order.indexOf(a) - order.indexOf(b));
312
+
313
+ for (const preset of allStarterBundles()) {
314
+ for (const shape of RULED_TEAM_SHAPES) {
315
+ const { modules } = adjustBundle(preset.bundle, shape);
316
+ assert.deepEqual(modules, sorted(modules), `${preset.type}/${shape} is in catalog order`);
317
+ }
318
+ }
264
319
  });
265
320
 
266
321
  test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
@@ -355,6 +410,40 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
355
410
  assert.equal(none.source, 'chosen');
356
411
  });
357
412
 
413
+ test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
414
+ // BV1.R22 / ADR 0243 — the property ADR 0240 §2 leans the skip on ("the
415
+ // preselect and the fallback are the same declaration"), now that the panel
416
+ // preselects the team-shape-adjusted set. Without this the picker would show a
417
+ // solo owner one thing and the project would quietly get another.
418
+ const eff = effectiveModules({ type: 'game', modules: null, detail: { team_shape: 'solo' } });
419
+
420
+ assert.deepEqual(eff.selected, ['discord'], 'the shared environment the rule removes is not silently restored');
421
+ assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
422
+ assert.deepEqual(eff.adjustments.map((a) => a.module), ['dev-box']);
423
+ assert.match(eff.adjustments[0].reason, /building this alone/, 'carrying the reason the panel showed');
424
+ });
425
+
426
+ test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
427
+ // A rule corrects a PRESELECT the owner did not make. Once they have toggled,
428
+ // re-applying it would overrule them with an answer they had already refused.
429
+ const eff = effectiveModules({ type: 'game', modules: ['dev-box'], detail: { team_shape: 'solo' } });
430
+
431
+ assert.deepEqual(eff.selected, ['dev-box'], 'solo removes dev-box, and the owner put it back');
432
+ assert.equal(eff.source, 'chosen');
433
+ assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
434
+ });
435
+
436
+ test('effectiveModules: a detail that says nothing usable adjusts nothing', () => {
437
+ // {} (skipped), a shape that has left the vocabulary, and a non-object column
438
+ // value all read as NO ANSWER — normalizeProjectDetail's rule, reached without
439
+ // importing it.
440
+ for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
441
+ const eff = effectiveModules({ type: 'game', modules: null, detail });
442
+ assert.deepEqual(eff.selected, ['dev-box', 'discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
443
+ assert.deepEqual(eff.adjustments, []);
444
+ }
445
+ });
446
+
358
447
  test('effectiveModules: an undeclared type resolves to the core, never a guess', () => {
359
448
  for (const row of [{ type: null, modules: null }, { type: 'not-sure', modules: null }, {}]) {
360
449
  const eff = effectiveModules(row);