@bongos/core 1.19.1039 → 1.19.1040
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 +79 -34
- 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 +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +39 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +2 -2
- package/docs/copy-registry.json +2 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/deploy.html +9 -0
- package/modules/hall-ui/public/deploy.js +14 -1
- package/modules/hall-ui/public/deploy.states.json +14 -0
- package/modules/lifecycle/conflict-resolve.js +9 -1
- package/modules/npm-release/module.json +19 -0
- package/modules/npm-release/public/work.css +8 -0
- package/modules/npm-release/public/work.js +261 -0
- package/modules/npm-release/registry.js +271 -0
- package/modules/npm-release/routes/work.js +46 -0
- package/modules/npm-release/work.js +252 -0
- package/modules/provisioning/starter-bundles.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/install-git-hooks.js +18 -2
- package/scripts/gds/publish-manifest.js +1 -0
- package/src/bongos/module-scope-map.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +15 -1
- package/tests/merge_driver_register.mjs +28 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_page.mjs +173 -0
- package/tests/npm_release_work.mjs +350 -0
- package/tests/render_check.mjs +3 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// tests/npm_release_page.mjs — the npm-release module's deploy-page sections, EXECUTED
|
|
2
|
+
// (task 1002622): modules/npm-release/public/work.js run in a DOM stub against a real
|
|
3
|
+
// reading, and deploy.js's loader that decides whether that script is fetched at all.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS AT RISK. The page's whole reason to exist is naming the work, so the cases pin
|
|
6
|
+
// that the work is named (every task, by title, linked), that a partial reading SAYS it is
|
|
7
|
+
// partial, that a reader the route refuses sees nothing rather than an error, and that a
|
|
8
|
+
// hall without the module never asks for its script.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/npm_release_page.mjs
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const WORK_SRC = fs.readFileSync(path.join(ROOT, 'modules/npm-release/public/work.js'), 'utf8');
|
|
20
|
+
const DEPLOY_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
|
|
21
|
+
// The fixture the hall-preview harness serves — a reading taken off the live registry.
|
|
22
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/npm-release__work.json'), 'utf8'));
|
|
23
|
+
|
|
24
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
25
|
+
|
|
26
|
+
function makeEl() {
|
|
27
|
+
return { hidden: true, _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run work.js with `answer(query)` standing in for the route. */
|
|
31
|
+
function bootWork(answer) {
|
|
32
|
+
const els = new Map([['nr-work', makeEl()], ['nr-versions', makeEl()]]);
|
|
33
|
+
const asked = [];
|
|
34
|
+
const clicks = [];
|
|
35
|
+
const head = [];
|
|
36
|
+
const documentObj = {
|
|
37
|
+
getElementById: (id) => els.get(id) || null,
|
|
38
|
+
addEventListener(ev, fn) { if (ev === 'click') clicks.push(fn); },
|
|
39
|
+
createElement: () => ({}),
|
|
40
|
+
head: { appendChild: (n) => head.push(n) },
|
|
41
|
+
};
|
|
42
|
+
const request = async (method, url) => {
|
|
43
|
+
const q = url.includes('?') ? url.slice(url.indexOf('?') + 1) : '';
|
|
44
|
+
asked.push(q);
|
|
45
|
+
return answer(q);
|
|
46
|
+
};
|
|
47
|
+
const windowObj = {
|
|
48
|
+
BongosClient: { createClient: () => ({ request }) },
|
|
49
|
+
OTB: { escapeHtml, toast() {} },
|
|
50
|
+
};
|
|
51
|
+
class Element {}
|
|
52
|
+
const sandbox = { window: windowObj, document: documentObj, Element, console, setTimeout, Date, Set, Map, Promise, encodeURIComponent };
|
|
53
|
+
vm.createContext(sandbox);
|
|
54
|
+
vm.runInContext(WORK_SRC, sandbox, { filename: 'work.js' });
|
|
55
|
+
const settle = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
56
|
+
return { els, asked, head, settle, work: () => els.get('nr-work'), versions: () => els.get('nr-versions') };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ok = (data) => ({ ok: true, status: 200, data });
|
|
60
|
+
|
|
61
|
+
test('every recent task is named, by title, linked to its page', async () => {
|
|
62
|
+
const b = bootWork(() => ok(FIXTURE));
|
|
63
|
+
await b.settle();
|
|
64
|
+
const html = b.work().innerHTML;
|
|
65
|
+
assert.equal(b.work().hidden, false);
|
|
66
|
+
for (const [stage, rows] of Object.entries(FIXTURE.stages)) {
|
|
67
|
+
// Stages longer than the fold show their first rows; the rest sit behind "Show all".
|
|
68
|
+
for (const t of rows.slice(0, 8)) {
|
|
69
|
+
assert.ok(html.includes(`href="/task/${t.id}"`), `${stage}: task ${t.id} is linked`);
|
|
70
|
+
assert.ok(html.includes(escapeHtml(t.title)), `${stage}: task ${t.id} is named`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
assert.match(html, /Show all 15/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the counts strip carries every stage, and the stage sentence sits under its head', async () => {
|
|
77
|
+
const b = bootWork(() => ok(FIXTURE));
|
|
78
|
+
await b.settle();
|
|
79
|
+
const html = b.work().innerHTML;
|
|
80
|
+
for (const fig of ['Not merged', 'Unversioned', 'Not running', 'Unreleased', 'Released', 'No version']) {
|
|
81
|
+
assert.ok(html.includes(`>${fig}</span>`), fig);
|
|
82
|
+
}
|
|
83
|
+
assert.doesNotMatch(html, /ov-group__note/, 'a sentence in the note slot squeezes the head on a phone');
|
|
84
|
+
assert.match(html, /Already offered to other projects\./, 'said once for the group when true of every row');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the version history lists each version with its tasks, and asks for a short first page', async () => {
|
|
88
|
+
const b = bootWork(() => ok(FIXTURE));
|
|
89
|
+
await b.settle();
|
|
90
|
+
assert.equal(b.asked[0], 'limit=6');
|
|
91
|
+
const html = b.versions().innerHTML;
|
|
92
|
+
for (const v of FIXTURE.history.versions) assert.ok(html.includes(`data-version="${v.version}"`), v.version);
|
|
93
|
+
assert.match(html, /running here|released/);
|
|
94
|
+
assert.match(html, /Show older versions/);
|
|
95
|
+
assert.equal(b.head.length, 1, 'the module stylesheet is loaded once');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a partial reading says it is partial', async () => {
|
|
99
|
+
const partial = { ...FIXTURE, registry: { ok: false, error: 'the registry answered 503' }, notes: { ...FIXTURE.notes, error: 'could not download 1.19.1039' } };
|
|
100
|
+
const b = bootWork(() => ok(partial));
|
|
101
|
+
await b.settle();
|
|
102
|
+
const html = b.work().innerHTML;
|
|
103
|
+
assert.match(html, /Partial reading/);
|
|
104
|
+
assert.match(html, /registry could not be read \(the registry answered 503\)/);
|
|
105
|
+
assert.match(html, /could not download 1\.19\.1039/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a version whose notes could not be read is never shown as a version with no work', async () => {
|
|
109
|
+
const d = JSON.parse(JSON.stringify(FIXTURE));
|
|
110
|
+
d.history.versions[0].entries = null;
|
|
111
|
+
d.history.versions[1].entries = [];
|
|
112
|
+
const b = bootWork(() => ok(d));
|
|
113
|
+
await b.settle();
|
|
114
|
+
const html = b.versions().innerHTML;
|
|
115
|
+
assert.match(html, /notes could not be read/);
|
|
116
|
+
assert.match(html, /housekeeping merges only/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a reader the route refuses sees nothing, not an error', async () => {
|
|
120
|
+
for (const status of [401, 403, 404]) {
|
|
121
|
+
const b = bootWork(() => ({ ok: false, status, data: {} }));
|
|
122
|
+
await b.settle();
|
|
123
|
+
assert.equal(b.work().hidden, true, `${status} keeps the section hidden`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a server failure is said in words', async () => {
|
|
128
|
+
const b = bootWork(() => ({ ok: false, status: 500, data: { error: { message: 'Could not read where the work is right now.' } } }));
|
|
129
|
+
await b.settle();
|
|
130
|
+
assert.equal(b.work().hidden, false);
|
|
131
|
+
assert.match(b.work().innerHTML, /Could not read where the work is right now\./);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('task titles are escaped', async () => {
|
|
135
|
+
const d = JSON.parse(JSON.stringify(FIXTURE));
|
|
136
|
+
d.stages.not_merged[0].title = '<img src=x onerror=alert(1)>';
|
|
137
|
+
const b = bootWork(() => ok(d));
|
|
138
|
+
await b.settle();
|
|
139
|
+
assert.doesNotMatch(b.work().innerHTML, /<img src=x/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---- deploy.js's loader ---------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function bootDeploy({ modules, search = '' }) {
|
|
145
|
+
const appended = [];
|
|
146
|
+
const documentObj = {
|
|
147
|
+
getElementById: () => ({ hidden: false, textContent: '', innerHTML: '', addEventListener() {} }),
|
|
148
|
+
addEventListener(ev, fn) { if (ev === 'DOMContentLoaded') this._ready = fn; },
|
|
149
|
+
createElement: () => ({}),
|
|
150
|
+
body: { appendChild: (n) => appended.push(n) },
|
|
151
|
+
};
|
|
152
|
+
const request = async () => ({ ok: false, status: 403, data: {} });
|
|
153
|
+
const windowObj = {
|
|
154
|
+
BongosClient: { createClient: () => ({ request }) },
|
|
155
|
+
OTB: { escapeHtml, toast() {} },
|
|
156
|
+
location: { assign() {}, search },
|
|
157
|
+
__MODULES__: modules,
|
|
158
|
+
__deployPollMs: 0,
|
|
159
|
+
};
|
|
160
|
+
const sandbox = { window: windowObj, document: documentObj, console, setTimeout, clearTimeout, Date, Set, Map, Promise, decodeURIComponent };
|
|
161
|
+
vm.createContext(sandbox);
|
|
162
|
+
vm.runInContext(DEPLOY_SRC, sandbox, { filename: 'deploy.js' });
|
|
163
|
+
documentObj._ready();
|
|
164
|
+
return appended;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
test('the deploy page loads the module script only for its own view, and only with the module on', () => {
|
|
168
|
+
const on = { enabled: { 'npm-release': true } };
|
|
169
|
+
assert.deepEqual(bootDeploy({ modules: on }).map((s) => s.src), ['/modules/npm-release/public/work.js']);
|
|
170
|
+
assert.deepEqual(bootDeploy({ modules: { enabled: { 'npm-release': false } } }), [], 'module off: no request for a script that would 404');
|
|
171
|
+
assert.deepEqual(bootDeploy({ modules: undefined }), [], 'no module record at all');
|
|
172
|
+
assert.deepEqual(bootDeploy({ modules: on, search: '?project=mercury' }), [], 'another project\'s view is not this package\'s');
|
|
173
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// tests/npm_release_work.mjs — the npm-release module's "where is the work?" reading
|
|
2
|
+
// (task 1002622): which stage every recent task has reached, and what each version carried.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. The owner's complaint was a deploy page that named none of the fifteen
|
|
5
|
+
// tasks sitting between the version the hall ran and the newest one. The failure modes that
|
|
6
|
+
// would bring that back are quiet ones: a task put in the wrong stage, a version whose notes
|
|
7
|
+
// could not be read rendered as a version with no work, or a registry that did not answer
|
|
8
|
+
// rendered as "nothing new". Each case below pins one of them.
|
|
9
|
+
//
|
|
10
|
+
// THE TRANSPORT IS FAKED, THE PARSING IS NOT. `get` returns a real stream of a real gzipped
|
|
11
|
+
// tar built here, so the tar reader, gunzip, chunk boundaries and the registry's JSON are all
|
|
12
|
+
// exercised as they run in production. Only the network is missing.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/npm_release_work.mjs
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { EventEmitter } from 'node:events';
|
|
19
|
+
import { Readable } from 'node:stream';
|
|
20
|
+
import zlib from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { readWork } = require('../modules/npm-release/work.js');
|
|
24
|
+
const buildRouter = require('../modules/npm-release/routes/work.js');
|
|
25
|
+
|
|
26
|
+
const ORIGIN = 'https://registry.npmjs.org';
|
|
27
|
+
const PKG = '@bongos/core';
|
|
28
|
+
|
|
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
|
+
function fakePool(tasks, titles = {}) {
|
|
114
|
+
const queries = [];
|
|
115
|
+
return {
|
|
116
|
+
queries,
|
|
117
|
+
async query(sql, params) {
|
|
118
|
+
queries.push({ sql, params });
|
|
119
|
+
if (/FROM tasks t/.test(sql)) return { rows: tasks };
|
|
120
|
+
if (/WHERE id = ANY/.test(sql)) return { rows: params[0].filter((id) => titles[id]).map((id) => ({ id, title: titles[id] })) };
|
|
121
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const freshStore = () => ({ packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } });
|
|
127
|
+
|
|
128
|
+
// Versions 1.0.1 … 1.0.5, published a day apart, ending 2026-09-26T10:00Z.
|
|
129
|
+
const TIMES = {
|
|
130
|
+
'1.0.1': '2026-09-22T10:00:00.000Z',
|
|
131
|
+
'1.0.2': '2026-09-23T10:00:00.000Z',
|
|
132
|
+
'1.0.3': '2026-09-24T10:00:00.000Z',
|
|
133
|
+
'1.0.4': '2026-09-25T10:00:00.000Z',
|
|
134
|
+
'1.0.5': '2026-09-26T10:00:00.000Z',
|
|
135
|
+
};
|
|
136
|
+
const NOTES = {
|
|
137
|
+
'1.0.1': [{ id: '11', text: 'An early fix.' }],
|
|
138
|
+
'1.0.2': [{ id: '12', text: 'Released and running.' }],
|
|
139
|
+
'1.0.3': [{ id: '13', text: 'Running here.' }, { text: 'A hand-landed change nobody numbered.' }],
|
|
140
|
+
'1.0.4': [],
|
|
141
|
+
'1.0.5': [{ id: '15', text: 'Published, not running yet.' }],
|
|
142
|
+
};
|
|
143
|
+
const INSTALLED_TO_103 = () => ({ '1.0.1': NOTES['1.0.1'], '1.0.2': NOTES['1.0.2'], '1.0.3': NOTES['1.0.3'] });
|
|
144
|
+
|
|
145
|
+
const row = (id, status, at, extra = {}) => ({ id, title: `Task ${id} title`, status, at, value_summary: null, builder: 'Lars', ...extra });
|
|
146
|
+
const TASKS = [
|
|
147
|
+
row('20', 'confirmed', '2026-09-26T11:00:00.000Z'), // done, not merged
|
|
148
|
+
row('15', 'shipped', '2026-09-26T09:00:00.000Z'), // in 1.0.5 > live 1.0.3
|
|
149
|
+
row('13', 'shipped', '2026-09-24T09:00:00.000Z'), // in 1.0.3 = live
|
|
150
|
+
row('12', 'shipped', '2026-09-23T09:00:00.000Z'), // in 1.0.2
|
|
151
|
+
row('21', 'shipped', '2026-09-26T12:00:00.000Z'), // after 1.0.5 went out
|
|
152
|
+
row('22', 'shipped', '2026-09-25T12:00:00.000Z'), // before it, named nowhere
|
|
153
|
+
row('23', 'shipped', '2026-09-24T08:00:00.000Z', { value_summary: 'A hand-landed change nobody numbered.' }),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function setup({ latest = '1.0.5', tgz = tarball({ notes: NOTES }), pkStatus = 200, tgzStatus = 200, installed = INSTALLED_TO_103, tasks = TASKS, titles = {} } = {}) {
|
|
157
|
+
const versions = Object.keys(TIMES);
|
|
158
|
+
const routes = {
|
|
159
|
+
[`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ? packument({ versions, latest, times: TIMES }) : 'nope' },
|
|
160
|
+
[`${ORIGIN}/@bongos/core/-/core-1.0.5.tgz`]: { status: tgzStatus, body: tgz },
|
|
161
|
+
};
|
|
162
|
+
const fg = fakeGet(routes);
|
|
163
|
+
const pool = fakePool(tasks, titles);
|
|
164
|
+
const store = freshStore();
|
|
165
|
+
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 });
|
|
166
|
+
return { read, asked: fg.asked, pool, store };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ids = (list) => list.map((t) => t.id);
|
|
170
|
+
|
|
171
|
+
// ---- the stages ---------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
test('every task lands in the stage it has actually reached', async () => {
|
|
174
|
+
const { read } = setup();
|
|
175
|
+
const d = await read();
|
|
176
|
+
assert.deepEqual(ids(d.stages.not_merged), ['20']);
|
|
177
|
+
assert.deepEqual(ids(d.stages.not_live), ['15']);
|
|
178
|
+
assert.deepEqual(ids(d.stages.released), ['13', '12', '23']);
|
|
179
|
+
assert.deepEqual(ids(d.stages.unversioned), ['21']);
|
|
180
|
+
assert.deepEqual(ids(d.stages.no_version), ['22']);
|
|
181
|
+
assert.deepEqual(d.stages.live_unreleased, []);
|
|
182
|
+
assert.equal(d.live, '1.0.3');
|
|
183
|
+
assert.equal(d.newest, '1.0.5');
|
|
184
|
+
assert.equal(d.released, '1.0.5');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a task published before this hall ran it says it was already offered', async () => {
|
|
188
|
+
const d = await setup().read();
|
|
189
|
+
const t = d.stages.not_live[0];
|
|
190
|
+
assert.equal(t.version, '1.0.5');
|
|
191
|
+
assert.equal(t.offered, true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('work running here but not released is its own stage', async () => {
|
|
195
|
+
// `latest` still on 1.0.2: 1.0.3 runs here and has not been released.
|
|
196
|
+
const d = await setup({ latest: '1.0.2' }).read();
|
|
197
|
+
assert.deepEqual(ids(d.stages.live_unreleased), ['13', '23']);
|
|
198
|
+
assert.deepEqual(ids(d.stages.released), ['12']);
|
|
199
|
+
assert.equal(d.stages.not_live[0].offered, undefined, 'not offered: 1.0.5 is past the release');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a merge with no task id is matched on the task\'s own ship summary', async () => {
|
|
203
|
+
const d = await setup().read();
|
|
204
|
+
const t = d.stages.released.find((x) => x.id === '23');
|
|
205
|
+
assert.equal(t.version, '1.0.3');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('with no running version (another package), nothing is "not live"', async () => {
|
|
209
|
+
const d = await setup().read({ live: null });
|
|
210
|
+
assert.deepEqual(d.stages.not_live, []);
|
|
211
|
+
assert.ok(ids(d.stages.released).includes('15'));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---- where the notes come from --------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
test('notes past the installed copy are read out of the newest package', async () => {
|
|
217
|
+
const { read, asked } = setup();
|
|
218
|
+
const d = await read();
|
|
219
|
+
assert.ok(asked.includes(`${ORIGIN}/@bongos/core/-/core-1.0.5.tgz`));
|
|
220
|
+
assert.deepEqual(d.notes.source, { from: 'package', version: '1.0.5' });
|
|
221
|
+
assert.equal(d.notes.covers_to, '1.0.5');
|
|
222
|
+
assert.equal(d.notes.error, null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a pax long-name header is followed to the notes file', async () => {
|
|
226
|
+
const d = await setup({ tgz: tarball({ notes: NOTES, pax: true }) }).read();
|
|
227
|
+
assert.equal(d.notes.covers_to, '1.0.5');
|
|
228
|
+
assert.deepEqual(ids(d.stages.not_live), ['15']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('when the installed notes already reach the newest version, nothing is downloaded', async () => {
|
|
232
|
+
const { read, asked } = setup({ installed: () => NOTES });
|
|
233
|
+
const d = await read();
|
|
234
|
+
assert.ok(!asked.some((u) => u.endsWith('.tgz')), 'no tarball download');
|
|
235
|
+
assert.deepEqual(d.notes.source, { from: 'installed', version: '1.0.5' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a package without notes is an error, not an empty history', async () => {
|
|
239
|
+
const d = await setup({ tgz: tarball({ notes: null }) }).read();
|
|
240
|
+
assert.match(d.notes.error, /without release notes/);
|
|
241
|
+
// The installed copy stands in: the work up to 1.0.3 is still placed.
|
|
242
|
+
assert.deepEqual(d.notes.source, { from: 'installed', version: '1.0.3' });
|
|
243
|
+
assert.ok(ids(d.stages.released).includes('13'));
|
|
244
|
+
// 1.0.5 is listed as unread, never as "no work".
|
|
245
|
+
const v = d.history.versions.find((x) => x.version === '1.0.5');
|
|
246
|
+
assert.equal(v.entries, null);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a download that fails says so', async () => {
|
|
250
|
+
const d = await setup({ tgzStatus: 503 }).read();
|
|
251
|
+
assert.match(d.notes.error, /could not download 1\.0\.5/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a registry that does not answer is reported, and the installed notes still place work', async () => {
|
|
255
|
+
const d = await setup({ pkStatus: 500 }).read();
|
|
256
|
+
assert.equal(d.registry.ok, false);
|
|
257
|
+
assert.match(d.registry.error, /500/);
|
|
258
|
+
assert.equal(d.newest, null);
|
|
259
|
+
assert.equal(d.released, null);
|
|
260
|
+
assert.ok(ids(d.stages.released).includes('13'), '1.0.3 work is still placed from the installed notes');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('the package download is only ever followed on the registry\'s own origin', async () => {
|
|
264
|
+
const versions = Object.keys(TIMES);
|
|
265
|
+
const doc = packument({ versions, latest: '1.0.5', times: TIMES });
|
|
266
|
+
doc.versions['1.0.5'].dist.tarball = 'https://evil.example/core.tgz';
|
|
267
|
+
const fg = fakeGet({ [`${ORIGIN}/@bongos%2fcore`]: { body: doc } });
|
|
268
|
+
const d = await readWork({ pool: fakePool(TASKS), pkg: PKG, live: '1.0.3', get: fg.get, store: freshStore(), installedNotes: INSTALLED_TO_103 });
|
|
269
|
+
assert.ok(!fg.asked.some((u) => u.includes('evil.example')));
|
|
270
|
+
assert.match(d.notes.error, /no download/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---- the cache --------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
test('a second read within minutes asks the registry nothing new', async () => {
|
|
276
|
+
const { read, asked } = setup();
|
|
277
|
+
await read();
|
|
278
|
+
const before = asked.length;
|
|
279
|
+
await read();
|
|
280
|
+
assert.equal(asked.length, before);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('a failed notes read is not retried on every page load', async () => {
|
|
284
|
+
const { read, asked } = setup({ tgzStatus: 503 });
|
|
285
|
+
await read();
|
|
286
|
+
await read();
|
|
287
|
+
assert.equal(asked.filter((u) => u.endsWith('.tgz')).length, 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---- the version history ----------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
test('the history lists each version\'s tasks newest first, with titles from the ledger', async () => {
|
|
293
|
+
const d = await setup({ titles: { 11: 'Old fix title' } }).read();
|
|
294
|
+
const v = d.history.versions;
|
|
295
|
+
assert.deepEqual(v.map((x) => x.version), ['1.0.5', '1.0.4', '1.0.3', '1.0.2', '1.0.1']);
|
|
296
|
+
assert.equal(v[0].released, true);
|
|
297
|
+
assert.equal(v[2].live, true);
|
|
298
|
+
assert.deepEqual(v[1].entries, [], 'a housekeeping-only version is an empty list, not unread');
|
|
299
|
+
assert.equal(v[0].entries[0].title, 'Task 15 title');
|
|
300
|
+
assert.equal(v[4].entries[0].title, 'Old fix title', 'a task outside the window is looked up by id');
|
|
301
|
+
assert.equal(v[0].published_at, TIMES['1.0.5']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('the history pages with before + limit', async () => {
|
|
305
|
+
const { read } = setup();
|
|
306
|
+
const first = await read({ limit: 2 });
|
|
307
|
+
assert.deepEqual(first.history.versions.map((x) => x.version), ['1.0.5', '1.0.4']);
|
|
308
|
+
assert.equal(first.history.more, true);
|
|
309
|
+
const next = await read({ limit: 2, before: '1.0.4' });
|
|
310
|
+
assert.deepEqual(next.history.versions.map((x) => x.version), ['1.0.3', '1.0.2']);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('the ledger window is clamped', async () => {
|
|
314
|
+
const { read, pool } = setup();
|
|
315
|
+
const windows = () => pool.queries.filter((q) => /FROM tasks t/.test(q.sql)).map((q) => q.params[0]);
|
|
316
|
+
await read({ days: '9999' });
|
|
317
|
+
await read({ days: 'nonsense' });
|
|
318
|
+
assert.deepEqual(windows(), [60, 14]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ---- the route ------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function routeLayers() {
|
|
324
|
+
const router = buildRouter({ read: async (args) => ({ ok: true, args }) });
|
|
325
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/work');
|
|
326
|
+
return layer.route.stack.map((s) => s.handle);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
test('the route is gated like the deploy page: a builder holding core.pin.move', async () => {
|
|
330
|
+
const handles = routeLayers();
|
|
331
|
+
assert.equal(handles.length, 3, 'requireBuilder, the permission gate, then the handler');
|
|
332
|
+
const src = (await import('node:fs')).readFileSync(new URL('../modules/npm-release/routes/work.js', import.meta.url), 'utf8');
|
|
333
|
+
assert.match(src, /api\.requireBuilder,\s*\n\s*api\.requirePermission\('core\.pin\.move'\)/);
|
|
334
|
+
assert.doesNotMatch(src, /api\.requireRank\(/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('the handler passes the query through and answers 500 plainly when the read throws', async () => {
|
|
338
|
+
const [, , ok] = routeLayers();
|
|
339
|
+
let body = null;
|
|
340
|
+
await ok({ query: { days: '7', before: '1.0.3', limit: '5' } }, { json(v) { body = v; } });
|
|
341
|
+
assert.equal(body.args.days, '7');
|
|
342
|
+
assert.equal(body.args.before, '1.0.3');
|
|
343
|
+
|
|
344
|
+
const router = buildRouter({ read: async () => { throw new Error('boom'); } });
|
|
345
|
+
const h = router.stack[0].route.stack.at(-1).handle;
|
|
346
|
+
let status = 0; let err = null;
|
|
347
|
+
await h({ query: {} }, { status(s) { status = s; return this; }, json(v) { err = v; } });
|
|
348
|
+
assert.equal(status, 500);
|
|
349
|
+
assert.equal(err.error.code, 'work_read_failed');
|
|
350
|
+
});
|
package/tests/render_check.mjs
CHANGED
|
@@ -34,7 +34,8 @@ test('the page a builder changed comes before the pages a shared file reaches, s
|
|
|
34
34
|
|
|
35
35
|
test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
|
|
36
36
|
assert.deepEqual(names(['src/bongos/db.js', 'modules/hall-ui/public/goal-map.states.json', 'modules/hall-ui/routes/x.js']), []);
|
|
37
|
-
|
|
37
|
+
// blockers, not deploy: /deploy gained a states file with the npm-release module (task 1002622).
|
|
38
|
+
assert.deepEqual(names(['modules/hall-ui/public/blockers.html']), [], 'a page without a states file does not fall back to the pages that link to it');
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
test('fitFindings names every element from the structured facts, once per element across renders, with its largest measure', () => {
|
|
@@ -102,7 +103,7 @@ test('the CLI renders nothing, and says why, when no page a states file describe
|
|
|
102
103
|
const none = cli('src/bongos/db.js');
|
|
103
104
|
assert.equal(none.status, 0);
|
|
104
105
|
assert.match(none.stdout, /no rendered surface changed/);
|
|
105
|
-
const noStates = cli('modules/hall-ui/public/
|
|
106
|
+
const noStates = cli('modules/hall-ui/public/blockers.html');
|
|
106
107
|
assert.equal(noStates.status, 0);
|
|
107
108
|
assert.match(noStates.stdout, /1 rendered-surface file\(s\) changed, but no <page>\.states\.json reaches them/);
|
|
108
109
|
});
|