@bongos/core 1.21.165 → 1.21.167

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.
@@ -722,20 +722,19 @@
722
722
  if (window.OTBAttention && typeof window.OTBAttention.refresh === 'function') window.OTBAttention.refresh();
723
723
  }
724
724
 
725
- // task 1003464: the greeting IS Home's one h1 (the mock opens with it), and
726
- // the sub-line is the live status sentence — how far the version is and
727
- // whether there is anything to do — the one line a returning builder needs.
728
- // Each half renders as its data lands; the line stays hidden until one has.
729
- const SMALL_NUMBERS = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
725
+ // task 1003464: the greeting IS Home's one h1 (the mock opens with it).
726
+ // task 1004780: it has no sub-line any more. The version tab under the banner
727
+ // shows how far the version is and Next up shows the ready work, so a sentence
728
+ // repeating both was redundant (owner, 2026-10-23).
730
729
  const greetingWord = (hour) => (hour < 12 ? 'Good morning' : (hour < 18 ? 'Good afternoon' : 'Good evening'));
731
- // The version the sentence is about: the one being built with the most work in it.
730
+ // The version the tab is about: the one being built with the most work in it.
732
731
  function currentVersion(progress) {
733
732
  const rows = progress && Array.isArray(progress.progress) ? progress.progress : [];
734
733
  return rows.filter((r) => r && r.status === 'building')
735
734
  .sort((a, b) => (Number(b.total_count) || 0) - (Number(a.total_count) || 0))[0] || null;
736
735
  }
737
- // task 1004774: the version tab under the Home banner — the same version the
738
- // sub-line names, drawn as a bar. Hidden while no version is being built.
736
+ // task 1004774: the version tab under the Home banner, drawn as a bar.
737
+ // Hidden while no version is being built.
739
738
  function renderVersionTab(v) {
740
739
  const tab = document.getElementById('home-version');
741
740
  if (!tab) return;
@@ -752,26 +751,11 @@
752
751
  }
753
752
  function renderGreeting() {
754
753
  const h1 = document.getElementById('hall-h1');
755
- const sub = document.getElementById('hall-sub');
756
754
  const builder = ctx.lastMeState && ctx.lastMeState.builder;
757
755
  if (h1 && builder) {
758
756
  h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
759
757
  }
760
- const v = currentVersion(ctx.lastProgressState);
761
- renderVersionTab(v);
762
- if (!sub) return;
763
- const parts = [];
764
- if (v) parts.push(`${v.version_id} is ${Math.round(Number(v.percent_complete) || 0)} percent complete.`);
765
- const n = ctx.lastClaimableCount;
766
- if (typeof n === 'number') {
767
- if (n === 0) parts.push('Nothing is claimable right now.');
768
- else {
769
- const word = n < SMALL_NUMBERS.length ? SMALL_NUMBERS[n] : fmtNum(n);
770
- parts.push(`${word.charAt(0).toUpperCase()}${word.slice(1)} ${n === 1 ? 'task is' : 'tasks are'} claimable right now.`);
771
- }
772
- }
773
- sub.textContent = parts.join(' ');
774
- sub.hidden = parts.length === 0;
758
+ renderVersionTab(currentVersion(ctx.lastProgressState));
775
759
  }
