@bongos/core 1.19.1041 → 1.19.1042
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +103 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
- package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +64 -7
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +41 -21
- package/docs/copy-registry.json +207 -27
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +10 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/palette.js +1 -0
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +194 -0
- package/modules/hall-ui/public/settings.css +39 -0
- package/modules/hall-ui/public/settings.html +24 -2
- package/modules/hall-ui/public/settings.js +5 -0
- package/modules/hall-ui/public/settings.states.json +2 -1
- package/modules/hall-ui/public/shell.js +25 -39
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/panel-family.md +9 -0
- package/modules/npm-release/public/work.js +8 -3
- package/modules/npm-release/work.js +8 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/main-audit.js +204 -7
- package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
- package/src/bongos/routes/core-update.js +65 -3
- package/src/bongos/serve-internal.js +17 -0
- package/src/bongos/software-update.js +127 -0
- package/src/module-api.js +10 -1
- package/tests/core_upgrade_owner_door.mjs +27 -18
- package/tests/helpers.mjs +92 -0
- package/tests/main_audit_attestation.mjs +238 -0
- package/tests/module_api.mjs +1 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/npm_release_page.mjs +11 -1
- package/tests/npm_release_work.mjs +9 -94
- package/tests/software_update.mjs +449 -0
|
@@ -15,9 +15,8 @@
|
|
|
15
15
|
import assert from 'node:assert/strict';
|
|
16
16
|
import { test } from 'node:test';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
import zlib from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
import { npmTarball, npmPackument, fakeHttpsGet } from './helpers.mjs';
|
|
21
20
|
|
|
22
21
|
const require = createRequire(import.meta.url);
|
|
23
22
|
const { readWork } = require('../modules/npm-release/work.js');
|
|
@@ -26,90 +25,6 @@ const buildRouter = require('../modules/npm-release/routes/work.js');
|
|
|
26
25
|
const ORIGIN = 'https://registry.npmjs.org';
|
|
27
26
|
const PKG = '@bongos/core';
|
|
28
27
|
|
|
29
|
-
// ---- a real tarball, built by hand ---------------------------------------------------
|
|
30
|
-
|
|
31
|
-
function tarHeader(name, size, type = '0') {
|
|
32
|
-
const h = Buffer.alloc(512, 0);
|
|
33
|
-
h.write(name.slice(0, 100), 0, 'utf8');
|
|
34
|
-
h.write('0000644\0', 100, 'ascii');
|
|
35
|
-
h.write('0000000\0', 108, 'ascii');
|
|
36
|
-
h.write('0000000\0', 116, 'ascii');
|
|
37
|
-
h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
|
|
38
|
-
h.write('00000000000\0', 136, 'ascii');
|
|
39
|
-
h.write(' ', 148, 'ascii');
|
|
40
|
-
h.write(type, 156, 'ascii');
|
|
41
|
-
h.write('ustar\0', 257, 'ascii');
|
|
42
|
-
h.write('00', 263, 'ascii');
|
|
43
|
-
let sum = 0;
|
|
44
|
-
for (const b of h) sum += b;
|
|
45
|
-
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
|
|
46
|
-
return h;
|
|
47
|
-
}
|
|
48
|
-
function tarEntry(name, data, type = '0') {
|
|
49
|
-
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
50
|
-
const pad = Buffer.alloc((512 - (body.length % 512)) % 512, 0);
|
|
51
|
-
return Buffer.concat([tarHeader(name, body.length, type), body, pad]);
|
|
52
|
-
}
|
|
53
|
-
function paxEntry(path) {
|
|
54
|
-
// A pax record is "<len> <key>=<value>\n", where <len> counts its own digits too.
|
|
55
|
-
const rec = (k, v) => {
|
|
56
|
-
const body = ` ${k}=${v}\n`;
|
|
57
|
-
let n = body.length + 1;
|
|
58
|
-
while (String(n).length + body.length !== n) n = String(n).length + body.length;
|
|
59
|
-
return `${n}${body}`;
|
|
60
|
-
};
|
|
61
|
-
return tarEntry('PaxHeader/x', rec('path', path), 'x');
|
|
62
|
-
}
|
|
63
|
-
/** A gzipped npm-shaped tarball. `notes` null leaves release-notes.json out. */
|
|
64
|
-
function tarball({ notes, padBytes = 40000, pax = false } = {}) {
|
|
65
|
-
const parts = [
|
|
66
|
-
tarEntry('package/package.json', JSON.stringify({ name: PKG })),
|
|
67
|
-
// A large file BEFORE the notes, so the reader has to skip across many chunks.
|
|
68
|
-
tarEntry('package/big.js', Buffer.alloc(padBytes, 97)),
|
|
69
|
-
];
|
|
70
|
-
if (notes) {
|
|
71
|
-
const json = JSON.stringify(notes);
|
|
72
|
-
if (pax) parts.push(paxEntry('package/release-notes.json'), tarEntry('package/SHORTNAME', json));
|
|
73
|
-
else parts.push(tarEntry('package/release-notes.json', json));
|
|
74
|
-
}
|
|
75
|
-
parts.push(Buffer.alloc(1024, 0));
|
|
76
|
-
return zlib.gzipSync(Buffer.concat(parts));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ---- a fake registry -----------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
function packument({ versions, latest, times }) {
|
|
82
|
-
const doc = { name: PKG, 'dist-tags': { latest }, versions: {}, time: {} };
|
|
83
|
-
for (const v of versions) {
|
|
84
|
-
doc.versions[v] = { version: v, dist: { tarball: `${ORIGIN}/@bongos/core/-/core-${v}.tgz` } };
|
|
85
|
-
doc.time[v] = times[v];
|
|
86
|
-
}
|
|
87
|
-
return doc;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** `get` answering from `routes` (url → { status, body }), recording every URL asked for. */
|
|
91
|
-
function fakeGet(routes) {
|
|
92
|
-
const asked = [];
|
|
93
|
-
const get = (url, opts, cb) => {
|
|
94
|
-
asked.push(url);
|
|
95
|
-
const req = new EventEmitter();
|
|
96
|
-
req.destroy = () => {};
|
|
97
|
-
setImmediate(() => {
|
|
98
|
-
const r = routes[url];
|
|
99
|
-
if (!r) { req.emit('error', new Error(`no route for ${url}`)); return; }
|
|
100
|
-
const body = Buffer.isBuffer(r.body) ? r.body : Buffer.from(typeof r.body === 'string' ? r.body : JSON.stringify(r.body));
|
|
101
|
-
// Small chunks, so a tar header lands across a chunk boundary.
|
|
102
|
-
const chunks = [];
|
|
103
|
-
for (let i = 0; i < body.length; i += 777) chunks.push(body.subarray(i, i + 777));
|
|
104
|
-
const res = Readable.from(chunks);
|
|
105
|
-
res.statusCode = r.status || 200;
|
|
106
|
-
cb(res);
|
|
107
|
-
});
|
|
108
|
-
return req;
|
|
109
|
-
};
|
|
110
|
-
return { get, asked };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
28
|
function fakePool(tasks, titles = {}) {
|
|
114
29
|
const queries = [];
|
|
115
30
|
return {
|
|
@@ -153,13 +68,13 @@ const TASKS = [
|
|
|
153
68
|
row('23', 'shipped', '2026-09-24T08:00:00.000Z', { value_summary: 'A hand-landed change nobody numbered.' }),
|
|
154
69
|
];
|
|
155
70
|
|
|
156
|
-
function setup({ latest = '1.0.5', tgz =
|
|
71
|
+
function setup({ latest = '1.0.5', tgz = npmTarball({ notes: NOTES }), pkStatus = 200, tgzStatus = 200, installed = INSTALLED_TO_103, tasks = TASKS, titles = {} } = {}) {
|
|
157
72
|
const versions = Object.keys(TIMES);
|
|
158
73
|
const routes = {
|
|
159
|
-
[`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ?
|
|
74
|
+
[`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ? npmPackument({ versions, latest, times: TIMES }) : 'nope' },
|
|
160
75
|
[`${ORIGIN}/@bongos/core/-/core-1.0.5.tgz`]: { status: tgzStatus, body: tgz },
|
|
161
76
|
};
|
|
162
|
-
const fg =
|
|
77
|
+
const fg = fakeHttpsGet(routes);
|
|
163
78
|
const pool = fakePool(tasks, titles);
|
|
164
79
|
const store = freshStore();
|
|
165
80
|
const read = (over = {}) => readWork({ pool, pkg: PKG, live: '1.0.3', get: fg.get, store, installedNotes: installed, now: () => Date.parse('2026-09-26T13:00:00Z'), ...over });
|
|
@@ -223,7 +138,7 @@ test('notes past the installed copy are read out of the newest package', async (
|
|
|
223
138
|
});
|
|
224
139
|
|
|
225
140
|
test('a pax long-name header is followed to the notes file', async () => {
|
|
226
|
-
const d = await setup({ tgz:
|
|
141
|
+
const d = await setup({ tgz: npmTarball({ notes: NOTES, pax: true }) }).read();
|
|
227
142
|
assert.equal(d.notes.covers_to, '1.0.5');
|
|
228
143
|
assert.deepEqual(ids(d.stages.not_live), ['15']);
|
|
229
144
|
});
|
|
@@ -236,7 +151,7 @@ test('when the installed notes already reach the newest version, nothing is down
|
|
|
236
151
|
});
|
|
237
152
|
|
|
238
153
|
test('a package without notes is an error, not an empty history', async () => {
|
|
239
|
-
const d = await setup({ tgz:
|
|
154
|
+
const d = await setup({ tgz: npmTarball({ notes: null }) }).read();
|
|
240
155
|
assert.match(d.notes.error, /without release notes/);
|
|
241
156
|
// The installed copy stands in: the work up to 1.0.3 is still placed.
|
|
242
157
|
assert.deepEqual(d.notes.source, { from: 'installed', version: '1.0.3' });
|
|
@@ -262,9 +177,9 @@ test('a registry that does not answer is reported, and the installed notes still
|
|
|
262
177
|
|
|
263
178
|
test('the package download is only ever followed on the registry\'s own origin', async () => {
|
|
264
179
|
const versions = Object.keys(TIMES);
|
|
265
|
-
const doc =
|
|
180
|
+
const doc = npmPackument({ versions, latest: '1.0.5', times: TIMES });
|
|
266
181
|
doc.versions['1.0.5'].dist.tarball = 'https://evil.example/core.tgz';
|
|
267
|
-
const fg =
|
|
182
|
+
const fg = fakeHttpsGet({ [`${ORIGIN}/@bongos%2fcore`]: { body: doc } });
|
|
268
183
|
const d = await readWork({ pool: fakePool(TASKS), pkg: PKG, live: '1.0.3', get: fg.get, store: freshStore(), installedNotes: INSTALLED_TO_103 });
|
|
269
184
|
assert.ok(!fg.asked.some((u) => u.includes('evil.example')));
|
|
270
185
|
assert.match(d.notes.error, /no download/);
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// tests/software_update.mjs — Settings → Software update (task 1004296): the core's reading
|
|
2
|
+
// (src/bongos/software-update.js over the real registry reader), its route, the panel
|
|
3
|
+
// script executed in a DOM stub, and the /deploy page answering only where the door is.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS AT RISK. The panel is trusted the way a phone's Software Update screen is: "up to
|
|
6
|
+
// date" must only ever be said from an answer, never from a failed read; the versions it
|
|
7
|
+
// offers must be exactly the ones the upgrade runner would take (its channel rule, not a
|
|
8
|
+
// copy); what's new must come from the package's release notes and say so when they could
|
|
9
|
+
// not be read; and the Update button must be drawn only for the people the door lets
|
|
10
|
+
// through, pointing only at that door.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/software_update.mjs
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test, after } from 'node:test';
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { npmTarball, npmPackument, fakeHttpsGet } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { readSoftwareUpdate } = require('../src/bongos/software-update.js');
|
|
27
|
+
|
|
28
|
+
const ORIGIN = 'https://registry.npmjs.org';
|
|
29
|
+
const PACKUMENT_URL = `${ORIGIN}/@bongos%2fcore`;
|
|
30
|
+
const tgzUrl = (v) => `${ORIGIN}/@bongos/core/-/core-${v}.tgz`;
|
|
31
|
+
|
|
32
|
+
const VERSIONS = ['1.19.10', '1.19.11', '1.19.12', '1.19.13', '1.20.0'];
|
|
33
|
+
const TIMES = Object.fromEntries(VERSIONS.map((v, i) => [v, `2026-09-2${i}T10:00:00.000Z`]));
|
|
34
|
+
const NOTES = {
|
|
35
|
+
'1.19.10': [{ id: 90, text: 'Already running.' }],
|
|
36
|
+
'1.19.11': [{ id: 101, text: 'Fixed the first thing.' }],
|
|
37
|
+
'1.19.12': [],
|
|
38
|
+
'1.19.13': [{ id: '103', text: 'A <b>third</b> thing.' }, { text: 'A merge that named no task.' }],
|
|
39
|
+
'1.20.0': [{ id: 200, text: 'The start of a new line.' }],
|
|
40
|
+
};
|
|
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 } = {}) {
|
|
44
|
+
const newest = versions[versions.length - 1];
|
|
45
|
+
return fakeHttpsGet({
|
|
46
|
+
[PACKUMENT_URL]: { status: pkStatus, body: npmPackument({ versions, latest: newest, times: TIMES }) },
|
|
47
|
+
[tgzUrl(newest)]: { status: tgzStatus, body: tgz || npmTarball({ notes }) },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const fresh = () => ({ packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } });
|
|
51
|
+
const read = (running, reg = registry(), extra = {}) => readSoftwareUpdate({ running, get: reg.get, store: fresh(), now: () => Date.parse('2026-09-26T12:00:00Z'), ...extra });
|
|
52
|
+
|
|
53
|
+
// ---- the reading ---------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
test('behind: offers the newest version its channel takes, never the next line', async () => {
|
|
56
|
+
const u = await read('1.19.10');
|
|
57
|
+
assert.equal(u.reading, 'ok');
|
|
58
|
+
assert.equal(u.running, '1.19.10');
|
|
59
|
+
assert.equal(u.channel, 'patch', 'no channel given: the runner\'s default, exactly as the banner');
|
|
60
|
+
assert.equal(u.offered, '1.19.13', 'the patch channel stays within 1.19');
|
|
61
|
+
assert.equal(u.newest, '1.20.0', 'the newer line is reported, so the panel can say why it is not offered');
|
|
62
|
+
assert.equal(u.offered_published_at, TIMES['1.19.13']);
|
|
63
|
+
assert.equal(u.checked_at, '2026-09-26T12:00:00.000Z');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("what's new: every version in the update, newest first, from the package's own notes", async () => {
|
|
67
|
+
const reg = registry();
|
|
68
|
+
const u = await read('1.19.10', reg);
|
|
69
|
+
const w = u.whats_new;
|
|
70
|
+
assert.equal(w.ok, true);
|
|
71
|
+
assert.equal(w.count, 3);
|
|
72
|
+
assert.deepEqual(w.versions.map((v) => v.version), ['1.19.13', '1.19.12', '1.19.11'], 'not the running one, not past the offer');
|
|
73
|
+
assert.deepEqual(w.versions[0].entries, [{ id: '103', text: 'A <b>third</b> thing.' }, { id: null, text: 'A merge that named no task.' }],
|
|
74
|
+
'text is carried as written (the page escapes it); a merge with no task keeps its line');
|
|
75
|
+
assert.deepEqual(w.versions[1].entries, [], 'a version with nothing listed is empty, not missing');
|
|
76
|
+
assert.deepEqual(w.versions[2].entries, [{ id: '101', text: 'Fixed the first thing.' }]);
|
|
77
|
+
assert.equal(w.versions[0].published_at, TIMES['1.19.13']);
|
|
78
|
+
assert.ok(reg.asked.includes(tgzUrl('1.20.0')), 'the newest package carries the whole history, so it is the one read');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('up to date: said only from an answer, and nothing is downloaded', async () => {
|
|
82
|
+
const reg = registry();
|
|
83
|
+
const u = await read('1.20.0', reg);
|
|
84
|
+
assert.equal(u.reading, 'ok');
|
|
85
|
+
assert.equal(u.offered, null);
|
|
86
|
+
assert.equal(u.whats_new, null);
|
|
87
|
+
assert.deepEqual(reg.asked, [PACKUMENT_URL], 'no tarball when nothing newer is published');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a registry that does not answer is a failed check — never "up to date"', async () => {
|
|
91
|
+
const u = await read('1.19.10', registry({ pkStatus: 500 }));
|
|
92
|
+
assert.equal(u.reading, 'no_answer');
|
|
93
|
+
assert.match(u.error, /500/);
|
|
94
|
+
assert.equal(u.offered, null);
|
|
95
|
+
assert.equal(u.whats_new, null);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a project that cannot name its own version gets no verdict', async () => {
|
|
99
|
+
for (const running of [null, '', 'not-a-version']) {
|
|
100
|
+
const u = await read(running);
|
|
101
|
+
assert.equal(u.reading, 'no_answer', String(running));
|
|
102
|
+
assert.match(u.error, /version it runs/);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('notes that could not be read say so, and the versions are still listed', async () => {
|
|
107
|
+
const noNotes = await read('1.19.10', registry({ tgz: npmTarball({ notes: null }) }));
|
|
108
|
+
assert.equal(noNotes.offered, '1.19.13', 'the offer does not depend on the notes');
|
|
109
|
+
assert.equal(noNotes.whats_new.ok, false);
|
|
110
|
+
assert.match(noNotes.whats_new.error, /without release notes/);
|
|
111
|
+
assert.deepEqual(noNotes.whats_new.versions.map((v) => v.entries), [null, null, null], 'unread, not empty');
|
|
112
|
+
|
|
113
|
+
const failed = await read('1.19.10', registry({ tgzStatus: 404 }));
|
|
114
|
+
assert.equal(failed.whats_new.ok, false);
|
|
115
|
+
assert.match(failed.whats_new.error, /could not download/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a version the notes do not list is missing, not empty', async () => {
|
|
119
|
+
const notes = { ...NOTES };
|
|
120
|
+
delete notes['1.19.11'];
|
|
121
|
+
const u = await read('1.19.10', registry({ notes }));
|
|
122
|
+
const v = u.whats_new.versions.find((x) => x.version === '1.19.11');
|
|
123
|
+
assert.equal(v.entries, null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('every line off the network is re-checked: bounded, blank dropped, ids digits only', async () => {
|
|
127
|
+
const long = 'x'.repeat(900);
|
|
128
|
+
const notes = { ...NOTES, '1.19.11': [{ id: 7, text: long }, { id: 8, text: ' ' }, { id: '1; DROP', text: 'odd id' }, null] };
|
|
129
|
+
const u = await read('1.19.10', registry({ notes }));
|
|
130
|
+
const e = u.whats_new.versions.find((x) => x.version === '1.19.11').entries;
|
|
131
|
+
assert.equal(e.length, 2);
|
|
132
|
+
assert.equal(e[0].text.length, 400);
|
|
133
|
+
assert.ok(e[0].text.endsWith('…'));
|
|
134
|
+
assert.deepEqual(e[1], { id: null, text: 'odd id' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a project far behind lists the newest forty and counts the rest', async () => {
|
|
138
|
+
const versions = Array.from({ length: 52 }, (_, i) => `1.19.${i}`);
|
|
139
|
+
const notes = Object.fromEntries(versions.map((v) => [v, [{ id: 1, text: v }]]));
|
|
140
|
+
const reg = fakeHttpsGet({
|
|
141
|
+
[PACKUMENT_URL]: { body: npmPackument({ versions, latest: '1.19.51', times: {} }) },
|
|
142
|
+
[tgzUrl('1.19.51')]: { body: npmTarball({ notes }) },
|
|
143
|
+
});
|
|
144
|
+
const u = await read('1.19.1', reg);
|
|
145
|
+
assert.equal(u.whats_new.count, 50);
|
|
146
|
+
assert.equal(u.whats_new.versions.length, 40);
|
|
147
|
+
assert.equal(u.whats_new.more, 10);
|
|
148
|
+
assert.equal(u.whats_new.versions[0].version, '1.19.51');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('a second look within minutes asks the registry nothing new', async () => {
|
|
152
|
+
const reg = registry();
|
|
153
|
+
const store = fresh();
|
|
154
|
+
const now = () => Date.parse('2026-09-26T12:00:00Z');
|
|
155
|
+
await readSoftwareUpdate({ running: '1.19.10', get: reg.get, store, now });
|
|
156
|
+
const n = reg.asked.length;
|
|
157
|
+
const again = await readSoftwareUpdate({ running: '1.19.10', get: reg.get, store, now });
|
|
158
|
+
assert.equal(reg.asked.length, n);
|
|
159
|
+
assert.equal(again.offered, '1.19.13');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---- the route -----------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
|
|
165
|
+
|
|
166
|
+
test('the route is open to every signed-in builder — and only the banner reads stay on the atom', () => {
|
|
167
|
+
const seg = ROUTE_SRC.slice(ROUTE_SRC.indexOf("'/software-update'"), ROUTE_SRC.indexOf('asyncHandler(\'GET /software-update\''));
|
|
168
|
+
assert.match(seg, /auth\.requireBuilder/);
|
|
169
|
+
assert.doesNotMatch(seg, /requirePermission/, 'the version and what is new are for everyone who can open Settings');
|
|
170
|
+
for (const p of ["'/core-update'", "'/core-upgrades'"]) {
|
|
171
|
+
const s = ROUTE_SRC.slice(ROUTE_SRC.indexOf(p), ROUTE_SRC.indexOf(p) + 200);
|
|
172
|
+
assert.match(s, /requirePermission\('core\.pin\.move'\)/, `${p} keeps its gate`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
async function drive(deps) {
|
|
177
|
+
const auth = require('../src/bongos/auth.js');
|
|
178
|
+
const saved = auth.requireBuilder;
|
|
179
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: '7' }; next(); };
|
|
180
|
+
const build = require('../src/bongos/routes/core-update.js');
|
|
181
|
+
const express = require('express');
|
|
182
|
+
const app = express();
|
|
183
|
+
app.use(build(deps));
|
|
184
|
+
auth.requireBuilder = saved;
|
|
185
|
+
const server = app.listen(0);
|
|
186
|
+
await new Promise((r) => server.once('listening', r));
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/software-update`);
|
|
189
|
+
return { status: res.status, body: await res.json() };
|
|
190
|
+
} finally { server.close(); }
|
|
191
|
+
}
|
|
192
|
+
const STUB_UPDATE = { reading: 'ok', running: '1.19.10', offered: '1.19.13' };
|
|
193
|
+
|
|
194
|
+
test('the platform\'s hall: the Update button goes to its own door', async () => {
|
|
195
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
|
|
196
|
+
assert.equal(r.status, 200);
|
|
197
|
+
assert.deepEqual(r.body.update, STUB_UPDATE);
|
|
198
|
+
assert.deepEqual(r.body.door, { kind: 'local', url: '/deploy' });
|
|
199
|
+
assert.equal(r.body.can_update, true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a hosted project: the Update button goes to the hub\'s door for THIS project', async () => {
|
|
203
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
|
|
204
|
+
doorInputs: () => ({ provisioning: false, idp: { origin: 'https://cloudbongos.com/', clientId: 'hoodify-finance' } }) });
|
|
205
|
+
assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=hoodify-finance' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a self-hosted instance has no door to link, and says how it is upgraded instead', async () => {
|
|
209
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
|
|
210
|
+
assert.deepEqual(r.body.door, { kind: 'cli', url: null });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('can_update is strictly the answer "yes" — anything else draws no button', async () => {
|
|
214
|
+
for (const answer of [false, 'yes', 1, null, undefined]) {
|
|
215
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => answer, doorInputs: () => ({ provisioning: true }) });
|
|
216
|
+
assert.equal(r.body.can_update, false, String(answer));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a reading that throws is a 500 with its own code, not a verdict', async () => {
|
|
221
|
+
const r = await drive({ softwareUpdate: async () => { throw new Error('boom'); }, canUpdate: async () => true, doorInputs: () => ({ provisioning: true }) });
|
|
222
|
+
assert.equal(r.status, 500);
|
|
223
|
+
assert.equal(r.body.error, 'software_update_failed');
|
|
224
|
+
assert.equal(r.body.message, undefined, 'the internal message is not handed to the page');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- the panel -----------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
|
|
230
|
+
// The fixture the hall-preview harness serves — a reading taken off the live registry for
|
|
231
|
+
// the version cloudbongos.com served on 2026-09-26.
|
|
232
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/software-update.json'), 'utf8'));
|
|
233
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
234
|
+
// Voice rule V4: quoted data renders with its dashes as commas (docs/voice.md).
|
|
235
|
+
const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
|
|
236
|
+
|
|
237
|
+
function bootPanel(answer, { active = 'update' } = {}) {
|
|
238
|
+
const el = () => ({ hidden: true, textContent: '', _html: '', dataset: {}, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, querySelectorAll: () => [] });
|
|
239
|
+
const els = new Map([['software-update-scroll', el()], ['su-body', el()], ['su-note', el()], ['settings-groups', el()]]);
|
|
240
|
+
els.get('settings-groups').dataset.active = active;
|
|
241
|
+
const listeners = {};
|
|
242
|
+
const asked = [];
|
|
243
|
+
const assigned = [];
|
|
244
|
+
const documentObj = {
|
|
245
|
+
getElementById: (id) => els.get(id) || null,
|
|
246
|
+
addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
|
|
247
|
+
};
|
|
248
|
+
const windowObj = {
|
|
249
|
+
BongosClient: { createClient: () => ({ request: async (m, url) => { asked.push(url); return answer(); } }) },
|
|
250
|
+
OTB: { escapeHtml },
|
|
251
|
+
location: { assign: (u) => assigned.push(u) },
|
|
252
|
+
};
|
|
253
|
+
const sandbox = { window: windowObj, document: documentObj, console, setTimeout, Date, Math, URL, Promise, String, Number };
|
|
254
|
+
vm.createContext(sandbox);
|
|
255
|
+
vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
|
|
256
|
+
const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
257
|
+
const open = (group = 'update') => { for (const fn of listeners['settings:group'] || []) fn({ detail: { group } }); };
|
|
258
|
+
return { els, asked, assigned, settle, open, body: () => els.get('su-body').innerHTML, note: () => els.get('su-note').textContent };
|
|
259
|
+
}
|
|
260
|
+
const ok = (data) => ({ ok: true, status: 200, data });
|
|
261
|
+
|
|
262
|
+
test('the live reading: the running version, the offer, the button, and every version named', async () => {
|
|
263
|
+
const b = bootPanel(() => ok(FIXTURE));
|
|
264
|
+
await b.settle();
|
|
265
|
+
const html = b.body();
|
|
266
|
+
assert.equal(b.els.get('software-update-scroll').hidden, false);
|
|
267
|
+
assert.ok(html.includes(FIXTURE.update.running));
|
|
268
|
+
assert.match(html, new RegExp(`id="su-update" href="/deploy">Update to ${FIXTURE.update.offered.replace(/\./g, '\\.')}<`));
|
|
269
|
+
assert.match(html, /Update ready/);
|
|
270
|
+
for (const v of FIXTURE.update.whats_new.versions) {
|
|
271
|
+
assert.ok(html.includes(`>${v.version}<`), v.version);
|
|
272
|
+
for (const e of v.entries) assert.ok(html.includes(escapeHtml(unDash(e.text))), `${v.version}: ${e.text.slice(0, 30)}`);
|
|
273
|
+
}
|
|
274
|
+
assert.match(b.note(), /^Checked /);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('read only when its group opens — most visits to Settings are for something else', async () => {
|
|
278
|
+
const b = bootPanel(() => ok(FIXTURE), { active: 'account' });
|
|
279
|
+
await b.settle();
|
|
280
|
+
assert.deepEqual(b.asked, [], 'nothing asked while another group is open');
|
|
281
|
+
b.open('sound');
|
|
282
|
+
await b.settle();
|
|
283
|
+
assert.deepEqual(b.asked, []);
|
|
284
|
+
b.open('update');
|
|
285
|
+
b.open('update');
|
|
286
|
+
await b.settle();
|
|
287
|
+
assert.deepEqual(b.asked, ['/api/bongos/software-update'], 'asked once when it opens, however often');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const answerWith = (over) => ok({ ...FIXTURE, ...over, update: { ...FIXTURE.update, ...(over.update || {}) } });
|
|
291
|
+
|
|
292
|
+
test('a hosted project\'s button names the hub it opens; a self-hosted one names the command', async () => {
|
|
293
|
+
const hub = bootPanel(() => answerWith({ door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' } }));
|
|
294
|
+
await hub.settle();
|
|
295
|
+
assert.match(hub.body(), /href="https:\/\/cloudbongos\.com\/deploy\?project=mercury">Update to [\d.]+ ↗</);
|
|
296
|
+
assert.match(hub.body(), /on cloudbongos\.com:/);
|
|
297
|
+
|
|
298
|
+
const cli = bootPanel(() => answerWith({ door: { kind: 'cli', url: null } }));
|
|
299
|
+
await cli.settle();
|
|
300
|
+
assert.doesNotMatch(cli.body(), /id="su-update"/);
|
|
301
|
+
assert.match(cli.body(), /<code>bongos upgrade<\/code>/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('no button for a viewer the door would refuse, or for a door that is not a plain address', async () => {
|
|
305
|
+
const viewer = bootPanel(() => answerWith({ can_update: false }));
|
|
306
|
+
await viewer.settle();
|
|
307
|
+
assert.doesNotMatch(viewer.body(), /id="su-update"/);
|
|
308
|
+
assert.match(viewer.body(), /Only the people allowed to upgrade/);
|
|
309
|
+
assert.ok(viewer.body().includes(escapeHtml(unDash(FIXTURE.update.whats_new.versions[0].entries[0].text))), 'what\'s new is for everyone');
|
|
310
|
+
|
|
311
|
+
for (const url of ['javascript:alert(1)', '//evil.test/x', 'https://x.test/"><img>']) {
|
|
312
|
+
const b = bootPanel(() => answerWith({ door: { kind: 'hub', url } }));
|
|
313
|
+
await b.settle();
|
|
314
|
+
assert.doesNotMatch(b.body(), /id="su-update"/, url);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('up to date is drawn only from an ok reading with nothing offered', async () => {
|
|
319
|
+
const b = bootPanel(() => answerWith({ update: { offered: null, whats_new: null, newest: FIXTURE.update.running } }));
|
|
320
|
+
await b.settle();
|
|
321
|
+
assert.match(b.body(), /Up to date/);
|
|
322
|
+
assert.doesNotMatch(b.body(), /id="su-update"|What's new/);
|
|
323
|
+
|
|
324
|
+
const failed = bootPanel(() => answerWith({ update: { reading: 'no_answer', error: 'the registry answered 503', offered: null, whats_new: null } }));
|
|
325
|
+
await failed.settle();
|
|
326
|
+
assert.doesNotMatch(failed.body(), /Up to date/);
|
|
327
|
+
assert.match(failed.body(), /Could not check for a newer version: the registry answered 503/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a newer line the channel does not take is explained, once, in the note', async () => {
|
|
331
|
+
const b = bootPanel(() => answerWith({ update: { running: '1.19.13', offered: null, whats_new: null, newest: '1.20.0' } }));
|
|
332
|
+
await b.settle();
|
|
333
|
+
assert.match(b.note(), /1\.20\.0 is also published; it starts a new line, and this project takes updates within 1\.19/);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('unread notes are reported as unread, never as versions with no changes', async () => {
|
|
337
|
+
const w = { ok: false, error: '1.19.1040 was published without release notes', count: 2, more: 0,
|
|
338
|
+
versions: [{ version: '1.19.1040', published_at: null, entries: null }, { version: '1.19.1039', published_at: null, entries: null }] };
|
|
339
|
+
const b = bootPanel(() => answerWith({ update: { whats_new: w } }));
|
|
340
|
+
await b.settle();
|
|
341
|
+
assert.match(b.body(), /The release notes could not be read: 1\.19\.1040 was published without release notes/);
|
|
342
|
+
assert.match(b.body(), /Its notes could not be read/);
|
|
343
|
+
assert.doesNotMatch(b.body(), /No changes are listed/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('task links only where the ids belong — the hall that runs the door — and text is escaped', async () => {
|
|
347
|
+
const w = { ok: true, error: null, count: 1, more: 0,
|
|
348
|
+
versions: [{ version: '1.19.1040', published_at: null, entries: [{ id: '42', text: '<img src=x onerror=alert(1)>' }, { id: null, text: 'no task' }] }] };
|
|
349
|
+
const local = bootPanel(() => answerWith({ update: { whats_new: w } }));
|
|
350
|
+
await local.settle();
|
|
351
|
+
assert.match(local.body(), /<a href="\/task\/42"><img src=x onerror=alert\(1\)><\/a>/);
|
|
352
|
+
assert.match(local.body(), /<li>no task<\/li>/);
|
|
353
|
+
|
|
354
|
+
const hub = bootPanel(() => answerWith({ door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=m' }, update: { whats_new: w } }));
|
|
355
|
+
await hub.settle();
|
|
356
|
+
assert.doesNotMatch(hub.body(), /\/task\/42/, 'a hosted project\'s hall has no such task');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a release-note line is quoted ledger data: its dashes render as commas (V4)', async () => {
|
|
360
|
+
const w = { ok: true, error: null, count: 1, more: 0,
|
|
361
|
+
versions: [{ version: '1.19.1040', published_at: null, entries: [{ id: null, text: 'Lists every task — not merged, published – or released' }] }] };
|
|
362
|
+
const b = bootPanel(() => answerWith({ update: { whats_new: w } }));
|
|
363
|
+
await b.settle();
|
|
364
|
+
assert.match(b.body(), /<li>Lists every task, not merged, published, or released<\/li>/);
|
|
365
|
+
assert.doesNotMatch(b.body().slice(b.body().indexOf('su-entries')), /[—–]/, 'no dash survives in the quoted lines');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('a long update opens its newest five and folds the rest behind one button', async () => {
|
|
369
|
+
const versions = Array.from({ length: 8 }, (_, i) => ({ version: `1.19.${100 - i}`, published_at: null, entries: [] }));
|
|
370
|
+
const b = bootPanel(() => answerWith({ update: { whats_new: { ok: true, error: null, count: 12, more: 4, versions } } }));
|
|
371
|
+
await b.settle();
|
|
372
|
+
assert.equal((b.body().match(/<li class="su-version" hidden>/g) || []).length, 3);
|
|
373
|
+
assert.match(b.body(), /Show 3 more versions/);
|
|
374
|
+
assert.match(b.body(), /…and 4 older versions, also in this update/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('a failed read says so in the panel and may be asked again', async () => {
|
|
378
|
+
let n = 0;
|
|
379
|
+
const b = bootPanel(() => { n++; return { ok: false, status: 500, data: { error: 'software_update_failed' } }; });
|
|
380
|
+
await b.settle();
|
|
381
|
+
assert.match(b.body(), /Could not check for updates/);
|
|
382
|
+
b.open('update');
|
|
383
|
+
await b.settle();
|
|
384
|
+
assert.equal(n, 2);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ---- /deploy exists only where the door does -----------------------------------------
|
|
388
|
+
//
|
|
389
|
+
// A REAL REQUEST, in two processes: whether the provisioning module is on is read from the
|
|
390
|
+
// environment at require time, so each shape boots its own server (the
|
|
391
|
+
// tests/single_host_apex_routes.mjs harness). node:http, not fetch — a redirect must be read,
|
|
392
|
+
// not followed, and fetch cannot send a Host header at all.
|
|
393
|
+
|
|
394
|
+
const SERVER_SRC = `
|
|
395
|
+
const express = require('express');
|
|
396
|
+
const { mountInternalSurfaces } = require(${JSON.stringify(path.join(ROOT, 'src/bongos/serve-internal.js'))});
|
|
397
|
+
const app = express();
|
|
398
|
+
mountInternalSurfaces(app);
|
|
399
|
+
const s = app.listen(0, () => console.log('PORT ' + s.address().port));
|
|
400
|
+
`;
|
|
401
|
+
const children = [];
|
|
402
|
+
after(() => { for (const c of children) c.kill(); });
|
|
403
|
+
|
|
404
|
+
function startServer(env) {
|
|
405
|
+
return new Promise((resolve, reject) => {
|
|
406
|
+
const child = spawn(process.execPath, ['-e', SERVER_SRC], { cwd: ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
407
|
+
children.push(child);
|
|
408
|
+
let out = '';
|
|
409
|
+
let err = '';
|
|
410
|
+
const timer = setTimeout(() => reject(new Error(`server did not start; stderr: ${err}`)), 30000);
|
|
411
|
+
child.stdout.setEncoding('utf8');
|
|
412
|
+
child.stderr.setEncoding('utf8');
|
|
413
|
+
child.stderr.on('data', (c) => { err += c; });
|
|
414
|
+
child.stdout.on('data', (c) => {
|
|
415
|
+
out += c;
|
|
416
|
+
const m = out.match(/PORT (\d+)/);
|
|
417
|
+
if (m) { clearTimeout(timer); resolve(Number(m[1])); }
|
|
418
|
+
});
|
|
419
|
+
child.on('exit', (code) => { if (!/PORT \d+/.test(out)) { clearTimeout(timer); reject(new Error(`server exited ${code}: ${err}`)); } });
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// Asked on the hall's own host, where the page lives at /deploy (the builders shim roots it).
|
|
423
|
+
function get(port, p) {
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const req = http.request({ host: '127.0.0.1', port, path: p, headers: { Host: 'builders.hall.test' } }, (res) => { res.resume(); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location })); });
|
|
426
|
+
req.on('error', reject);
|
|
427
|
+
req.end();
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const MODULE_ENV = 'BONGOS_MODULE_PROVISIONING';
|
|
431
|
+
|
|
432
|
+
test('/deploy answers with Settings → Software update on a hall that does not run the door', async () => {
|
|
433
|
+
const port = await startServer({ [MODULE_ENV]: '0' });
|
|
434
|
+
for (const p of ['/deploy', '/deploy.html', '/deploy/']) {
|
|
435
|
+
const r = await get(port, p);
|
|
436
|
+
assert.equal(r.status, 302, p);
|
|
437
|
+
assert.equal(r.location, '/settings#software-update', p);
|
|
438
|
+
}
|
|
439
|
+
const other = await get(port, '/settings');
|
|
440
|
+
assert.notEqual(other.location, '/settings#software-update', 'only /deploy is sent there');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('the platform, which runs the door, keeps /deploy behind its own page gate', async () => {
|
|
444
|
+
const port = await startServer({ [MODULE_ENV]: '1' });
|
|
445
|
+
const r = await get(port, '/deploy');
|
|
446
|
+
assert.notEqual(r.location, '/settings#software-update', 'not redirected away');
|
|
447
|
+
assert.equal(r.status, 302, 'a visitor with no session meets the page gate');
|
|
448
|
+
assert.match(String(r.location), /auth|sign/i, 'and is sent to sign in, as before');
|
|
449
|
+
});
|