@bongos/core 1.24.24 → 1.24.26

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 (65) hide show
  1. package/.bongos-core.json +84 -69
  2. package/docs/copy-inventory.md +217 -212
  3. package/docs/copy-registry.json +298 -233
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +855 -852
  6. package/modules/hall-ui/public/ai-studio.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/blockers.js +3 -1
  9. package/modules/hall-ui/public/board-room.html +2 -0
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/collab.js +4 -0
  12. package/modules/hall-ui/public/commands.html +2 -2
  13. package/modules/hall-ui/public/copy-desk.html +2 -2
  14. package/modules/hall-ui/public/criterion-uat-ui.js +1 -1
  15. package/modules/hall-ui/public/deploy.html +2 -0
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +2 -0
  18. package/modules/hall-ui/public/gate.html +2 -0
  19. package/modules/hall-ui/public/goals-page.js +1 -1
  20. package/modules/hall-ui/public/goals.css +3 -2
  21. package/modules/hall-ui/public/goals.html +2 -2
  22. package/modules/hall-ui/public/goals.js +1 -1
  23. package/modules/hall-ui/public/government.html +2 -0
  24. package/modules/hall-ui/public/hall-widgets.js +9 -9
  25. package/modules/hall-ui/public/ideas.html +2 -2
  26. package/modules/hall-ui/public/index.html +2 -0
  27. package/modules/hall-ui/public/lightbulb.js +1 -1
  28. package/modules/hall-ui/public/market-watch-item.html +1 -1
  29. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  30. package/modules/hall-ui/public/market-watch-settings.html +2 -0
  31. package/modules/hall-ui/public/market-watch.html +1 -1
  32. package/modules/hall-ui/public/modules.html +1 -1
  33. package/modules/hall-ui/public/my-agents.html +2 -2
  34. package/modules/hall-ui/public/primer.html +1 -1
  35. package/modules/hall-ui/public/profile.html +5 -5
  36. package/modules/hall-ui/public/project-settings.html +2 -0
  37. package/modules/hall-ui/public/ranks.html +3 -3
  38. package/modules/hall-ui/public/roadmap.html +1 -1
  39. package/modules/hall-ui/public/roster.html +1 -1
  40. package/modules/hall-ui/public/sessions.html +2 -0
  41. package/modules/hall-ui/public/settings-software-update.js +1 -1
  42. package/modules/hall-ui/public/settings-specialities.js +1 -1
  43. package/modules/hall-ui/public/settings.html +3 -1
  44. package/modules/hall-ui/public/settings.js +3 -0
  45. package/modules/hall-ui/public/studio.js +3 -3
  46. package/modules/hall-ui/public/style.css +90 -1
  47. package/modules/hall-ui/public/task-detail.js +2 -2
  48. package/modules/hall-ui/public/tweak-editor.html +1 -1
  49. package/modules/hall-ui/public/watch.html +2 -0
  50. package/modules/hall-ui/public/watch.js +1 -1
  51. package/modules/hall-ui/public/work.html +3 -3
  52. package/modules/hall-ui/records/loader.md +35 -0
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +16 -0
  56. package/scripts/hall-preview/README.md +1 -0
  57. package/scripts/hall-preview/server.js +9 -0
  58. package/src/bongos/apex-hall-redirects.js +53 -0
  59. package/src/bongos/package-registry.js +3 -2
  60. package/src/bongos/routes/core-update.js +11 -3
  61. package/src/bongos/serve-internal.js +3 -18
  62. package/src/bongos/software-update.js +36 -16
  63. package/src/module-api.js +1 -1
  64. package/tests/hall_loading.mjs +153 -0
  65. package/tests/software_update.mjs +94 -3