776
760
  const ONBOARDING_STAGE_ORDER = [
777
761
  'auth_complete',
@@ -42,12 +42,11 @@
42
42
  (one-header-main-h1 — never a second one). The static text is the no-JS /
43
43
  signed-out fallback and what brand-apply.js writes the hall's name into;
44
44
  hall-render.js renderGreeting turns it into "Good morning, <name>" once
45
- /me answers, and fills the sub-line — the live status sentence (how far
46
- the version is, whether anything is claimable) — hidden until it has
47
- something to say. The label voice is the board's own (.page-head--board). -->
45
+ /me answers. It has no sub-line (task 1004780): the version tab and Next
46
+ up already say how far the version is and what is ready. The label voice
47
+ is the board's own (.page-head--board). -->
48
48
  <div class="page-head page-head--board page-head--hall">
49
49
  <h1 id="hall-h1" class="page-head__h">Builders' Hall</h1>
50
- <p id="hall-sub" class="page-head__sub" hidden></p>
51
50
  <!-- task 1004774: Standing left the Home band for this chip. At rest it
52
51
  shows the avatar and rank. On hover or keyboard focus the chip itself
53
52
  widens to the left into the top of the card, and only the facts below
@@ -1030,6 +1030,10 @@
1030
1030
  renderArtKeyBanner(artKeyNeedItem(me));
1031
1031
  const res = await api.request('GET', `${API}/me/art-key/own`);
1032
1032
  if (res.status === 401) { toAuth(); return; }
1033
+ // 404: the art pipeline is not installed, so there is no key to add here —
1034
+ // keep the section (and any banner) hidden rather than show a reload error
1035
+ // (task 1004786).
1036
+ if (res.status === 404) { renderArtKeyBanner(null); return; }
1033
1037
  if (!res.ok) throw new Error(`art-key GET → ${res.status}`);
1034
1038
  renderArtKeyCard(res.data);
1035
1039
  wireArtKeyCard();
@@ -147,7 +147,10 @@
147
147
  id: String(g.goal_id), title: g.title || 'A private goal', subtitle: g.subtitle || null, version: g.version_id, status: g.status,
148
148
  taskTotal: Number(g.task_total) || 0, taskDone: Number(g.task_done) || 0, createdAt: g.created_at || null,
149
149
  crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true, uat: c.uat_state || null, uatLabel: uatLabel(c.uat_state), backendOnly: c.backend_only === true })),
150
- tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
150
+ // task 1004782 — abandoned (or merged-away) work carries no credits: it is listed, at zero, and never adds to a
151
+ // well's mass or a ring. A merged-away task is always abandoned (tasks.merged_into_task_id is CHECK-constrained to
152
+ // abandoned rows), so the status test covers both; /sky does not send the pointer.
153
+ tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.status === 'abandoned' || t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
151
154
  };
152
155
  }
153
156
  const taskRow = (t) => ({ id: String(t.task_id ?? t.id), title: t.title || 'a task', status: t.status, goalId: t.goal_id != null ? String(t.goal_id) : null, origin: t.origin || null });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.165",
3
+ "version": "1.21.167",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.165",
9
+ "version": "1.21.167",
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.165",
3
+ "version": "1.21.167",
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",
@@ -10040,5 +10040,21 @@
10040
10040
  "id": "1004771",
10041
10041
  "text": "Artist review is now a small tab on the right edge of every hall page in artist mode. Hover the tab and the review panel slides out; move the cursor back onto the page and it tucks away, keeping your draft. The page stays scro"
10042
10042
  }
10043
+ ],
10044
+ "1.21.166": [
10045
+ {
10046
+ "id": "1004780",
10047
+ "text": "Home's greeting stands on its own: the \"BONGOS-V2 is 83 percent complete. 150 tasks are claimable right now.\" line under \"Good morning\" is gone, because the version bar and the ready-work list beside it already say both."
10048
+ },
10049
+ {
10050
+ "id": "1004786",
10051
+ "text": "Settings no longer shows a broken image-generation key card on projects without the art tools. Before, builders were told their free art coverage had ended and to add a key, but the card could never load, so there was nowhere"
10052
+ }
10053
+ ],
10054
+ "1.21.167": [
10055
+ {
10056
+ "id": "1004782",
10057
+ "text": "Goals no longer count abandoned work in their credit totals. Will goal drops by 241 credits of dropped tasks; across the project about 20,000 credits of abandoned estimates stop inflating goal sizes in the sky map and goal pag"
10058
+ }
10043
10059
  ]
10044
10060
  }
