@bongos/core 1.20.77 → 1.20.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.bongos-core.json +83 -58
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  8. package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +70 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +22 -14
  13. package/docs/copy-registry.json +125 -41
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +5 -1
  16. package/docs/page-readings.json +150 -137
  17. package/docs/recipes/upgrading-the-core.md +11 -2
  18. package/modules/copy-desk/artist-stats.js +15 -1
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +41 -0
  21. package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
  23. package/modules/hall-ui/public/deploy.states.json +1 -1
  24. package/modules/hall-ui/public/profile-crafts.js +231 -0
  25. package/modules/hall-ui/public/profile.css +60 -0
  26. package/modules/hall-ui/public/profile.html +31 -12
  27. package/modules/hall-ui/public/profile.js +180 -75
  28. package/modules/hall-ui/public/profile.states.json +34 -0
  29. package/modules/hall-ui/public/sky-draw.js +66 -39
  30. package/modules/hall-ui/public/sky-interaction.js +1 -1
  31. package/modules/hall-ui/public/sky-panel.js +1 -1
  32. package/modules/hall-ui/public/thinking.css +52 -24
  33. package/modules/hall-ui/records/profile-roles.md +18 -0
  34. package/modules/npm-release/public/work.js +72 -0
  35. package/modules/npm-release/work.js +67 -1
  36. package/modules/provisioning/render-standup.js +2 -1
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +24 -0
  40. package/scripts/gds/init.js +16 -6
  41. package/scripts/gds/update-channel.js +68 -1
  42. package/scripts/gds/update-sweep.js +100 -4
  43. package/scripts/migrate.sh +6 -2
  44. package/src/module-api.js +1 -1
  45. package/tests/hall_sky.mjs +77 -0
  46. package/tests/helpers.mjs +32 -7
  47. package/tests/init.mjs +21 -5
  48. package/tests/migrate_applied_check.mjs +94 -0
  49. package/tests/npm_release_page.mjs +67 -0
  50. package/tests/npm_release_work.mjs +82 -0
  51. package/tests/profile_activity.mjs +23 -15
  52. package/tests/profile_role_headline.mjs +4 -4
  53. package/tests/profile_role_tabs.mjs +163 -0
  54. package/tests/update_channel.mjs +80 -1
  55. package/tests/update_subscription_engine.mjs +99 -0
  56. package/tests/upgrade_migrate.mjs +1 -1
