@bongos/core 1.19.1040 → 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 +110 -80
- 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 +12 -0
- package/modules/grading/grader-subagent.js +7 -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 +16 -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/grade_model_provider.mjs +1 -1
- package/tests/grader_reviewer_isolation.mjs +68 -0
- 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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// tests/main_audit_attestation.mjs — the main audit's attestation pass (task 1001433).
|
|
2
|
+
//
|
|
3
|
+
// The pass ties every permission-sensitive commit on main to the pull request that
|
|
4
|
+
// brought it in, and requires that PR's head to carry the server's `gate-author-trust`
|
|
5
|
+
// status, posted by the server's own GitHub identity. The author NAME the older pass
|
|
6
|
+
// reads is typed text; the status creator is not. FLAG-ONLY by the owner's ruling
|
|
7
|
+
// (2026-09-26): findings are 'review', never 'blocker'.
|
|
8
|
+
//
|
|
9
|
+
// Everything here runs on injected git + fetch seams — no repository, no network.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/main_audit_attestation.mjs
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const audit = require('../scripts/gds/main-audit.js');
|
|
18
|
+
const {
|
|
19
|
+
ATTEST_CONTEXT, mapIntroducingHeads, isAttested, analyzeAttestation, attestationConfig,
|
|
20
|
+
fetchAttestations, runAttestationPass,
|
|
21
|
+
} = audit;
|
|
22
|
+
|
|
23
|
+
const sha = (c) => c.repeat(40);
|
|
24
|
+
const PROTECTED = 'src/bongos/auth.js'; // floored in the real registry
|
|
25
|
+
const PLAIN = 'docs/notes.md'; // not a protected surface
|
|
26
|
+
const status = (login, state = 'success', context = ATTEST_CONTEXT) => ({ context, state, creator: { login } });
|
|
27
|
+
|
|
28
|
+
// A tiny history. Mainline: R (release) ← M (merges PR head H over base B).
|
|
29
|
+
// The PR carried two commits: A (protected) then H (the head, plain).
|
|
30
|
+
const B = sha('b'); const A = sha('a'); const H = sha('h'); const M = sha('m'); const D = sha('d');
|
|
31
|
+
const commitA = { sha: A, authorName: 'somemetic', parents: [B], files: [PROTECTED] };
|
|
32
|
+
const commitH = { sha: H, authorName: 'somemetic', parents: [A], files: [PLAIN] };
|
|
33
|
+
const mergeM = { sha: M, authorName: 'Lars', parents: [B, H], isMerge: true, files: [] };
|
|
34
|
+
// D: a protected commit pushed straight onto main (no merge brought it in).
|
|
35
|
+
const directD = { sha: D, authorName: 'IAMMASTERQUA', parents: [M], files: [PROTECTED] };
|
|
36
|
+
const revRanges = { [`${B}..${H}`]: [H, A] };
|
|
37
|
+
const revList = (r) => revRanges[r] || [];
|
|
38
|
+
|
|
39
|
+
test('the real registry still floors the fixture paths the way these tests assume', () => {
|
|
40
|
+
const ppc = require('../src/bongos/permission-path-check.js');
|
|
41
|
+
assert.ok(ppc.matchProtected([PROTECTED]).length > 0, `${PROTECTED} must be protected`);
|
|
42
|
+
assert.equal(ppc.matchProtected([PLAIN]).length, 0, `${PLAIN} must not be protected`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ---- mapIntroducingHeads ----
|
|
46
|
+
|
|
47
|
+
test('a mainline merge vouches for every commit its PR brought in, via its SECOND parent', () => {
|
|
48
|
+
const headOf = mapIntroducingHeads([mergeM], revList);
|
|
49
|
+
assert.equal(headOf.get(A), H);
|
|
50
|
+
assert.equal(headOf.get(H), H, 'a PR head vouches for itself');
|
|
51
|
+
assert.equal(headOf.has(D), false, 'a direct push has no introducing merge');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a commit keeps the FIRST merge that claims it; single-parent commits are ignored', () => {
|
|
55
|
+
const other = { sha: sha('n'), parents: [sha('x'), sha('y')] };
|
|
56
|
+
const headOf = mapIntroducingHeads([mergeM, other, commitA], (r) => (r === `${sha('x')}..${sha('y')}` ? [A] : revList(r)));
|
|
57
|
+
assert.equal(headOf.get(A), H);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---- isAttested: the creator is the part that cannot be faked ----
|
|
61
|
+
|
|
62
|
+
test('a success from the trusted account attests; the login match ignores case', () => {
|
|
63
|
+
assert.equal(isAttested([status('owner-acct')], 'Owner-Acct'), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('FORGERY: the same context posted by any other account attests nothing', () => {
|
|
67
|
+
assert.equal(isAttested([status('IAMMASTERQUA')], 'owner-acct'), false);
|
|
68
|
+
assert.equal(isAttested([status('github-actions[bot]')], 'owner-acct'), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("only the trusted account's NEWEST status for the context decides", () => {
|
|
72
|
+
// newest first, as GitHub returns them
|
|
73
|
+
assert.equal(isAttested([status('owner-acct', 'failure'), status('owner-acct', 'success')], 'owner-acct'), false, 'a later failure withdraws');
|
|
74
|
+
assert.equal(isAttested([status('owner-acct', 'success'), status('owner-acct', 'failure')], 'owner-acct'), true, 'a later success restores');
|
|
75
|
+
// another account can neither attest nor revoke
|
|
76
|
+
assert.equal(isAttested([status('mallory', 'failure'), status('owner-acct', 'success')], 'owner-acct'), true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('another context, a pending state, or no trusted login attests nothing', () => {
|
|
80
|
+
assert.equal(isAttested([status('owner-acct', 'success', 'unit-report')], 'owner-acct'), false);
|
|
81
|
+
assert.equal(isAttested([status('owner-acct', 'pending')], 'owner-acct'), false);
|
|
82
|
+
assert.equal(isAttested([status('owner-acct')], ''), false);
|
|
83
|
+
assert.equal(isAttested(null, 'owner-acct'), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---- analyzeAttestation ----
|
|
87
|
+
|
|
88
|
+
test('an attested PR head clears every protected commit it brought in', () => {
|
|
89
|
+
const headOf = mapIntroducingHeads([mergeM], revList);
|
|
90
|
+
const r = analyzeAttestation([commitA, commitH], { headOf, attested: new Set([H]), trustedLogin: 'owner-acct' });
|
|
91
|
+
assert.equal(r.checked, 1, 'only the protected commit is checked');
|
|
92
|
+
assert.deepEqual(r.findings, []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('an unattested PR head is a REVIEW finding naming the head — never a blocker', () => {
|
|
96
|
+
const headOf = mapIntroducingHeads([mergeM], revList);
|
|
97
|
+
const r = analyzeAttestation([commitA], { headOf, attested: new Set(), trustedLogin: 'owner-acct' });
|
|
98
|
+
assert.equal(r.findings.length, 1);
|
|
99
|
+
const f = r.findings[0];
|
|
100
|
+
assert.equal(f.severity, 'review', 'flag-only: the owner ruled this pass must not gate');
|
|
101
|
+
assert.equal(f.kind, 'unattested');
|
|
102
|
+
assert.equal(f.head, H);
|
|
103
|
+
assert.match(f.summary, /pull request that brought it in \(head hhhhhhhhh\)/);
|
|
104
|
+
assert.match(f.summary, /gate-author-trust' status from 'owner-acct'/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a direct push to main is flagged as having no pull request at all', () => {
|
|
108
|
+
const r = analyzeAttestation([directD], { headOf: new Map(), attested: new Set(), trustedLogin: 'owner-acct' });
|
|
109
|
+
assert.equal(r.findings.length, 1);
|
|
110
|
+
assert.equal(r.findings[0].severity, 'review');
|
|
111
|
+
assert.equal(r.findings[0].head, D);
|
|
112
|
+
assert.match(r.findings[0].summary, /without passing through a pull request/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a ONE-commit pull request (head === the commit) still reads as a pull request', () => {
|
|
116
|
+
const lone = { sha: H, authorName: 'somemetic', parents: [B], files: [PROTECTED] };
|
|
117
|
+
const r = analyzeAttestation([lone], { headOf: new Map([[H, H]]), attested: new Set(), trustedLogin: 'owner-acct' });
|
|
118
|
+
assert.match(r.findings[0].summary, /pull request that brought it in/);
|
|
119
|
+
assert.doesNotMatch(r.findings[0].summary, /without passing through a pull request/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a direct push that carries the attestation ITSELF is cleared', () => {
|
|
123
|
+
const r = analyzeAttestation([directD], { headOf: new Map(), attested: new Set([D]), trustedLogin: 'owner-acct' });
|
|
124
|
+
assert.deepEqual(r.findings, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---- attestationConfig ----
|
|
128
|
+
|
|
129
|
+
test('config: Actions env first, GH_TOKEN as the laptop fallback, owner as the default login', () => {
|
|
130
|
+
assert.deepEqual(attestationConfig({ env: { GITHUB_TOKEN: 't1', GITHUB_REPOSITORY: 'owner-acct/core' } }),
|
|
131
|
+
{ token: 't1', repo: 'owner-acct/core', trustedLogin: 'owner-acct' });
|
|
132
|
+
assert.equal(attestationConfig({ env: { GH_TOKEN: 't2' } }).token, 't2');
|
|
133
|
+
assert.equal(attestationConfig({ env: {} }).token, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('config: the repo is read from the origin remote without ever capturing a credential in it', () => {
|
|
137
|
+
const https = attestationConfig({ env: {}, remoteUrl: 'https://x-access-token:SECRET@github.com/owner/repo.git' });
|
|
138
|
+
assert.equal(https.repo, 'owner/repo');
|
|
139
|
+
assert.ok(!JSON.stringify(https).includes('SECRET'));
|
|
140
|
+
assert.equal(attestationConfig({ env: {}, remoteUrl: 'git@github.com:owner/repo.git' }).repo, 'owner/repo');
|
|
141
|
+
assert.equal(attestationConfig({ env: {}, remoteUrl: 'https://example.com/x/y.git' }).repo, null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('config: --attest-login overrides the owner default (an App-posting instance)', () => {
|
|
145
|
+
const c = attestationConfig({ env: { GITHUB_REPOSITORY: 'o/r' }, loginArg: 'bongos-app[bot]' });
|
|
146
|
+
assert.equal(c.trustedLogin, 'bongos-app[bot]');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---- fetchAttestations ----
|
|
150
|
+
|
|
151
|
+
function stubFetch(bySha) {
|
|
152
|
+
const calls = [];
|
|
153
|
+
const impl = async (url, init) => {
|
|
154
|
+
calls.push({ url, init });
|
|
155
|
+
const s = Object.keys(bySha).find((k) => url.includes(`/commits/${k}/`));
|
|
156
|
+
const r = bySha[s];
|
|
157
|
+
if (r instanceof Error) throw r;
|
|
158
|
+
if (typeof r === 'number') return { ok: false, status: r, json: async () => ({}) };
|
|
159
|
+
return { ok: true, status: 200, json: async () => r };
|
|
160
|
+
};
|
|
161
|
+
return { impl, calls };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test('fetch: reads each head once, keeps only the trusted attestations, sends the token', async () => {
|
|
165
|
+
const { impl, calls } = stubFetch({ [H]: [status('owner-acct')], [D]: [status('mallory')] });
|
|
166
|
+
const r = await fetchAttestations([H, D, H], { repo: 'o/r', token: 'tok', trustedLogin: 'owner-acct', fetchImpl: impl });
|
|
167
|
+
assert.deepEqual([...r.attested], [H]);
|
|
168
|
+
assert.deepEqual(r.unreadable, []);
|
|
169
|
+
assert.equal(calls.length, 2, 'a head shared by several commits is read once');
|
|
170
|
+
assert.equal(calls[0].url, `https://api.github.com/repos/o/r/commits/${H}/statuses?per_page=100`);
|
|
171
|
+
assert.equal(calls[0].init.headers.Authorization, 'Bearer tok');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('fetch: an unreadable head is reported, never counted as attested or unattested', async () => {
|
|
175
|
+
const { impl } = stubFetch({ [H]: 404, [D]: new Error('network down') });
|
|
176
|
+
const r = await fetchAttestations([H, D], { repo: 'o/r', token: 'tok', trustedLogin: 'owner-acct', fetchImpl: impl });
|
|
177
|
+
assert.equal(r.attested.size, 0);
|
|
178
|
+
assert.deepEqual(r.unreadable.map((u) => u.reason), ['HTTP 404', 'network down']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---- runAttestationPass: the whole pass on fake git + fetch ----
|
|
182
|
+
|
|
183
|
+
function fakeGit({ mainline, ranges, remote = 'https://github.com/o/r.git' }) {
|
|
184
|
+
return (args) => {
|
|
185
|
+
if (args === 'remote get-url origin') return `${remote}\n`;
|
|
186
|
+
if (args.startsWith('rev-list --first-parent ')) return mainline.join('\n');
|
|
187
|
+
if (args.startsWith('rev-list ')) return (ranges[args.slice('rev-list '.length)] || []).join('\n');
|
|
188
|
+
throw new Error(`unexpected git ${args}`);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const history = [directD, mergeM, commitH, commitA];
|
|
192
|
+
const gitImpl = fakeGit({ mainline: [D, M], ranges: revRanges });
|
|
193
|
+
|
|
194
|
+
test('pass: nothing protected in range → it ran, checked nothing, needed no token', async () => {
|
|
195
|
+
const r = await runAttestationPass([commitH], 'x..y', { env: {}, gitImpl: () => { throw new Error('git must not be called'); } });
|
|
196
|
+
assert.deepEqual(r, { ran: true, checked: 0, findings: [] });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('pass: no token → DID NOT RUN with the reason, never a clean pass', async () => {
|
|
200
|
+
const r = await runAttestationPass(history, 'x..y', { env: {}, gitImpl });
|
|
201
|
+
assert.equal(r.ran, false);
|
|
202
|
+
assert.match(r.reason, /no GitHub token/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('pass: end to end — the attested PR clears, the direct push is flagged', async () => {
|
|
206
|
+
const { impl } = stubFetch({ [H]: [status('o')], [D]: [] });
|
|
207
|
+
const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
|
|
208
|
+
assert.equal(r.ran, true);
|
|
209
|
+
assert.equal(r.checked, 2);
|
|
210
|
+
assert.deepEqual(r.findings.map((f) => f.sha), [D]);
|
|
211
|
+
assert.equal(r.trustedLogin, 'o', 'the owner of the origin remote');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('pass: a FORGED mark on the PR head (right name, wrong account) is flagged', async () => {
|
|
215
|
+
const { impl } = stubFetch({ [H]: [status('IAMMASTERQUA')], [D]: [status('o')] });
|
|
216
|
+
const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
|
|
217
|
+
assert.deepEqual(r.findings.map((f) => f.sha), [A]);
|
|
218
|
+
assert.ok(r.findings.every((f) => f.severity === 'review'));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('pass: a head it could not read makes the whole pass DID NOT RUN', async () => {
|
|
222
|
+
const { impl } = stubFetch({ [H]: 502, [D]: [status('o')] });
|
|
223
|
+
const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
|
|
224
|
+
assert.equal(r.ran, false);
|
|
225
|
+
assert.match(r.reason, /could not read the commit statuses of 1 pull-request head/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('pass: a merge INSIDE a pull request never vouches — only mainline merges do', async () => {
|
|
229
|
+
// I: a branch's "merge main into me" whose second parent is a main commit P.
|
|
230
|
+
const P = sha('p'); const I = sha('i');
|
|
231
|
+
const inner = { sha: I, authorName: 'x', parents: [A, P], isMerge: true, files: [] };
|
|
232
|
+
const mainP = { sha: P, authorName: 'x', parents: [B], files: [PROTECTED] };
|
|
233
|
+
const g = fakeGit({ mainline: [P], ranges: { [`${A}..${P}`]: [P] } });
|
|
234
|
+
const { impl } = stubFetch({ [P]: [] });
|
|
235
|
+
const r = await runAttestationPass([inner, mainP], 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl: g, fetchImpl: impl });
|
|
236
|
+
assert.equal(r.findings.length, 1);
|
|
237
|
+
assert.equal(r.findings[0].head, P, 'P answers for itself; the inner merge did not claim it');
|
|
238
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -56,6 +56,7 @@ const PUBLISHED_SURFACE = [
|
|
|
56
56
|
'buildInfo', 'validateOrRespond', 'LIMITS', 'parseId', 'asyncHandler', 'corsPublicGet', // parseId added 1.8.0 (BV1.R78); asyncHandler + corsPublicGet added 1.12.0 (BV1.R86; lifecycle routes)
|
|
57
57
|
'parsePagination', 'pageMeta', 'PAGINATION', // R14 (#2001, 1.17.0): shared pagination contract for module list routes (ADR 0119)
|
|
58
58
|
'responsibilityFor', 'ROLE_RESPONSIBILITIES', // added by task 1003732 (ADR 0275; criterion wa6-written-responsibilities): the ONE source for the sentence each craft is answerable for. It rides the doorway because two modules need the same text — grading judges role-shaped work against it, the hall shows it to the person being judged — and modules never import each other. Declared here deliberately: this list is what makes adding to the doorway a decision rather than a side effect
|
|
59
|
+
'readPackageRegistry', // added by task 1004296 (ADR 0340 D5): the npm registry reader moved into the core, because Settings → Software update needs it in every hall; the module that publishes a package reads the same answer and cache through here rather than keeping a second copy
|
|
59
60
|
'logger',
|
|
60
61
|
];
|
|
61
62
|
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = 'ff2017aa9bbbc507';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -22,6 +22,8 @@ const DEPLOY_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deplo
|
|
|
22
22
|
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/npm-release__work.json'), 'utf8'));
|
|
23
23
|
|
|
24
24
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
25
|
+
// Voice rule V4: quoted data renders with its dashes as commas (docs/voice.md).
|
|
26
|
+
const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
|
|
25
27
|
|
|
26
28
|
function makeEl() {
|
|
27
29
|
return { hidden: true, _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
|
|
@@ -67,7 +69,7 @@ test('every recent task is named, by title, linked to its page', async () => {
|
|
|
67
69
|
// Stages longer than the fold show their first rows; the rest sit behind "Show all".
|
|
68
70
|
for (const t of rows.slice(0, 8)) {
|
|
69
71
|
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`);
|
|
72
|
+
assert.ok(html.includes(escapeHtml(unDash(t.title))), `${stage}: task ${t.id} is named`);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
assert.match(html, /Show all 15/);
|
|
@@ -171,3 +173,11 @@ test('the deploy page loads the module script only for its own view, and only wi
|
|
|
171
173
|
assert.deepEqual(bootDeploy({ modules: undefined }), [], 'no module record at all');
|
|
172
174
|
assert.deepEqual(bootDeploy({ modules: on, search: '?project=mercury' }), [], 'another project\'s view is not this package\'s');
|
|
173
175
|
});
|
|
176
|
+
|
|
177
|
+
test('a task title and a note line are quoted ledger data: their dashes render as commas (V4)', async () => {
|
|
178
|
+
const stages = { ...FIXTURE.stages, not_merged: [{ ...FIXTURE.stages.not_merged[0], title: 'Deploy page — where the work is – phase 1' }] };
|
|
179
|
+
const b = bootWork(() => ok({ ...FIXTURE, stages }));
|
|
180
|
+
await b.settle();
|
|
181
|
+
assert.ok(b.work().innerHTML.includes('>Deploy page, where the work is, phase 1</a>'));
|
|
182
|
+
assert.doesNotMatch(b.work().innerHTML, /Deploy page [—–]/);
|
|
183
|
+
});
|
|
@@ -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/);
|