@@ -569,6 +569,9 @@ module.exports = function buildMeRouter() {
569
569
  rank: builder?.rank,
570
570
  ownKeySet: !!(geminiKeyRow && geminiKeyRow.enc),
571
571
  sharedKeyConfigured: artSharedKeyConfigured(),
572
+ // task 1004786: the art-pipeline module provides this port when enabled;
573
+ // without it there are no key routes, so the need is not raised.
574
+ artPipeline: seams.hasProvider('art.sharedKeyConfigured'),
572
575
  // founding grace (task 1002739 / direction §5.6): a newborn instance's
573
576
  // founder gets a welcome roster, not a coverage nag. The module owns the
574
577
  // policy (window length, copy); we pass the raw facts.
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.165'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.167'; // 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');
@@ -244,12 +244,15 @@ const hallRenderJs = read('hall-render.js');
244
244
  const buildersJs = read('builders.js');
245
245
  const goalInboxJs = read('goal-inbox.js');
246
246
 
247
- test('the greeting IS the page\'s one h1, in the board\'s label voice, with a sub-line beside it', () => {
247
+ test('the greeting IS the page\'s one h1, in the board\'s label voice, with no sub-line under it', () => {
248
248
  const h1s = indexHtml.match(/<h1\b/g) || [];
249
249
  assert.equal(h1s.length, 1, 'one-header-main-h1: the greeting must BECOME the h1, never add a second');
250
- assert.match(indexHtml, /<div class="page-head page-head--board page-head--hall">\s*<h1 id="hall-h1" class="page-head__h">Builders' Hall<\/h1>\s*<p id="hall-sub" class="page-head__sub" hidden><\/p>/,
251
- 'the h1 is visible (no sr-only), keeps the id brand-apply.js writes into, and the sub-line ships hidden');
250
+ assert.match(indexHtml, /<div class="page-head page-head--board page-head--hall">\s*<h1 id="hall-h1" class="page-head__h">Builders' Hall<\/h1>/,
251
+ 'the h1 is visible (no sr-only) and keeps the id brand-apply.js writes into');
252
252
  assert.doesNotMatch(indexHtml, /id="hall-h1" class="sr-only"/, 'the sr-only h1 is gone');
253
+ // task 1004780 (owner): the status sentence repeated the version tab and Next up, so it went.
254
+ assert.doesNotMatch(indexHtml, /id="hall-sub"/, 'no status line under the greeting');
255
+ assert.doesNotMatch(hallRenderJs, /hall-sub|claimable right now/, 'and nothing renders one');
253
256
  });
254
257
 
255
258
  test('the band ends level: no align-items on the grid, so the two cards stretch', () => {
@@ -300,28 +303,18 @@ test('the system notices are inbox LINES, first in the card, and counted by its
300
303
  }
301
304
  });
302
305
 
303
- test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
306
+ test('the greeting reads /me and the public progress (for the version tab), and builders.js feeds both', () => {
304
307
  assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
305
- // The count comes off the SAME Next-up response — one request, not a second
306
- // fetch. It reads `claimable_total` rather than the length of the returned array,
307
- // because that request is now bounded (?limit=2, task 1003545): the array holds two
308
- // rows and the total holds the whole rank-scoped queue. Asserting the derivation
309
- // rather than one literal expression is deliberate — the previous version pinned
310
- // `resp.claimable.length`, which read as a requirement to download the feed.
311
- assert.match(buildersJs, /lastClaimableCount = Number\.isFinite\(claimableTotal\)/,
312
- 'the claimable feed Home already fetches for Next-up answers the count');
313
- assert.match(buildersJs, /const claimableTotal = Number\(resp && resp\.claimable_total\);/,
314
- 'and it takes the count from the response total, so a bounded read still knows it');
315
308
  assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
316
- 'no unbounded claimable fetch on Home — the count must never cost the whole queue');
309
+ 'no unbounded claimable fetch on Home');
317
310
  assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
318
- assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
319
- assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
311
+ assert.doesNotMatch(buildersJs, /lastClaimableCount/, 'the greeting no longer needs the claimable count (task 1004780)');
312
+ assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 2, 'rendered after /me and after progress — each as it lands');
320
313
  });
321
314
 
322
315
  // Boot hall-render.js against a stub DOM and a ctx of getters (the way
323
316
  // builders.js builds it) and read what the greeting and the card actually say.