@@ -0,0 +1,94 @@
1
+ // tests/migrate_applied_check.mjs — migrate.sh never re-runs a migration that is already applied
2
+ // (task 1004488).
3
+ //
4
+ // WHAT IS AT RISK. migrate.sh runs under `set -euo pipefail`, and it used to ask "is this stem
5
+ // applied?" with `echo "$APPLIED" | grep -q "^stem$"`. grep -q exits on its first match; if echo
6
+ // has not finished writing the list, echo dies of SIGPIPE, pipefail fails the pipeline, and an
7
+ // applied migration reads as NOT applied. On the droplet that happened to a random handful of the
8
+ // ~300 checks in a run (task 1004388: 091, then 145 + 177, then 037 + 059 — all recorded as
9
+ // applied), so a deploy could silently re-run a data migration against the live database.
10
+ //
11
+ // HOW THIS MAKES A RACE DETERMINISTIC. A stand-in psql answers the "SELECT version FROM
12
+ // schema_migrations" query with every real core stem FIRST, then enough padding to overflow any
13
+ // pipe buffer. echo then blocks on a full pipe while grep -q has already matched and exited, so
14
+ // the old check fails for every stem, every run. The stub records each `-f <file>` it is asked to
15
+ // apply; with every stem recorded as applied, there must be none.
16
+ //
17
+ // Run: node --test tests/migrate_applied_check.mjs
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, chmodSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { fileURLToPath } from 'node:url';
24
+ import path from 'node:path';
25
+
26
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const MIGRATE = path.join(REPO_ROOT, 'scripts', 'migrate.sh');
28
+
29
+ // migrate.sh is POSIX bash; the stub psql below is a shell script. Windows CI runs the suite too.
30
+ const skip = process.platform === 'win32' ? 'POSIX shell stub' : false;
31
+
32
+ const sqlStems = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.sql')).map((f) => f.slice(0, -4)) : []);
33
+
34
+ // The core stems: the ones the core loop must skip.
35
+ function stems() {
36
+ return sqlStems(path.join(REPO_ROOT, 'migrations')).sort();
37
+ }
38
+
39
+ // Every module's stems too, so the module loop that follows finds them applied as well.
40
+ function moduleStems() {
41
+ const root = path.join(REPO_ROOT, 'modules');
42
+ return readdirSync(root).flatMap((m) => sqlStems(path.join(root, m, 'migrations'))).sort();
43
+ }
44
+
45
+ function rig() {
46
+ const dir = mkdtempSync(path.join(tmpdir(), 'migrate-applied-'));
47
+ const list = path.join(dir, 'applied.txt');
48
+ const log = path.join(dir, 'applied-files.log');
49
+ // Real stems first (so grep -q matches early), then ~400 KB of padding (so echo is still writing).
50
+ const padding = Array.from({ length: 8000 }, (_, i) => `zz_padding_${String(i).padStart(6, '0')}_not_a_real_migration_stem`);
51
+ writeFileSync(list, [...stems(), ...moduleStems(), ...padding].join('\n') + '\n');
52
+ const psql = path.join(dir, 'psql');
53
+ writeFileSync(psql, [
54
+ '#!/usr/bin/env bash',
55
+ '# Stand-in psql: the applied list for the schema_migrations read, a log line for every -f.',
56
+ 'prev=""',
57
+ 'for a in "$@"; do',
58
+ ` if [ "$prev" = "-f" ]; then echo "$a" >> ${JSON.stringify(log)}; fi`,
59
+ ' case "$a" in *"SELECT version FROM schema_migrations"*)' + ` cat ${JSON.stringify(list)};; esac`,
60
+ ' prev="$a"',
61
+ 'done',
62
+ 'exit 0',
63
+ '',
64
+ ].join('\n'));
65
+ chmodSync(psql, 0o755);
66
+ return { dir, log };
67
+ }
68
+
69
+ test('a migration recorded as applied is skipped, even when the applied list overflows a pipe buffer', { skip }, () => {
70
+ const { dir, log } = rig();
71
+ try {
72
+ const env = { ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, PGDATABASE: 'stub_db', DATABASE_URL: '',
73
+ PGHOST: path.join(dir, 'no-such-socket-dir'), INIT_CWD: REPO_ROOT };
74
+ // The run goes on past the core loop (modules, gates) and may stop there against a stub; the core
75
+ // loop's own output is what this test reads, so the exit code is not asserted.
76
+ const r = spawnSync('bash', [MIGRATE], { cwd: REPO_ROOT, env, encoding: 'utf8', timeout: 120000 });
77
+ const out = String(r.stdout || '');
78
+ const skipped = (out.match(/^skip {3}/gm) || []).length;
79
+ assert.ok(skipped >= stems().length, `expected every core stem skipped, saw ${skipped} of ${stems().length}\n${out.slice(0, 2000)}\n${String(r.stderr || '').slice(-2000)}`);
80
+ const reapplied = (out.match(/^apply {2}(\S+)/gm) || []).map((l) => l.slice(7));
81
+ assert.deepEqual(reapplied, [], 'already-applied migrations were re-run: ' + reapplied.join(', '));
82
+ const files = existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).filter((f) => f.endsWith('.sql')) : [];
83
+ assert.deepEqual(files, [], 'psql -f was called for an applied migration');
84
+ } finally {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test('migrate.sh asks "is it applied?" without a pipe', () => {
90
+ // The behavioural test above is the proof; this names the shape so a future edit that brings
91
+ // `echo ... | grep -q` back is refused at the line, not rediscovered on the droplet.
92
+ const src = readFileSync(MIGRATE, 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
93
+ assert.doesNotMatch(src, /echo [^\n|]*\|\s*grep -q/, 'an `echo … | grep -q` pipeline under pipefail can read a match as a miss');
94
+ });
@@ -181,3 +181,70 @@ test('a task title and a note line are quoted ledger data: their dashes render a
181
181
  assert.ok(b.work().innerHTML.includes('>Deploy page, where the work is, phase 1</a>'));
182
182
  assert.doesNotMatch(b.work().innerHTML, /Deploy page [—–]/);
183
183
  });
184
+
185
+ // ---- this hall's own upgrades (task 1004297) ----------------------------------------
186
+
187
+ const withUpgrades = (over) => ({ ...FIXTURE, upgrades: { ok: true, rows: [] }, waiting: null, ...over });
188
+ const upgrade = (o) => ({ from: '1.19.1032', to: '1.19.1033', at: '2026-09-26T00:22:18.000Z', how: 'server', rolled_back: false, reason: null, ...o });
189
+
190
+ test('the upgrades group lists each move with who made it', async () => {
191
+ const b = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [
192
+ upgrade({}), upgrade({ from: '1.19.1030', to: '1.19.1032', how: 'door' }), upgrade({ from: '1.19.1029', to: '1.19.1030', how: 'sweep' }),
193
+ ] } })));
194
+ await b.settle();
195
+ const html = b.work().innerHTML;
196
+ assert.match(html, /Upgrades on this hall/);
197
+ assert.match(html, />1\.19\.1032 → 1\.19\.1033</);
198
+ assert.match(html, /on the server/);
199
+ assert.match(html, /Upgrade pressed on \/deploy/);
200
+ assert.match(html, /the automatic update/);
201
+ assert.doesNotMatch(html, /was rolled back/, 'no warning when nothing rolled back');
202
+ });
203
+
204
+ test('a rolled-back upgrade is said plainly, and a rollback as the LAST move is a warning', async () => {
205
+ const back = upgrade({ from: '1.19.1033', to: '1.19.1034', rolled_back: true, reason: 'health check failed — after restart' });
206
+ const b = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [back, upgrade({})] } })));
207
+ await b.settle();
208
+ const html = b.work().innerHTML;
209
+ assert.match(html, />Tried 1\.19\.1033 → 1\.19\.1034</);
210
+ assert.match(html, /It did not come back healthy \(health check failed, after restart\), so the hall went back to 1\.19\.1033\./, 'the reason is ledger text: its dash renders as a comma');
211
+ assert.match(html, /<strong>The last upgrade here was rolled back\.<\/strong> 1\.19\.1034 was installed, did not come back healthy, and was undone\./);
212
+
213
+ assert.match(html, /This hall will not try it again for a day; the next newer version is installed as soon as it is published\./);
214
+
215
+ // The version still waits in the reading, but it is HELD by the sweep: "takes it on its next
216
+ // update" would be a promise the lane will not keep, so the rollback note speaks alone.
217
+ const heldBack = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [back] },
218
+ waiting: { versions: 1, first: '1.19.1034', since: new Date().toISOString(), minutes: 3, stalled: false } })));
219
+ await heldBack.settle();
220
+ assert.doesNotMatch(heldBack.work().innerHTML, /takes it on its next automatic update/);
221
+
222
+ const older = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [upgrade({ to: '1.19.1035' }), back] } })));
223
+ await older.settle();
224
+ assert.doesNotMatch(older.work().innerHTML, /The last upgrade here was rolled back/, 'an old rollback is history, not a warning');
225
+ });
226
+
227
+ test('a stalled wait warns that the automation may have stopped; a fresh one is one quiet line', async () => {
228
+ const stalled = bootWork(() => ok(withUpgrades({ waiting: { versions: 3, first: '1.19.1041', since: '2026-09-26T08:00:00.000Z', minutes: 300, stalled: true } })));
229
+ await stalled.settle();
230
+ assert.match(stalled.work().innerHTML, /ov-note--warn"><p><strong>The automatic update may have stopped\.<\/strong> 1\.19\.1041 \(and 2 newer\) was published/);
231
+
232
+ const fresh = bootWork(() => ok(withUpgrades({ waiting: { versions: 1, first: '1.19.1042', since: new Date(Date.now() - 4 * 60000).toISOString(), minutes: 4, stalled: false } })));
233
+ await fresh.settle();
234
+ assert.doesNotMatch(fresh.work().innerHTML, /may have stopped/);
235
+ assert.match(fresh.work().innerHTML, /1\.19\.1042 was published 4 minutes ago; this hall takes it on its next automatic update\./);
236
+ });
237
+
238
+ test('an unreadable ledger says so; a page with no ledger reading shows no group at all', async () => {
239
+ const bad = bootWork(() => ok(withUpgrades({ upgrades: { ok: false, error: 'this hall\'s upgrade ledger could not be read (<b>boom</b>)' } })));
240
+ await bad.settle();
241
+ assert.match(bad.work().innerHTML, /this hall&#39;s upgrade ledger could not be read \(&lt;b&gt;boom&lt;\/b&gt;\)\./);
242
+
243
+ const none = bootWork(() => ok({ ...FIXTURE, upgrades: null }));
244
+ await none.settle();
245
+ assert.doesNotMatch(none.work().innerHTML, /Upgrades on this hall/);
246
+
247
+ const empty = bootWork(() => ok(withUpgrades({})));
248
+ await empty.settle();
249
+ assert.match(empty.work().innerHTML, /No upgrade is recorded on this hall yet\./);
250
+ });
@@ -263,3 +263,85 @@ test('the handler passes the query through and answers 500 plainly when the read
263
263
  assert.equal(status, 500);
264
264
  assert.equal(err.error.code, 'work_read_failed');
265
265
  });
266
+
267
+ // ---- this hall's own upgrades (task 1004297) --------------------------------------------
268
+
269
+ // The fixture pool refuses any query it was not written for; these cases answer the ledger
270
+ // read on top of it, so every earlier query is still held to the same strictness.
271
+ function withLedger(pool, answer) {
272
+ const inner = pool.query.bind(pool);
273
+ pool.query = async (sql, params) => {
274
+ if (/FROM core_upgrades/.test(sql)) {
275
+ pool.queries.push({ sql, params });
276
+ if (answer instanceof Error) throw answer;
277
+ return { rows: answer };
278
+ }
279
+ return inner(sql, params);
280
+ };
281
+ return pool;
282
+ }
283
+
284
+ test('the upgrade ledger is read newest first, bounded, and each move says who made it', async () => {
285
+ const { read, pool } = setup();
286
+ withLedger(pool, [
287
+ { from_version: '1.0.2', to_version: '1.0.3', applied_at: '2026-09-26T12:00:00Z', source: 'shell', note: null },
288
+ { from_version: '1.0.1', to_version: '1.0.2', applied_at: '2026-09-25T12:00:00Z', source: 'owner-control', note: null },
289
+ { from_version: '1.0.1', to_version: '1.0.2', applied_at: '2026-09-25T11:00:00Z', source: null, note: 'auto-subscription (patch)' },
290
+ ]);
291
+ const d = await read();
292
+ assert.equal(d.upgrades.ok, true);
293
+ assert.deepEqual(d.upgrades.rows.map((r) => [r.from, r.to, r.how, r.rolled_back]), [
294
+ ['1.0.2', '1.0.3', 'server', false],
295
+ ['1.0.1', '1.0.2', 'door', false],
296
+ ['1.0.1', '1.0.2', 'sweep', false],
297
+ ]);
298
+ assert.equal(d.upgrades.rows[0].at, '2026-09-26T12:00:00.000Z');
299
+ const q = pool.queries.find((x) => /FROM core_upgrades/.test(x.sql));
300
+ assert.match(q.sql, /ORDER BY applied_at DESC, id DESC/);
301
+ assert.match(q.sql, /LIMIT \$1/);
302
+ assert.equal(q.params[0], 8);
303
+ });
304
+
305
+ test('a rolled-back upgrade is flagged, with its reason and without the bookkeeping', async () => {
306
+ const { read, pool } = setup();
307
+ withLedger(pool, [{ from_version: '1.0.3', to_version: '1.0.4', applied_at: '2026-09-26T12:30:00Z', source: 'shell',
308
+ note: 'rolled_back: health check failed after restart (attempted 1.0.3 → 1.0.4, restored 1.0.3)' }]);
309
+ const r = (await read()).upgrades.rows[0];
310
+ assert.equal(r.rolled_back, true);
311
+ assert.equal(r.reason, 'health check failed after restart');
312
+ assert.equal(r.to, '1.0.4');
313
+ });
314
+
315
+ test('a ledger that cannot be read is reported as unreadable, never as "no upgrades"', async () => {
316
+ const { read, pool } = setup();
317
+ withLedger(pool, new Error('relation "core_upgrades" does not exist'));
318
+ const d = await read();
319
+ assert.equal(d.upgrades.ok, false);
320
+ assert.match(d.upgrades.error, /upgrade ledger could not be read \(relation "core_upgrades" does not exist\)/);
321
+ assert.ok(d.stages, 'and the rest of the page still reads');
322
+ });
323
+
324
+ test('a published version waiting past the alarm says the automation may have stopped', async () => {
325
+ const { read, pool } = setup();
326
+ withLedger(pool, []);
327
+ const d = await read(); // runs 1.0.3; 1.0.4 was published 2026-09-25T10:00Z, 27 hours before `now`
328
+ assert.deepEqual(d.waiting, { versions: 2, first: '1.0.4', since: TIMES['1.0.4'], minutes: 27 * 60, stalled: true });
329
+
330
+ const fresh = await read({ now: () => Date.parse(TIMES['1.0.4']) + 10 * 60000 });
331
+ assert.equal(fresh.waiting.stalled, false, 'ten minutes is the system working, not stopped');
332
+ assert.equal(fresh.waiting.minutes, 10);
333
+
334
+ const current = await read({ live: '1.0.5' });
335
+ assert.equal(current.waiting, null, 'nothing newer: nothing waiting');
336
+ });
337
+
338
+ test('another package has no "this hall" upgrades to show', async () => {
339
+ const { pool } = setup();
340
+ withLedger(pool, []);
341
+ const fg = fakeHttpsGet({ [`${ORIGIN}/other-pkg`]: { body: npmPackument({ versions: ['1.0.0'], latest: '1.0.0', times: {}, pkg: 'other-pkg' }) },
342
+ [`${ORIGIN}/other-pkg/-/other-pkg-1.0.0.tgz`]: { body: npmTarball({ notes: { '1.0.0': [] }, pkg: 'other-pkg' }) } });
343
+ const d = await readWork({ pool, pkg: 'other-pkg', get: fg.get, store: freshStore(), now: () => Date.parse('2026-09-26T13:00:00Z') });
344
+ assert.equal(d.upgrades, null);
345
+ assert.equal(d.waiting, null);
346
+ assert.ok(!pool.queries.some((x) => /FROM core_upgrades/.test(x.sql)), 'and the ledger is not read at all');
347
+ });
@@ -468,27 +468,35 @@ await test('the other three outcomes each say what happened', async () => {
468
468
  assert.ok(/Still open/.test(html), 'AN OPEN IDEA READS AS OPEN — not as nothing');
469
469
  });
470
470
 
471
- await test('switching craft re-asks the SERVER — it does not filter a page in the browser', async () => {
472
- const p = bootProfile();
471
+ // Task 1004435 (WA6.RP05): one tab per role the person has, each its craft's
472
+ // own record. The engineer tab is the works and goals, WHOLE (no craft filter:
473
+ // most of the corpus is unclassified); the ideator tab is the trail; the artist
474
+ // tab is the approved pages. No tab is "tasks carrying that label" any more.
475
+ await test('each role tab asks the server for that craft\'s OWN record', async () => {
476
+ const p = bootProfile({ roles: ['ideator', 'engineer', 'artist'] });
473
477
  await settle(); await settle(); await settle();
474
- const before = p.requests.length;
478
+ assert.ok(p.requests.some((u) => u.includes('/inbox/by-builder/')), 'the first tab (the ideator) reads the trail');
479
+ assert.ok(!p.requests.some((u) => u.includes('section=goals')), 'and no goals ledger until the engineer tab opens');
480
+ let before = p.requests.length;
481
+ p.switchCraft('engineer');
482
+ await settle(); await settle();
483
+ let after = p.requests.slice(before);
484
+ assert.ok(after.some((u) => u.includes('section=tasks') && !u.includes('role=')), 'the engineer tab reads the whole works ledger');
485
+ assert.ok(after.some((u) => u.includes('section=goals') && !u.includes('role=')), 'and the whole goals ledger');
486
+ before = p.requests.length;
475
487
  p.switchCraft('artist');
476
488
  await settle(); await settle();
477
- const after = p.requests.slice(before);
478
- assert.ok(after.some((u) => u.includes('section=tasks') && u.includes('role=artist')), 'tasks are re-fetched for the craft');
479
- assert.ok(after.some((u) => u.includes('section=goals') && u.includes('role=artist')), 'goals are re-fetched for the craft');
489
+ after = p.requests.slice(before);
490
+ assert.ok(after.some((u) => /\/copy-desk\/artists\/95\/pages\?limit=\d+&offset=0/.test(u)), 'the artist tab reads their approved pages, a few at a time');
491
+ assert.ok(!after.some((u) => u.includes('role=artist')), 'never "tasks labelled artist"');
480
492
  });
481
493
 
482
- await test('the idea trail belongs to the ideator view, not to every craft', async () => {
483
- const p = bootProfile();
494
+ await test('a role the person does not have gets no tab and no read', async () => {
495
+ const p = bootProfile({ roles: ['engineer'] });
484
496
  await settle(); await settle(); await settle();
485
- assert.equal(p.el('activity-ideas-panel').hidden, false, 'the default "all" view is the whole record, trail included');
486
- p.switchCraft('engineer');
487
- await settle();
488
- assert.equal(p.el('activity-ideas-panel').hidden, true, 'an engineer view is engineer work');
489
- p.switchCraft('ideator');
490
- await settle();
491
- assert.equal(p.el('activity-ideas-panel').hidden, false, 'filing an idea IS ideator work');
497
+ assert.ok(p.requests.some((u) => u.includes('section=tasks')), 'the only tab is the engineer\'s');
498
+ assert.ok(!p.requests.some((u) => u.includes('/inbox/by-builder/')), 'no trail read for a person with no ideator role');
499
+ assert.ok(!p.requests.some((u) => u.includes('/copy-desk/artists/')), 'no pages read for a person with no artist role');
492
500
  });
493
501
 
494
502
  await test('the ledgers page server-side — the request carries a window, not the whole record', async () => {
@@ -64,11 +64,11 @@ await test('an artist who never shipped a task leads with pages approved and the
64
64
 
65
65
  await test('an ideator leads with thoughts, size and what is still alive', () => {
66
66
  const v = headline(IDEATOR, OPTS);
67
- assert.deepEqual(words(v.tally), ['186 credits', '31 thoughts filed', '7 ignited']);
68
- assert.deepEqual(words(v.tiles), ['31 thoughts filed', '7 ignited', '3 Full Ideas', '186~ total size', '72% still alive', '3 karma']);
67
+ assert.deepEqual(words(v.tally), ['186 credits', '14 thoughts filed', '7 ignited']);
68
+ assert.deepEqual(words(v.tiles), ['14 thoughts filed', '7 ignited', '3 Full Ideas', '186~ total size', '72% still alive', '3 karma']);
69
69
  assert.equal(v.tiles[2].f, '1 ratified by the board');
70
70
  assert.match(v.tiles[3].title, /Includes 24 credits of historical estimate/, 'the ~ says what it means');
71
- assert.equal(v.sub, 'Builder 9 — Contributor · 31 thoughts filed');
71
+ assert.equal(v.sub, 'Builder 9 — Contributor · 14 thoughts filed');
72
72
  });
73
73
 
74
74
  await test('several crafts each show, main first, in the server\'s order', () => {
@@ -164,7 +164,7 @@ await test('the real page draws the artist\'s tally, chips, tiles and surface ro
164
164
 
165
165
  await test('the real page draws the ideator\'s and the engineer\'s own headlines', async () => {
166
166
  const i = await boot(IDEATOR);
167
- assert.match(text(i.head), /186 credits 31 thoughts filed 7 ignited/);
167
+ assert.match(text(i.head), /186 credits 14 thoughts filed 7 ignited/);
168
168
  assert.match(text(i.nums), /186~ total size/);
169
169
  assert.match(text(i.nums), /72% still alive/);
170
170
  assert.ok(!i.nums.includes('kit-bars'), 'no surface rows for an ideator');
@@ -0,0 +1,163 @@
1
+ // tests/profile_role_tabs.mjs — the profile's Ideator and Artist tabs show the
2
+ // role's own record (task 1004435 / WA6.RP05, goal 1000095, criterion
3
+ // wa6-roles-read-true-in-public).
4
+ //
5
+ // The done-when: "both tabs match the mock for fixtures, and the old
6
+ // task-labelled lists are gone from those tabs". Run for real, end to end:
7
+ //
8
+ // 1. THE ARTIST'S READ. composeArtistPages over the copy desk's own round
9
+ // fixture, then the new signed-in route over a stubbed lifecycle port:
10
+ // only that artist's SHIPPED rounds, newest first, each with its first
11
+ // changed line; a stranger gets a 401, a bad id a 400.
12
+ // 2. THE IDEATOR'S TRAIL. profile-crafts.js over the harness's ideator fixture
13
+ // (the mock's fourteen thoughts): one mark per thought, each tied to its
14
+ // row, the sky's five words, size and brightness as separate channels.
15
+ // 3. THE PAGE. The REAL profile.js in a vm with the artist tab open, fed the
16
+ // route's own output, so a field the route renames cannot leave this green.
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { makeRunner, bootProfilePage, settle } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
30
+
31
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
32
+ const pageStatus = require('../modules/copy-desk/page-status.js');
33
+ const { test, summary } = makeRunner();
34
+
35
+ // ---- 1. the artist's read ---------------------------------------------------
36
+
37
+ await test('an artist\'s approved pages: only theirs, only shipped, newest first, with the first changed line', () => {
38
+ const alice = pageStatus.composeArtistPages({ builderId: F.ALICE.id, inventory: F.INVENTORY, tasks: F.TASKS });
39
+ assert.deepEqual(alice.map((p) => p.task_id), ['201', '101'], 'Sep 20 before Sep 1; Bob\'s round and the abandoned one are not hers');
40
+ assert.equal(alice[0].page_id, 'builders:studio');
41
+ assert.equal(alice[0].surface_label, 'Builders hall');
42
+ assert.equal(alice[0].line_count, 1);
43
+ assert.deepEqual(alice[0].first, { before: 'Welcome', after: 'Welcome home' });
44
+ assert.equal(alice[0].approved_at, '2026-09-20T10:00:00.000Z');
45
+ const bob = pageStatus.composeArtistPages({ builderId: F.BOB.id, inventory: F.INVENTORY, tasks: F.TASKS });
46
+ assert.deepEqual(bob.map((p) => p.task_id), ['102'], 'his active round is not an approved page');
47
+ assert.equal(bob[0].line_count, 2);
48
+ assert.deepEqual(bob[0].first, { before: 'Every projekt', after: 'Every project' }, 'the FIRST changed line');
49
+ });
50
+
51
+ await test('the route: signed in only, a bad id refused, paged, and it asks for THIS artist\'s rounds', async () => {
52
+ const api = require('../src/module-api.js');
53
+ const seams = require('../src/module-seams.js');
54
+ const pageData = require('../modules/copy-desk/page-data.js');
55
+ const asked = [];
56
+ seams._reset();
57
+ seams.registerProvider('lifecycle', { async listPageTweakTasks(opts) { asked.push(opts); return F.TASKS; } });
58
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'artist-pages-'));
59
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
60
+ fs.writeFileSync(path.join(tmp, 'docs', 'page-inventory.json'), JSON.stringify(F.INVENTORY));
61
+ pageData._setRoot(tmp);
62
+ let signedIn = true;
63
+ const realRequire = api.requireBuilder;
64
+ api.requireBuilder = (req, res, next) => (signedIn ? next() : res.status(401).json({ error: 'unauthorized' }));
65
+ const express = require('express');
66
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
67
+ const app = express();
68
+ app.use(attachFail);
69
+ app.use(require('../modules/copy-desk/routes/copy-desk-artist.js')());
70
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
71
+ const get = async (p) => { const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: r.status, body: await r.json() }; };
72
+ try {
73
+ const all = await get(`/copy-desk/artists/${F.ALICE.id}/pages`);
74
+ assert.equal(all.status, 200);
75
+ assert.deepEqual(all.body.rows.map((r) => r.task_id), ['201', '101']);
76
+ assert.equal(all.body.total, 2);
77
+ assert.deepEqual(asked.at(-1), { artistId: F.ALICE.id }, 'the read is scoped to the artist, never every round');
78
+ const page2 = await get(`/copy-desk/artists/${F.ALICE.id}/pages?limit=1&offset=1`);
79
+ assert.deepEqual(page2.body.rows.map((r) => r.task_id), ['101']);
80
+ assert.equal(page2.body.total, 2, 'the total is the whole set, not the page');
81
+ assert.equal((await get('/copy-desk/artists/abc/pages')).status, 400);
82
+ assert.equal((await get('/copy-desk/artists/0/pages')).status, 400);
83
+ signedIn = false;
84
+ assert.equal((await get(`/copy-desk/artists/${F.ALICE.id}/pages`)).status, 401, 'owner decision (a): the pages and their words need a sign-in');
85
+ } finally {
86
+ api.requireBuilder = realRequire;
87
+ pageData._setRoot(null);
88
+ seams._reset();
89
+ server.closeAllConnections();
90
+ await new Promise((r) => server.close(r));
91
+ fs.rmSync(tmp, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ // ---- 2. the ideator's trail -------------------------------------------------
96
+
97
+ function loadCrafts() {
98
+ const sandbox = { window: {}, console };
99
+ sandbox.globalThis = sandbox;
100
+ vm.createContext(sandbox);
101
+ for (const f of ['idea-objects.js', 'profile-crafts.js']) {
102
+ vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8'), sandbox, { filename: f });
103
+ }
104
+ return sandbox.window.OTBProfileCrafts;
105
+ }
106
+ const crafts = loadCrafts();
107
+ const TRAIL = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/inbox__by-builder__2002.json'), 'utf8')).ideas;
108
+
109
+ await test('the trail draws every thought once, each mark tied to its ledger row', () => {
110
+ const svg = crafts.trailSvgHtml(TRAIL, { width: 900 });
111
+ const ids = [...svg.matchAll(/data-i="(\d+)"/g)].map((m) => Number(m[1]));
112
+ assert.deepEqual(ids.sort((a, b) => a - b), TRAIL.map((_, i) => i), 'one mark per thought, none missing, none doubled');
113
+ assert.match(svg, /role="img"/);
114
+ assert.match(svg, /aria-label="14 thoughts filed, by the day each was filed\. Each is also a row in the table below\."/);
115
+ assert.ok(!/<(?:path|polyline|rect)\b/.test(svg), 'circles and lines only: series geometry is the kit\'s (hall_audit §6b)');
116
+ assert.equal((svg.match(/class="pt-tether"/g) || []).length, 2, 'each folded-in thought is tethered to the one it joined');
117
+ for (const cls of ['pt-star', 'pt-remnant', 'pt-cold', 'pt-ring']) assert.ok(svg.includes(cls), `the ${cls} mark is drawn`);
118
+ });
119
+
120
+ await test('size and brightness are separate channels', () => {
121
+ const big = crafts.trailSvgHtml([TRAIL[0]], { width: 600 }); // 64 cr, all alive
122
+ const half = crafts.trailSvgHtml([TRAIL[1]], { width: 600 }); // 42 cr, 60% alive
123
+ const r = (s) => Number(/class="pt-m pt-star"[^>]*r="([\d.]+)"/.exec(s)[1]);
124
+ const op = (s) => Number(/fill-opacity="([\d.]+)"/.exec(s)[1]);
125
+ assert.ok(r(big) > r(half), 'more credits, a wider mark');
126
+ assert.equal(op(big), 1);
127
+ assert.equal(op(half), 0.72, 'less of its work alive, a dimmer mark (0.3 + 0.7 x 0.6)');
128
+ const remnant = crafts.trailSvgHtml([TRAIL[4]], { width: 600 });
129
+ assert.match(remnant, /pt-remnant[^>]*r="(?!3\.2")/, 'a burned-out thought keeps its size');
130
+ });
131
+
132
+ await test('every row says its state in the sky\'s words, its size and what is still alive', () => {
133
+ const href = (id) => `/idea/${id}`;
134
+ const outcome = () => 'OUTCOME';
135
+ const row = (i) => crafts.thoughtRowHtml(TRAIL[i], i, { outcomeHtml: outcome, ideaHref: href });
136
+ assert.match(row(0), /burning<\/span>/);
137
+ assert.match(row(0), />64 cr</);
138
+ assert.match(row(1), />60%</);
139
+ assert.match(row(2), /waiting<\/span>[\s\S]*no size/);
140
+ assert.match(row(4), /burned out[\s\S]*22 cr<span class="pt-est" title="12 of it estimated[^"]*"> ~<\/span>[\s\S]*>0%</, 'the ~ marks the historical estimate');
141
+ assert.match(row(5), /folded in/);
142
+ assert.match(row(6), /declined/);
143
+ assert.match(row(0), /<tr id="pt-row-0" data-i="0" tabindex="0">/, 'a row is focusable, so a mark\'s click can land on it');
144
+ assert.match(row(0), /OUTCOME/, 'the old trail\'s "what became of it" is folded in, not dropped');
145
+ const legend = crafts.legendHtml();
146
+ for (const w of ['burning', 'waiting', 'folded in', 'burned out', 'declined']) assert.ok(legend.includes(w), `the legend names "${w}"`);
147
+ });
148
+
149
+ // ---- 3. the page --------------------------------------------------------------
150
+
151
+ await test('the real page\'s artist tab draws the route\'s own rows, before and after', async () => {
152
+ const rows = pageStatus.composeArtistPages({ builderId: F.ALICE.id, inventory: F.INVENTORY, tasks: F.TASKS });
153
+ const wire = JSON.parse(JSON.stringify(rows));
154
+ const p = bootProfilePage({ roles: ['artist'], artistPages: wire });
155
+ await settle(); await settle(); await settle(); await settle();
156
+ const html = p.el('activity-pages').innerHTML;
157
+ assert.match(html, /Studio[\s\S]*Builders hall · approved Sep 20[\s\S]*1 line changed/);
158
+ assert.match(html, /pg-ba__was">Welcome<[\s\S]*pg-ba__now">Welcome home</);
159
+ assert.match(html, /Projects[\s\S]*Landing · approved Sep 1/);
160
+ assert.ok(!p.requests.some((u) => u.includes('role=artist')), 'the old "tasks labelled artist" list is gone');
161
+ });
162
+
163
+ summary();
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { strict as assert } from 'node:assert';
17
17
  import { createRequire } from 'node:module';
18
- import { mkdtempSync, writeFileSync } from 'node:fs';
18
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
 
@@ -338,6 +338,85 @@ t('describeStaleness: silent when healthy, explicit about "the upgrader stopped"
338
338
  assert.match(msg, /upgrader has most likely stopped/);
339
339
  });
340
340
 
341
+ // ---- the follow: every candidate, or released only (task 1004297, ADR 0360) -
342
+
343
+ t('normalizeFollow: released and candidates are the words; anything else reads as the safer released', () => {
344
+ assert.equal(uc.DEFAULT_FOLLOW, 'released');
345
+ assert.deepEqual(uc.VALID_FOLLOWS, ['released', 'candidates']);
346
+ assert.equal(uc.normalizeFollow('candidates'), 'candidates');
347
+ assert.equal(uc.normalizeFollow(' Candidates '), 'candidates');
348
+ for (const v of [undefined, null, '', 'every', 'latest', 'next', 42]) assert.equal(uc.normalizeFollow(v), 'released', String(v));
349
+ });
350
+
351
+ t('versionsForFollow: candidates takes every version; released stops at the released label', () => {
352
+ const available = ['1.19.1038', '1.19.1039', '1.19.1040', '1.19.1041'];
353
+ assert.deepEqual(uc.versionsForFollow({ follow: 'candidates', available, released: '1.19.1039' }), available);
354
+ assert.deepEqual(uc.versionsForFollow({ follow: 'released', available, released: '1.19.1039' }), ['1.19.1038', '1.19.1039']);
355
+ assert.deepEqual(uc.versionsForFollow({ available, released: '1.19.1039' }), ['1.19.1038', '1.19.1039'], 'no follow given reads as released');
356
+ });
357
+
358
+ t('versionsForFollow: a follower of releases takes NOTHING when nobody can say what is released', () => {
359
+ const available = ['1.19.1040', '1.19.1041'];
360
+ for (const released of [null, undefined, '', 'not-a-version']) {
361
+ assert.deepEqual(uc.versionsForFollow({ follow: 'released', available, released }), [], String(released));
362
+ }
363
+ assert.deepEqual(uc.versionsForFollow({ follow: 'candidates', available, released: null }), available, 'candidates never needs the label');
364
+ });
365
+
366
+ t('the follow composes with the channel: released caps which versions exist, the channel caps the jump', () => {
367
+ const available = ['1.19.1040', '1.19.1041', '1.20.0'];
368
+ const pick = (follow, released) => uc.resolveChannelTarget({
369
+ installed: '1.19.1039', channel: 'patch', available: uc.versionsForFollow({ follow, available, released }),
370
+ });
371
+ assert.equal(pick('candidates', '1.19.1040'), '1.19.1041', 'Bongos takes the newest candidate its channel allows');
372
+ assert.equal(pick('released', '1.19.1040'), '1.19.1040', 'every other project stops at the release');
373
+ assert.equal(pick('released', '1.20.0'), '1.19.1041', 'and the patch channel still refuses the new line');
374
+ assert.equal(pick('released', null), null, 'an unreadable label moves nobody');
375
+ });
376
+
377
+ t('readReleaseTags: the released version is the latest label, read fail-soft', () => {
378
+ const seen = [];
379
+ const run = (cmd, args, o) => { seen.push([cmd, args, o.timeout]); return { status: 0, stdout: '{"latest":"1.19.1040","next":"1.19.1042"}' }; };
380
+ assert.deepEqual(uc.readReleaseTags({ run }), { ok: true, released: '1.19.1040', tags: { latest: '1.19.1040', next: '1.19.1042' } });
381
+ assert.deepEqual(seen[0].slice(0, 2), ['npm', ['view', '@bongos/core', 'dist-tags', '--json']]);
382
+ assert.ok(seen[0][2] > 0, 'the read carries a timeout');
383
+
384
+ const noLatest = uc.readReleaseTags({ run: () => ({ status: 0, stdout: '{"next":"1.19.1042"}' }) });
385
+ assert.equal(noLatest.ok, false);
386
+ assert.equal(noLatest.released, null);
387
+ const pre = uc.readReleaseTags({ run: () => ({ status: 0, stdout: '{"latest":"2.0.0-rc.1"}' }) });
388
+ assert.equal(pre.ok, false, 'a prerelease is never the release');
389
+ assert.equal(uc.readReleaseTags({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) }).ok, false);
390
+ assert.match(uc.readReleaseTags({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) }).error, /E404/);
391
+ assert.equal(uc.readReleaseTags({ run: () => ({ status: 0, stdout: 'not json' }) }).ok, false);
392
+ assert.equal(uc.readReleaseTags({ run: () => ({ status: 0, stdout: '["1.0.0"]' }) }).ok, false);
393
+ assert.equal(uc.readReleaseTags({ run: () => { throw new Error('spawn ENOENT'); } }).ok, false);
394
+ });
395
+
396
+ t('listAvailableVersions is untouched by the follow: same command, same shape', () => {
397
+ const seen = [];
398
+ const r = uc.listAvailableVersions({ run: (c, a) => { seen.push(a); return { status: 0, stdout: '["1.0.0"]' }; } });
399
+ assert.deepEqual(seen[0], ['view', '@bongos/core', 'versions', '--json']);
400
+ assert.deepEqual(r, { ok: true, versions: ['1.0.0'] });
401
+ });
402
+
403
+ t('loadSubscriptions: each entry carries a follow, released unless it says candidates', () => {
404
+ const dir = mkdtempSync(join(tmpdir(), 'uc-follow-'));
405
+ mkdirSync(join(dir, 'config'), { recursive: true });
406
+ writeFileSync(join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ instances: [
407
+ { slug: 'cloudbongos', dir: '/srv/cb', follow: 'candidates' },
408
+ { slug: 'tenant', dir: '/srv/t' },
409
+ { slug: 'typo', dir: '/srv/x', follow: 'everything' },
410
+ ] }));
411
+ const r = uc.loadSubscriptions({ instanceDir: dir });
412
+ assert.equal(r.defaultFollow, 'released');
413
+ assert.deepEqual(r.instances.map((i) => [i.slug, i.follow]), [['cloudbongos', 'candidates'], ['tenant', 'released'], ['typo', 'released']]);
414
+
415
+ writeFileSync(join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ defaultFollow: 'candidates', instances: [{ slug: 'a', dir: '/a' }, { slug: 'b', dir: '/b', follow: 'released' }] }));
416
+ const d = uc.loadSubscriptions({ instanceDir: dir });
417
+ assert.deepEqual(d.instances.map((i) => i.follow), ['candidates', 'released'], 'a roster-wide default, overridable per entry');
418
+ });
419
+
341
420
  console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
342
421
  process.exit(failed ? 1 : 0);
343
422