@@ -0,0 +1,153 @@
1
+ // tests/hall_loading.mjs — the hall's one loader, the Bongos mark (task 1005043, goal 1000095).
2
+ //
3
+ // Owner ask, 2026-11-03: a smooth load screen with the Bongos logo anywhere the
4
+ // hall waits for data, in the spirit of monday.com's Vibe data loaders. One CSS
5
+ // component in style.css (.hall-loading) draws the mark from shell.js's
6
+ // brand-mark geometry; a page opts in by putting the class on the placeholder it
7
+ // already had. These cases read the real sheets and pages: the loader keeps
8
+ // DESIGN.md's motion and colour rules, its rest frame is the logo, and no hall
9
+ // page is left with a bare "Loading…" line or a blank wait.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
18
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
19
+ const STYLE = read('style.css');
20
+ const SHELL = read('shell.js');
21
+
22
+ // The loader's section of style.css, from its banner to the next rule family.
23
+ const START = STYLE.indexOf('/* ---- the loader (task 1005043)');
24
+ const LOADER = STYLE.slice(START, STYLE.indexOf('/* collapsible card body', START));
25
+
26
+ function keyframes(name) {
27
+ const m = STYLE.match(new RegExp(`@keyframes ${name} \\{`));
28
+ assert.ok(m, `@keyframes ${name} exists`);
29
+ let depth = 1, i = m.index + m[0].length;
30
+ for (; i < STYLE.length && depth; i++) depth += STYLE[i] === '{' ? 1 : STYLE[i] === '}' ? -1 : 0;
31
+ return STYLE.slice(m.index + m[0].length, i - 1);
32
+ }
33
+
34
+ test('the loader is drawn from the Bongos mark, in three sizes', () => {
35
+ assert.ok(START > 0 && LOADER.length > 500, 'the loader section is found in style.css');
36
+ for (const sel of ['.hall-loading {', '.hall-loading::before {', '.hall-loading::after {', '.hall-loading--page {', '.hall-loading--inline {']) {
37
+ assert.ok(LOADER.includes(sel), `${sel} is declared`);
38
+ }
39
+ // The same ring arc, core and dot the shell's brand mark draws, so the loader IS the logo.
40
+ const ring = SHELL.match(/<path class="mr" d="([^"]+)"/)[1];
41
+ assert.ok(LOADER.includes(`d='${ring}'`), 'the ring is the brand mark\'s arc');
42
+ assert.ok(SHELL.includes('<circle class="mp" cx="100" cy="100" r="55"'), 'the brand mark\'s core');
43
+ assert.ok(LOADER.includes("circle cx='100' cy='100' r='55'"), 'is the loader\'s core');
44
+ assert.ok(SHELL.includes('<circle cx="156" cy="46" r="12" fill="#fb9569"/>'), 'the brand mark\'s dot');
45
+ assert.ok(LOADER.includes("circle cx='156' cy='46' r='12'"), 'is the loader\'s dot');
46
+ });
47
+
48
+ test('it moves in CSS only, on transform and filter only, and rests on the logo', () => {
49
+ const frames = { 'hall-loading-arrive': keyframes('hall-loading-arrive'), 'hall-loading-turn': keyframes('hall-loading-turn'), 'hall-loading-breathe': keyframes('hall-loading-breathe') };
50
+ for (const [name, body] of Object.entries(frames)) {
51
+ const props = [...body.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
52
+ assert.ok(props.length, `${name} animates something`);
53
+ for (const prop of props) assert.ok(['transform', 'filter'].includes(prop), `${name} animates ${prop}, not transform or filter`);
54
+ }
55
+ // The rest frame: the turn has no `from`, so 0deg (the logo's own pose, dot top right)
56
+ // is where it starts and where the Kill Switch leaves it; the arrival and the breath
57
+ // have no `to`, so their resting value is the element as styled, fully shown.
58
+ assert.match(frames['hall-loading-turn'], /^\s*to \{ transform: rotate\(1turn\); \}\s*$/);
59
+ assert.match(frames['hall-loading-arrive'], /^\s*from \{/);
60
+ assert.match(frames['hall-loading-breathe'], /^\s*from \{/);
61
+ // The Kill Switch stops every animation, pseudo-elements included.
62
+ assert.match(STYLE, /@media \(prefers-reduced-motion: reduce\) \{\s*\*, \*::before, \*::after \{\s*animation: none !important;/);
63
+ // No motion authored in JavaScript: nothing in the hall drives the loader from script.
64
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.js'))) {
65
+ assert.doesNotMatch(read(f), /hall-loading[^'"\n]*['"]\)?\.(animate|style)\b/, `${f} does not animate the loader`);
66
+ }
67
+ });
68
+
69
+ test('every colour is a token; the black in the masks is alpha, not paint', () => {
70
+ const css = LOADER.replace(/\/\*[\s\S]*?\*\//g, '');
71
+ assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'no colour literal');
72
+ const paints = [...css.matchAll(/(?:background|color):\s*([^;]+);/g)].map((m) => m[1].trim());
73
+ assert.ok(paints.length >= 3, 'the ink, the dot and the caption are painted');
74
+ for (const p of paints) assert.match(p, /^(var\(--[a-z-]+\)|color-mix\(in srgb, var\(--ink\) \d+%, var\(--bg-card\)\))$/, `${p} is a token or a mix of one`);
75
+ // The one accent is the dot: the live thing on a waiting page (the One Warm Thing Rule).
76
+ assert.equal((css.match(/var\(--accent\)/g) || []).length, 1, 'the accent paints the dot alone');
77
+ const masks = [...css.matchAll(/url\("data:image\/svg\+xml,([^"]+)"\)/g)].map((m) => decodeURIComponent(m[1]));
78
+ assert.equal(masks.length, 2, 'two masks: the ring and core, and the dot');
79
+ for (const m of masks) assert.doesNotMatch(m.replace(/stroke='black'|fill='none'/g, ''), /fill='|stroke='|#/, 'a mask carries no colour of its own');
80
+ });
81
+
82
+ test('the page loaders step aside by themselves, and never show without :has()', () => {
83
+ assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
84
+ assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
85
+ // A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
86
+ for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'market-watch-settings', 'project-settings', 'sessions']) {
87
+ const html = read(`${page}.html`);
88
+ const main = html.slice(html.indexOf('<main id="hall-main">'));
89
+ const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
90
+ assert.match(first, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, `${page} opens its main on the page loader`);
91
+ const after = first.slice(first.indexOf('</p>') + 4);
92
+ const kids = [...after.matchAll(/^ {8}<(section|div|p|nav)\b([^>]*)>/gm)];
93
+ assert.ok(kids.length >= 2, `${page} has sections after the loader`);
94
+ for (const [, tag, attrs] of kids) assert.match(attrs, /\bhidden\b/, `${page}: a <${tag}> after the loader starts hidden, or the loader would never show`);
95
+ }
96
+ assert.match(read('index.html'), /<section class="view" id="view-home"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Home waits on its zones');
97
+ assert.match(read('watch.html'), /<main id="hall-main">\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Watch waits on its panes');
98
+ // Settings' layout is never [hidden] while it loads, so its loader is put away by hideLayout().
99
+ assert.match(read('settings.html'), /id="settings-groups"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page" id="settings-loading" role="status">/);
100
+ assert.match(read('settings.js'), /function hideLayout\(hide\) \{[\s\S]*?getElementById\('settings-loading'\)[\s\S]*?loading\.hidden = true;/);
101
+ });
102
+
103
+ test('no hall page waits on a bare line of text any more', () => {
104
+ // A placeholder an element's own script rewrites IN PLACE keeps its plain words:
105
+ // the class would stay behind on the finished sentence. Each is named with why.
106
+ const IN_PLACE = new Set([
107
+ 'atlas.html#caption', // the map's caption, rewritten by atlas.js
108
+ 'sessions.html#ledger-status', // the ledger's status line, rewritten by sessions.js
109
+ 'goals.html#goal-deps-card-lede', // the dependency card's lede, rewritten by goals-page.js
110
+ 'goals.html#goal-deps-lede',
111
+ 'profile.html#activity-placeholder', // reused for the signed-out notice by profile.js
112
+ ]);
113
+ const WAIT = /<(p|div|li|span|td)\b([^>]*)>\s*(?:Loading|Reading|Opening|Checking)\b[^<]*…\s*<\/\1>|<(p|div|li|span|td)\b([^>]*)>…<\/\4>/g;
114
+ const misses = [];
115
+ for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.html'))) {
116
+ for (const m of read(f).matchAll(WAIT)) {
117
+ const attrs = m[2] || m[5] || '';
118
+ if (/page-head__sub/.test(attrs)) continue; // the page head is hidden on every page but Home (task 1004836)
119
+ const id = (attrs.match(/\bid="([^"]+)"/) || [])[1];
120
+ if (id && IN_PLACE.has(`${f}#${id}`)) continue;
121
+ if (!/\bhall-loading\b/.test(attrs)) misses.push(`${f}: ${m[0].slice(0, 90)}`);
122
+ }
123
+ }
124
+ assert.deepEqual(misses, [], 'every waiting placeholder wears the loader');
125
+ // The table placeholder rows keep their cell, with the loader inside it.
126
+ assert.match(read('work.html'), /<td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
127
+ assert.match(read('hall-widgets.js'), /<td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
128
+ });
129
+
130
+ test('the placeholders the page scripts write wear it too, and a failure stops it', () => {
131
+ const WRITTEN = {
132
+ 'blockers.js': '<li class="hall-loading hall-loading--page" role="status">Loading…</li>',
133
+ 'goals.js': '<div class="hall-loading" role="status">Loading your goals…</div>',
134
+ 'lightbulb.js': '<p class="hall-loading" role="status">Loading…</p>',
135
+ 'task-detail.js': '<p class="hall-loading hall-loading--inline" role="status">Loading versions…</p>',
136
+ 'criterion-uat-ui.js': '<p class="hall-loading hall-loading--inline" role="status">Loading…</p>',
137
+ 'watch.js': '<p class="hall-loading" role="status">Reading…</p>',
138
+ 'studio.js': '<p class="hall-loading" role="status">Reading the reviews…</p>',
139
+ 'settings-specialities.js': '<p class="hall-loading hall-loading--inline" role="status">Loading this project’s skills…</p>',
140
+ 'settings-software-update.js': '<p class="hall-loading hall-loading--inline" role="status">Checking for a newer version…</p>',
141
+ 'goals-page.js': '<div class="hall-loading" role="status">Loading…</div>',
142
+ };
143
+ for (const [f, markup] of Object.entries(WRITTEN)) assert.ok(read(f).includes(markup), `${f} writes the loader`);
144
+ // Blockers' first paint replaces the list wholesale, so its loader cannot outlive the rows.
145
+ assert.match(read('blockers.js'), /listEl\.innerHTML = rows\.length/);
146
+ // Collab used to be blank on a failed read; with loaders in its lists it must stop them.
147
+ const collab = read('collab.js');
148
+ const failure = collab.slice(collab.lastIndexOf('load().catch('));
149
+ assert.match(failure, /\['for-me', 'mine', 'all'\]\.forEach\(\(k\) => \{ const host = \$\(`#cb-\$\{k\}`\); if \(host\) host\.innerHTML = ''; \}\);/);
150
+ // The skeleton bars and the goal list's rings breathe in the loader's beat.
151
+ assert.match(STYLE, /\.kit-skel \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
152
+ assert.match(read('goals.css'), /\.gr-skel__ring \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
153
+ });
@@ -39,11 +39,12 @@ const NOTES = {
39
39
  '1.20.0': [{ id: 200, text: 'The start of a new line.' }],
40
40
  };
41
41
 
42
- /** A registry that answers the packument and the NEWEST version's tarball. */
43
- function registry({ versions = VERSIONS, notes = NOTES, pkStatus = 200, tgzStatus = 200, tgz } = {}) {
42
+ /** A registry that answers the packument and the NEWEST version's tarball. `latest` is the
43
+ * release label (ADR 0361): every version above it is a candidate. */
44
+ function registry({ versions = VERSIONS, notes = NOTES, pkStatus = 200, tgzStatus = 200, tgz, latest } = {}) {
44
45
  const newest = versions[versions.length - 1];
45
46
  return fakeHttpsGet({
46
- [PACKUMENT_URL]: { status: pkStatus, body: npmPackument({ versions, latest: newest, times: TIMES }) },
47
+ [PACKUMENT_URL]: { status: pkStatus, body: npmPackument({ versions, latest: latest === undefined ? newest : latest, times: TIMES }) },
47
48
  [tgzUrl(newest)]: { status: tgzStatus, body: tgz || npmTarball({ notes }) },
48
49
  });
49
50
  }
@@ -148,6 +149,44 @@ test('a project far behind lists the newest forty and counts the rest', async ()
148
149
  assert.equal(u.whats_new.versions[0].version, '1.19.51');
149
150
  });
150
151
 
152
+ // ---- released vs candidate (task 1004593, ADR 0361 D5) --------------------------------
153
+ // The live defect, 2026-10-06, uattest (1.21.66, patch): npm `latest` was 1.21.66 and 1.21.69
154
+ // sat above it as a candidate. Settings offered 1.21.69; /deploy and the banner offered
155
+ // nothing. A project that follows releases must be offered what the runner would move it to.
156
+
157
+ test('a project that follows releases is offered nothing above the release label', async () => {
158
+ const reg = registry({ latest: '1.19.11' });
159
+ const u = await read('1.19.10', reg);
160
+ assert.equal(u.reading, 'ok');
161
+ assert.equal(u.follow, 'released', 'no follow given: the default, exactly as the runner');
162
+ assert.equal(u.offered, '1.19.11', '1.19.12 and 1.19.13 are candidates, never offered here');
163
+ assert.equal(u.newest, '1.19.11', 'and the newest it reports is the newest it could take, not a candidate');
164
+ assert.deepEqual(u.whats_new.versions.map((v) => v.version), ['1.19.11'], 'what\'s new stops at the offer');
165
+ });
166
+
167
+ test('the uattest case: running the released version with only candidates above it is up to date', async () => {
168
+ const reg = registry({ latest: '1.19.10' });
169
+ const u = await read('1.19.10', reg);
170
+ assert.equal(u.reading, 'ok');
171
+ assert.equal(u.offered, null, 'Settings now agrees with /deploy: nothing it can take');
172
+ assert.equal(u.whats_new, null);
173
+ assert.deepEqual(reg.asked, [PACKUMENT_URL], 'and nothing is downloaded for a version it will never offer');
174
+ });
175
+
176
+ test('the platform\'s own hall follows candidates and is offered them', async () => {
177
+ const u = await read('1.19.10', registry({ latest: '1.19.10' }), { follow: 'candidates' });
178
+ assert.equal(u.follow, 'candidates');
179
+ assert.equal(u.offered, '1.19.13');
180
+ assert.equal(u.newest, '1.20.0');
181
+ });
182
+
183
+ test('a registry with no release label is no answer for a release follower — never "up to date"', async () => {
184
+ const u = await read('1.19.10', registry({ latest: null }));
185
+ assert.equal(u.reading, 'no_answer');
186
+ assert.match(u.error, /released/);
187
+ assert.equal(u.offered, null);
188
+ });
189
+
151
190
  test('a second look within minutes asks the registry nothing new', async () => {
152
191
  const reg = registry();
153
192
  const store = fresh();
@@ -465,6 +504,39 @@ test('the platform, which runs the door, keeps /deploy behind its own page gate'
465
504
  assert.match(String(r.location), /auth|sign/i, 'and is sent to sign in, as before');
466
505
  });
467
506
 
507
+ // ── the door on the hub's apex (task 1004736) ─────────────────────────────────
508
+ //
509
+ // Every hosted project's Update button is `<hub origin>/deploy?project=<slug>` — the IdP
510
+ // origin, or HUB_UPGRADE_URL built from the hub's public origin — and on cloudbongos.com
511
+ // that origin is the apex, where /deploy was a 404 (2026-10-14). The page is the hall's,
512
+ // on builders.<apex>. The hub answers the apex address with the hall's, so every link
513
+ // already written into a project's web.env works without that project moving its core.
514
+
515
+ function getOn(port, p, host) {
516
+ return new Promise((resolve, reject) => {
517
+ const req = http.request({ host: '127.0.0.1', port, path: p, headers: { Host: host } }, (res) => { res.resume(); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location })); });
518
+ req.on('error', reject);
519
+ req.end();
520
+ });
521
+ }
522
+
523
+ test('the apex /deploy sends the owner to the hall\'s door for the same project (task 1004736)', async () => {
524
+ const port = await startServer({ [MODULE_ENV]: '1' });
525
+ for (const p of ['/deploy?project=toast', '/deploy.html?project=toast', '/deploy/?project=toast']) {
526
+ const r = await getOn(port, p, 'hall.test');
527
+ assert.equal(r.status, 302, p);
528
+ assert.equal(r.location, 'https://builders.hall.test/deploy?project=toast', `${p}: the project is carried, so the door opens on it`);
529
+ }
530
+ const bare = await getOn(port, '/deploy', 'hall.test');
531
+ assert.equal(bare.location, 'https://builders.hall.test/deploy', 'no project → the door itself');
532
+ for (const p of ['/deployments', '/deploy/x', '/api/deploy']) {
533
+ const r = await getOn(port, p, 'hall.test');
534
+ assert.notEqual(String(r.location || '').includes('builders.hall.test/deploy'), true, `${p} is not the door`);
535
+ }
536
+ const onHall = await getOn(port, '/deploy?project=toast', 'builders.hall.test');
537
+ assert.doesNotMatch(String(onHall.location || ''), /^https:\/\/builders\./, 'on the hall itself the page is served, not bounced');
538
+ });
539
+
468
540
  // ── the project's OWN update rule reaches the offer (task 1004535) ────────────
469
541
  //
470
542
  // The live defect, 2026-10-02: cloudbongos's rule on /deploy is `minor` and the sweep
@@ -510,6 +582,25 @@ test('the REAL default reads the provisioning.selfUpdateChannel port — and wit
510
582
  }
511
583
  });
512
584
 
585
+ test('the route hands the reader the follow of the hall it serves: candidates only where the door runs (task 1004593)', async () => {
586
+ const cases = [
587
+ [{ provisioning: true, idp: null }, 'candidates', 'the platform\'s own hall, the one project that takes every candidate'],
588
+ [{ provisioning: false, idp: { origin: 'https://cloudbongos.com', clientId: 'uattest' } }, 'released', 'a hosted project'],
589
+ [{ provisioning: false, idp: null }, 'released', 'a self-hosted instance'],
590
+ ];
591
+ for (const [inputs, want, what] of cases) {
592
+ let seen;
593
+ const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, canUpdate: async () => true, doorInputs: () => inputs });
594
+ assert.equal(r.status, 200, what);
595
+ assert.equal(seen.follow, want, what);
596
+ }
597
+ });
598
+
599
+ test('[source] the upgrade request asks with the same follow as the panel (task 1004593)', () => {
600
+ const seg = ROUTE_SRC.slice(ROUTE_SRC.indexOf("'/software-update/request'"), ROUTE_SRC.indexOf("'/project/manage-link'"));
601
+ assert.match(seg, /softwareUpdate\(\{ running, channel, follow: followFor\(door\) \}\)/, 'the version asked for is one the runner would take');
602
+ });
603
+
513
604
  test('[source] the port the route reads is the port the provisioning module registers and declares (task 1004535)', () => {
514
605
  const port = 'provisioning.selfUpdateChannel';
515
606
  const route = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');