324
- function bootHallRender({ me, progress = null, claimable = null, leaderboard = [] }) {
317
+ function bootHallRender({ me, progress = null, leaderboard = [] }) {
325
318
  const els = new Map();
326
319
  const makeEl = () => ({
327
320
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
@@ -359,7 +352,7 @@ function bootHallRender({ me, progress = null, claimable = null, leaderboard = [
359
352
  instanceRepo: null, newcomerGated: false,
360
353
  get lastMeState() { return me; }, get lastCostState() { return me.cost || null; }, get lastKarmaState() { return me.karma || null; },
361
354
  get lastNeedsState() { return me.needs || null; }, get lastRebaseState() { return me.needs_rebase_tasks || null; },
362
- get lastProgressState() { return progress; }, get lastClaimableCount() { return claimable; },
355
+ get lastProgressState() { return progress; },
363
356
  };
364
357
  const api = windowObj.OTBHallRender(ctx);
365
358
  api.lbState.rows = leaderboard;
@@ -375,30 +368,16 @@ const ME = {
375
368
  const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
376
369
  const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
377
370
 
378
- test('RUN: the greeting names the builder, and the sub-line says how far the version is and what is claimable', () => {
379
- const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
371
+ test('RUN: the greeting names the builder, and the version tab carries how far the version is', () => {
372
+ const { api, el } = bootHallRender({ me: ME, progress: PROGRESS });
380
373
  api.renderGreeting();
381
374
  assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
382
- assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete. Five tasks are claimable right now.');
383
- assert.equal(el('hall-sub').hidden, false);
384
- });
385
-
386
- test('RUN: each half of the sentence renders as its data lands, and the line hides with neither', () => {
375
+ assert.equal(el('home-version-name').textContent, 'BONGOS-V1', 'the building version, not the bigger planning one');
376
+ assert.equal(el('home-version-pct').textContent, '75% complete');
377
+ assert.equal(el('home-version').hidden, false);
387
378
  const none = bootHallRender({ me: ME });
388
379
  none.api.renderGreeting();
389
- assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
390
- const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
391
- onlyProgress.api.renderGreeting();
392
- assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete.');
393
- const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
394
- zero.api.renderGreeting();
395
- assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
396
- const one = bootHallRender({ me: ME, claimable: 1 });
397
- one.api.renderGreeting();
398
- assert.equal(one.el('hall-sub').textContent, 'One task is claimable right now.');
399
- const many = bootHallRender({ me: ME, claimable: 63 });
400
- many.api.renderGreeting();
401
- assert.equal(many.el('hall-sub').textContent, '63 tasks are claimable right now.');
380
+ assert.equal(none.el('home-version').hidden, true, 'no progress yet: no tab');
402
381
  });
403
382
 
404
383
  test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
@@ -216,6 +216,24 @@ await test('the rings are the criteria, closing as each is satisfied and thick w
216
216
  assert.equal(g.tasks.find((t) => t.id === '500').sourceRef, '10', 'and which thought it was born of');
217
217
  });
218
218
 
219
+ await test('abandoned work is listed at zero and adds nothing to a well or its ring (task 1004782)', async () => {
220
+ const base = well();
221
+ const p = await boot({ sky: sky([well({
222
+ criteria: [{ ...base.criteria[0], task_ids: ['500', '504'] }, { ...base.criteria[1], task_ids: ['501', '502', '503'] }],
223
+ tasks: [
224
+ ...base.tasks,
225
+ { id: '503', title: 'A dropped task', status: 'abandoned', credits_reward: 500, created_on: '2026-08-03', idea_id: null },
226
+ // merged away: the DB only allows merged_into_task_id on an abandoned row, so this is its whole shape
227
+ { id: '504', title: 'A task folded into 500', status: 'abandoned', merged_into_task_id: '500', credits_reward: 90, created_on: '2026-08-04', idea_id: null },
228
+ ],
229
+ })]) });
230
+ const g = p.S().goalById.get('7');
231
+ assert.equal(g.tasks.find((t) => t.id === '503').credits, 0, 'still listed, at zero');
232
+ assert.equal(g.tasks.find((t) => t.id === '504').credits, 0, 'a merged-away task is listed at zero too');
233
+ assert.equal(g.credits, 130, 'the well\'s mass is the live work only (60 + 40 + 30)');
234
+ same(g.crit.map((c) => c.credits), [60, 70], 'and the ring it sat on is not thickened by it');
235
+ });
236
+
219
237
  await test('a private goal keeps its structure and none of its prose', async () => {
220
238
  const p = await boot({ sky: sky([well({ goal_id: '8', title: null, subtitle: null, criteria: [{ id: '80', criterion_id: 'c1', criterion_md: null, satisfied: false, pending_review: false, task_ids: [] }], tasks: [{ id: '800', title: null, status: 'ready', credits_reward: 10, created_on: '2026-08-01', idea_id: null }] })]) });
221
239
  const g = p.S().goalById.get('8');
@@ -97,8 +97,12 @@ test('closing natively (Escape) also returns the slice, so a reopen still finds
97
97
  const { ctx, page } = await bootInbox();
98
98
  await page.click('[data-attn-open="attention-blockers"]');
99
99
  await page.keyboard.press('Escape');
100
- await page.waitForFunction(() => !document.getElementById('attention-dialog').open);
101
- const back = await page.evaluate(() => !!document.querySelector('#attention-slices > #attention-blockers'));
100
+ // `open` turns false at once, but the dialog's `close` EVENT (which moves the slice home) is
101
+ // queued after it, so wait for the slice itself rather than for `open` (task 1004780 — reading
102
+ // straight after `open` flipped raced the event and failed CI about half the time).
103
+ const back = await page.waitForFunction(
104
+ () => !document.getElementById('attention-dialog').open && !!document.querySelector('#attention-slices > #attention-blockers'),
105
+ null, { timeout: 3000 }).then(() => true, () => false);
102
106
  assert.equal(back, true, 'the dialog close event moves the slice home');
103
107
  await page.click('[data-attn-open="attention-blockers"]');
104
108
  const rows = await page.evaluate(() => document.querySelectorAll('#attention-dialog-body .ginbox__item').length);
@@ -164,5 +164,17 @@ test('hall: the tooltip names the mode and what actually pays', () => {
164
164
  assert.match(note, /guide, not a payout/i);
165
165
  });
166
166
 
167
+ // task 1004782 — abandoned and merged-away tasks are worth 0 wherever a total
168
+ // adds them up, on every reward mode.
169
+ test('hall: an abandoned or merged-away task is worth 0, whatever its estimate', () => {
170
+ for (const mode of [CPO, PAYS, undefined]) {
171
+ const B = loadBrandApply(mode);
172
+ assert.equal(B.taskCreditValue({ status: 'abandoned', credits_reward: 60 }), 0);
173
+ assert.equal(B.taskCreditValue({ status: 'abandoned', credits_reward: 60, credits_awarded: 12 }), 0);
174
+ assert.equal(B.taskCreditValue({ status: 'ready', credits_reward: 60, merged_into_task_id: 9 }), 0);
175
+ assert.equal(B.taskCreditValue(READY_TASK), 60, 'live work keeps its value');
176
+ }
177
+ });
178
+
167
179
  console.log(`\nreward_mode_surfacing: ${passed} passed, ${failed} failed`);
168
180
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,137 @@
1
+ // tests/settings_art_key_absent.mjs — the image-generation key card on a project
2
+ // with no art pipeline (task 1004786).
3
+ //
4
+ // The key routes (/me/art-key/*) left the core with the art-pipeline module, but
5
+ // the Settings card and the art_key need stayed. On cloudbongos.com — which has
6
+ // no art pipeline — every builder past Xenos was told "your free art coverage has
7
+ // ended, add your key", and the card the nudge pointed at could never load, so
8
+ // there was nowhere to add one. Two halves, both pinned here:
9
+ //
10
+ // - builder-needs: with the art pipeline absent (ctx.artPipeline === false) the
11
+ // art_key need is not raised at all — nothing to nag about, nothing to add;
12
+ // - settings.js: a 404 from GET /me/art-key/own means the routes are not
13
+ // installed, so the section stays hidden instead of showing a reload error.
14
+ // A real failure (500) still shows the error.
15
+ //
16
+ // Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
17
+ import { strict as assert } from 'node:assert';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
26
+ const ME = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
27
+ const needs = createRequire(import.meta.url)('../modules/builder-settings/builder-needs.js');
28
+
29
+ let passed = 0, failed = 0;
30
+ async function t(name, fn) {
31
+ try { await fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+
35
+ function makeEl() {
36
+ return {
37
+ hidden: false, textContent: '', value: '', disabled: false, href: '', className: '',
38
+ dataset: {}, style: {}, children: [], _html: '',
39
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
40
+ addEventListener() {}, appendChild(c) { return c; },
41
+ append() {}, prepend() {}, remove() {}, setAttribute() {}, removeAttribute() {}, getAttribute() { return null; },
42
+ querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
43
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
44
+ focus() {},
45
+ };
46
+ }
47
+
48
+ // Boot the real settings.js; GET /me/art-key/own answers with `artKeyStatus`.
49
+ async function boot(artKeyStatus) {
50
+ const els = new Map();
51
+ const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
52
+ // The markup ships both hidden (settings.html), so the stub starts them hidden.
53
+ $('#art-key').hidden = true;
54
+ $('#artkey-banner').hidden = true;
55
+ const builder = { id: 4, github_login: 'b4', rank: 'thetes', preferred_disciplines: ['engineer'] };
56
+ const me = { builder, needs: { items: [needs.artKeyNeed({ rank: 'thetes' })] } };
57
+ const api = {
58
+ async request(method, url) {
59
+ if (method === 'GET' && url === '/api/bongos/me') return { status: 200, ok: true, data: me };
60
+ if (method === 'GET' && url === '/api/bongos/me/art-key/own') {
61
+ return artKeyStatus === 200
62
+ ? { status: 200, ok: true, data: { set: false, storage_configured: true } }
63
+ : { status: artKeyStatus, ok: false, data: null };
64
+ }
65
+ return { status: 404, ok: false, data: null };
66
+ },
67
+ };
68
+ const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
69
+ const document = {
70
+ getElementById: (id) => $(`#${id}`), createElement: () => makeEl(),
71
+ dispatchEvent() {}, addEventListener() {}, querySelector: () => null, querySelectorAll: () => [],
72
+ };
73
+ const window = {
74
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
75
+ BongosClient: { createClient: () => api },
76
+ OTB: {
77
+ $, escapeHtml, toast() {}, disciplines: () => ['engineer'], roleLabel: (d) => d,
78
+ hubOrigin: () => null, settingsPanels: () => [],
79
+ },
80
+ OTBKit: { emptyStateHtml: (m) => m, makeSectionRail: () => ({ refresh() {}, activate() {} }) },
81
+ location: { hash: '', pathname: '/settings', search: '', assign() {} },
82
+ scrollTo() {}, confirm: () => false, addEventListener() {},
83
+ };
84
+ const sandbox = {
85
+ window, document, console: { log() {}, warn() {}, error() {}, info() {} },
86
+ history: { replaceState() {} }, URLSearchParams, setTimeout, clearTimeout,
87
+ };
88
+ sandbox.globalThis = sandbox;
89
+ vm.createContext(sandbox);
90
+ vm.runInContext(SRC, sandbox, { filename: 'settings.js' });
91
+ for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r));
92
+ return $;
93
+ }
94
+
95
+ console.log('\nthe art_key need without an art pipeline:');
96
+
97
+ await t('artPipeline: false raises no art_key need at any rank', () => {
98
+ for (const rank of ['xenos', 'thetes', 'metic', 'archon']) {
99
+ assert.equal(needs.artKeyNeed({ rank, artPipeline: false }), null, rank);
100
+ const r = needs.computeNeeds({ rank, artPipeline: false });
101
+ assert.ok(!r.items.some((n) => n.id === 'art_key'), rank);
102
+ }
103
+ });
104
+
105
+ await t('with the pipeline present (or unstated) the need is unchanged', () => {
106
+ assert.equal(needs.artKeyNeed({ rank: 'thetes', artPipeline: true }).state, 'action_needed');
107
+ assert.equal(needs.artKeyNeed({ rank: 'thetes' }).state, 'action_needed');
108
+ });
109
+
110
+ await t('/me tells computeNeeds whether the art pipeline is installed', () => {
111
+ const call = ME.slice(ME.indexOf('settings.computeNeeds({'), ME.indexOf('isFoundingOwner'));
112
+ assert.match(call, /artPipeline:/);
113
+ });
114
+
115
+ console.log('\nthe Settings card:');
116
+
117
+ await t('routes not installed (404) → the section stays hidden, no reload error', async () => {
118
+ const $ = await boot(404);
119
+ assert.equal($('#art-key').hidden, true);
120
+ assert.equal($('#artkey-banner').hidden, true);
121
+ assert.doesNotMatch($('#artkey-card').innerHTML, /could not be loaded/);
122
+ });
123
+
124
+ await t('a real failure (500) still shows the error', async () => {
125
+ const $ = await boot(500);
126
+ assert.equal($('#art-key').hidden, false);
127
+ assert.match($('#artkey-card').innerHTML, /could not be loaded/);
128
+ });
129
+
130
+ await t('routes present → the key box renders', async () => {
131
+ const $ = await boot(200);
132
+ assert.equal($('#art-key').hidden, false);
133
+ assert.match($('#artkey-card').innerHTML, /id="artkey-input"/);
134
+ });
135
+
136
+ console.log(`\n${passed} passed, ${failed} failed`);
137
+ process.exit(failed ? 1 : 0);