@bongos/core 1.19.347 → 1.19.349

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.
@@ -0,0 +1,122 @@
1
+ // tests/client_baseurl_guard.mjs — the guard that stops a page 404ing every call
2
+ // (task 1003263).
3
+ //
4
+ // Three pages shipped the defect this guard exists to catch: ideas.js and idea.js
5
+ // were dead in production from the day they shipped, and collab.js was caught one
6
+ // commit before it would have joined them. So the guard itself is worth testing on
7
+ // synthetic sources rather than only on the live tree — a scan that passes because
8
+ // the tree happens to be clean today proves nothing about what it would REFUSE.
9
+ //
10
+ // Every case below is an in-memory file: no fixtures on disk, no dependence on the
11
+ // current state of modules/hall-ui/public/.
12
+ //
13
+ // Run: node tests/client_baseurl_guard.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const { checkClientBaseUrl, scanPages } = require('../scripts/gds/client-baseurl-guard.js');
20
+
21
+ let passed = 0;
22
+ let failed = 0;
23
+ function test(name, fn) {
24
+ try { fn(); passed++; console.log(` ok ${name}`); }
25
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
26
+ }
27
+
28
+ const from = (map) => ({
29
+ files: Object.keys(map),
30
+ read: (rel) => (rel in map ? map[rel] : null),
31
+ });
32
+
33
+ // --- THE DEFECT ------------------------------------------------------------
34
+
35
+ test('the exact shipped defect is refused: baseUrl:\'\' plus a namespaced call', () => {
36
+ const { violations } = scanPages(...Object.values(from({
37
+ 'modules/hall-ui/public/bad.js':
38
+ "const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
39
+ + 'const r = await api.inbox.getInboxTemplate({});\n',
40
+ })));
41
+ assert.equal(violations.length, 1);
42
+ assert.match(violations[0], /bad\.js/);
43
+ assert.match(violations[0], /site root/, 'the message says WHY it is wrong, not just that it is');
44
+ assert.match(violations[0], /Drop the baseUrl override/, 'and what to do about it');
45
+ });
46
+
47
+ test('double-quoted and backtick empty bases are the same defect', () => {
48
+ for (const empty of ['""', '``']) {
49
+ const { violations } = scanPages(...Object.values(from({
50
+ 'public/p.js': `const api = createClient({ baseUrl: ${empty} });\napi.me.getMe({});\n`,
51
+ })));
52
+ assert.equal(violations.length, 1, `baseUrl: ${empty} must be caught`);
53
+ }
54
+ });
55
+
56
+ // --- WHAT IT MUST NOT FLAG -------------------------------------------------
57
+ // A guard that fires on correct code gets suppressed, and then it protects nothing.
58
+
59
+ test("the escape-hatch style is CORRECT with baseUrl:'' — never flagged", () => {
60
+ // work.js, copy-desk.js and most of the hall do exactly this: empty base, but
61
+ // every path written out in full. Flagging it would be a false positive on the
62
+ // majority of the hall.
63
+ const { violations } = scanPages(...Object.values(from({
64
+ 'modules/hall-ui/public/work.js':
65
+ "const API = '/api/bongos';\n"
66
+ + "const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
67
+ + "const res = await api.request('GET', `${API}/tasks`);\n",
68
+ })));
69
+ assert.deepEqual(violations, []);
70
+ });
71
+
72
+ test('the fix itself passes — omitting baseUrl with namespaced calls is correct', () => {
73
+ const { violations } = scanPages(...Object.values(from({
74
+ 'modules/hall-ui/public/ideas.js':
75
+ "const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });\n"
76
+ + 'await api.inbox.getInboxTemplate({});\n',
77
+ })));
78
+ assert.deepEqual(violations, [], 'the shipped fix must not trip the guard that motivated it');
79
+ });
80
+
81
+ test('a non-empty baseUrl is a deliberate choice, not the defect', () => {
82
+ const { violations } = scanPages(...Object.values(from({
83
+ 'public/ext.js':
84
+ "const api = createClient({ baseUrl: 'https://other.example/api/bongos/v1' });\n"
85
+ + 'api.me.getMe({});\n',
86
+ })));
87
+ assert.deepEqual(violations, []);
88
+ });
89
+
90
+ test('a page that builds no client at all is ignored', () => {
91
+ const { violations, usingClient } = scanPages(...Object.values(from({
92
+ 'public/static.js': "document.querySelector('#x').textContent = 'hi';\n",
93
+ })));
94
+ assert.deepEqual(violations, []);
95
+ assert.equal(usingClient, 0);
96
+ });
97
+
98
+ // --- THE SCAN MUST NOT SILENTLY SUCCEED ------------------------------------
99
+
100
+ test('an empty enumeration is a hard FAIL, not a clean pass', () => {
101
+ // The failure mode that makes a guard worthless: git ls-files returns nothing,
102
+ // zero files are scanned, and the check reports green forever.
103
+ const r = checkClientBaseUrl({ files: [], read: () => null });
104
+ assert.equal(r.hardFail, true);
105
+ assert.match(r.violations[0], /scan defect/);
106
+ });
107
+
108
+ test('an unreadable file is skipped without being counted as scanned', () => {
109
+ const { scanned } = scanPages(['gone.js'], () => null);
110
+ assert.equal(scanned, 0);
111
+ });
112
+
113
+ test('the check reports hardFail so CI actually stops', () => {
114
+ const r = checkClientBaseUrl(from({
115
+ 'public/bad.js': "createClient({ baseUrl: '' });\napi.inbox.postInbox({});\n",
116
+ }));
117
+ assert.equal(r.ok, false);
118
+ assert.equal(r.hardFail, true, 'a page dead in prod is not a warning');
119
+ });
120
+
121
+ console.log(`\nclient_baseurl_guard: ${passed} passed, ${failed} failed`);
122
+ process.exit(failed ? 1 : 0);
@@ -112,6 +112,17 @@ await test('the page gets both the FORM and the METER from the server', () => {
112
112
  assert.match(IDEAS_JS, /getInboxTemplate/, 'the form is rendered from GET /inbox/template');
113
113
  assert.match(IDEAS_JS, /postInboxScore/, 'the meter is filled from POST /inbox/score');
114
114
  assert.match(IDEAS_JS, /postInbox\b/, 'and it files through POST /inbox');
115
+
116
+ // ...AND THOSE CALLS MUST REACH THE API. The three assertions above are regexes
117
+ // over the SOURCE TEXT, so they passed for months while the page 404'd on every
118
+ // one of them: baseUrl:'' told the client the API-relative templates were already
119
+ // absolute, and each request went to the site root. Naming a method proves the
120
+ // call is written, never that it is addressed — task 1003263.
121
+ assert.doesNotMatch(
122
+ IDEAS_JS,
123
+ /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|"")/,
124
+ "baseUrl:'' sends the generated client's API-relative paths to the site root",
125
+ );
115
126
  });
116
127
 
117
128
  await test('the page does not print a pass bar — that rule lives in economy', () => {
@@ -99,7 +99,19 @@ test('a "fetch first" rejection names the branch and the reset-onto-origin remed
99
99
  assert.match(out, /reset/i, 'names the reset');
100
100
  assert.match(out, /generator/i, 'and the regenerate step before re-shipping');
101
101
  assert.match(out, /re-ship|fast-forward/i, 'and why: so the push fast-forwards');
102
- assert.ok(!/\n/.test(out), 'multi-line git stderr is collapsed — this goes mid-sentence into one log line');
102
+ // CONTRACT CHANGED, task 1003222. This used to assert the OPPOSITE — that the
103
+ // whole thing collapsed to one line — which was right while the server clipped
104
+ // stderr to 200 chars, and wrong once it stopped. The summary line is still
105
+ // collapsed and scannable; the verbatim text is quoted BELOW it, because git puts
106
+ // the line that names the cause last and a one-line squash buries it.
107
+ const [summary] = out.split('\n');
108
+ assert.ok(!/\n/.test(summary), 'the SUMMARY is still one scannable line');
109
+ assert.match(summary, /reason:/, 'and still carries a reason inline');
110
+ assert.match(out, /what the remote actually said/, 'the untruncated stderr is quoted below');
111
+ assert.ok(
112
+ out.includes("error: failed to push some refs to 'github.com/org/repo.git'"),
113
+ 'the remote message survives VERBATIM, on its own line — the whole point of the task',
114
+ );
103
115
  });
104
116
 
105
117
  test('the "non-fast-forward" wording triggers the same hint through the envelope', () => {
@@ -158,5 +170,77 @@ test('1003221 a non-NFF failure gains no hint from either flag', () => {
158
170
  }
159
171
  });
160
172
 
173
+
174
+ // --- task 1003222: the remote's own words reach the builder ------------------
175
+ //
176
+ // The failure this closes: a builder saw `reason: error: failed` — clipped
177
+ // mid-word by a 200-char slice on top of an earlier 500-char one — while the line
178
+ // that named the cause (`remote: fatal error in commit_refs`) and the HTTP status
179
+ // existed server-side only. That cost hours AND produced a wrong first diagnosis,
180
+ // because the real message was never in the room.
181
+
182
+ const LONG_STDERR = [
183
+ 'To https://github.com/org/repo.git',
184
+ ' ! [remote rejected] b -> b (pre-receive hook declined)',
185
+ 'error: failed to push some refs',
186
+ 'remote: fatal error in commit_refs', // git's useful line is LAST
187
+ ].join('\n');
188
+
189
+ test('1003222 the LAST line survives — a head-truncation would have eaten it', () => {
190
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: LONG_STDERR });
191
+ assert.ok(
192
+ out.includes('remote: fatal error in commit_refs'),
193
+ 'the cause git printed last must reach the builder',
194
+ );
195
+ });
196
+
197
+ test('1003222 the git exit code is reported — 128 and 1 are different remedies', () => {
198
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: 'boom', git_exit_code: 128 });
199
+ assert.match(out, /git exit 128/);
200
+ });
201
+
202
+ test('1003222 a GitHub HTTP status is reported when the API refused', () => {
203
+ const out = fmt({ error: 'pr_create_failed', github_status: 422, reason: 'No commits between main and b' });
204
+ assert.match(out, /GitHub HTTP 422/, '422 vs 403 vs 404 are three different fixes');
205
+ });
206
+
207
+ test('1003222 a timeout says so rather than looking like a rejection', () => {
208
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: 'killed', timed_out: true });
209
+ assert.match(out, /TIMED OUT/);
210
+ });
211
+
212
+ test('1003222 a single-line stderr is NOT repeated in a quote block', () => {
213
+ // It is already fully present in the summary; quoting it again is noise.
214
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: 'fatal: repository not found' });
215
+ assert.ok(!out.includes('what the remote actually said'), 'no block for a one-liner');
216
+ assert.match(out, /fatal: repository not found/);
217
+ });
218
+
219
+ test('1003222 an omitted middle is DECLARED, never silently clipped', () => {
220
+ const out = fmt({
221
+ error: 'publish_failed', step: 'push', reason: LONG_STDERR, reason_bytes: 99999,
222
+ });
223
+ assert.match(out, /99999 bytes at the server/, 'the reader is told text is missing');
224
+ assert.match(out, /omitted/);
225
+ });
226
+
227
+ test('1003222 the remedy precedes the quote block, not stranded under it', () => {
228
+ // The remedy is the actionable sentence. Below twenty lines of git chatter it
229
+ // reads as a footnote to the quotation and gets missed.
230
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: NON_FF_STDERR });
231
+ const remedy = out.indexOf('reset onto');
232
+ const quote = out.indexOf('what the remote actually said');
233
+ assert.ok(remedy > -1 && quote > -1, 'both present');
234
+ assert.ok(remedy < quote, 'remedy comes first');
235
+ });
236
+
237
+ test('1003222 nothing regressed for a failure carrying no stderr at all', () => {
238
+ const out = fmt({ error: 'publish_failed' });
239
+ assert.match(out, /publish_failed/);
240
+ assert.ok(!out.includes('what the remote actually said'));
241
+ assert.ok(!/undefined|null|\[object/.test(out), 'no placeholder leakage');
242
+ });
243
+
244
+
161
245
  console.log(`ship_publish_error: ${passed} passed, ${failed} failed`);
162
- process.exit(failed === 0 ? 0 : 1);
246
+ process.exit(failed === 0 ? 0 : 1);
@@ -8,6 +8,8 @@
8
8
  // 4. parseNpmViewVersions — the array / bare-string / empty shapes npm view emits.
9
9
  // 5. listAvailableVersions — ok + fail-soft with an injected runner.
10
10
  // 6. loadSubscriptions — missing/malformed → empty; channel default + validate; env kept; dropless.
11
+ // 7. describeRoster — an empty roster REPORTS WHY, and is an alarm on a control plane (task 1003158).
12
+ // 8. subscriptionStaleness — releases the channel was entitled to and did not take = "the upgrader stopped".
11
13
  //
12
14
  // DB-free + network-free (every external is injected). Run: node tests/update_channel.mjs
13
15
 
@@ -182,6 +184,129 @@ t('loadSubscriptions: an entry without a dir is dropped (cannot be upgraded)', (
182
184
  assert.equal(r.instances[0].slug, 'real');
183
185
  });
184
186
 
187
+ // ---- describeRoster: fails closed, no longer fails quiet (task 1003158) ----
188
+ //
189
+ // The regression these pin: before this, a missing manifest, a malformed manifest and a deliberately
190
+ // empty roster were INDISTINGUISHABLE — all three returned a bare `instances: []` and the routine
191
+ // shrugged. cloudbongos.com sat 36 releases behind for days because "nothing happened" looked
192
+ // identical to "nothing needed to happen". Every empty roster must now say WHY, every run.
193
+
194
+ t('loadSubscriptions: missing file reports reason manifest-absent (not just an empty list)', () => {
195
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-none2-'));
196
+ const r = uc.loadSubscriptions({ instanceDir: dir });
197
+ assert.deepEqual(r.instances, []);
198
+ assert.equal(r.reason, uc.ROSTER_ABSENT);
199
+ assert.equal(r.dropped, 0);
200
+ });
201
+
202
+ t('loadSubscriptions: malformed JSON reports reason manifest-malformed + keeps the error', () => {
203
+ const dir = scratchWith('{ not valid');
204
+ const r = uc.loadSubscriptions({ instanceDir: dir });
205
+ assert.equal(r.reason, uc.ROSTER_MALFORMED);
206
+ assert.match(r.error, /malformed JSON/);
207
+ });
208
+
209
+ t('loadSubscriptions: parsed-but-unsubscribable reports roster-empty and COUNTS the drops', () => {
210
+ const dir = scratchWith({ instances: [{ slug: 'ghost' }, { slug: 'alsoghost' }] });
211
+ const r = uc.loadSubscriptions({ instanceDir: dir });
212
+ assert.deepEqual(r.instances, []);
213
+ assert.equal(r.reason, uc.ROSTER_EMPTY);
214
+ assert.equal(r.dropped, 2);
215
+ });
216
+
217
+ t('loadSubscriptions: a usable roster reports ok', () => {
218
+ const dir = scratchWith({ instances: [{ slug: 'cb', dir: '/srv/cb' }] });
219
+ const r = uc.loadSubscriptions({ instanceDir: dir });
220
+ assert.equal(r.reason, uc.ROSTER_OK);
221
+ assert.equal(r.dropped, 0);
222
+ });
223
+
224
+ t('describeRoster: an empty roster on a CONTROL PLANE is an alarm, not a shrug', () => {
225
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-cp-'));
226
+ const d = uc.describeRoster(uc.loadSubscriptions({ instanceDir: dir }), { controlPlane: true });
227
+ assert.equal(d.level, 'alarm');
228
+ assert.match(d.message, /CONTROL PLANE/);
229
+ assert.match(d.message, /subscribing NOTHING/);
230
+ });
231
+
232
+ t('describeRoster: the same empty roster on a plain instance is only a warn (shipped default)', () => {
233
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-plain-'));
234
+ const d = uc.describeRoster(uc.loadSubscriptions({ instanceDir: dir }));
235
+ assert.equal(d.level, 'warn');
236
+ assert.match(d.message, /shipped default/);
237
+ });
238
+
239
+ t('describeRoster: a malformed manifest is an alarm everywhere', () => {
240
+ const d = uc.describeRoster(uc.loadSubscriptions({ instanceDir: scratchWith('{ nope') }));
241
+ assert.equal(d.level, 'alarm');
242
+ assert.match(d.message, /MALFORMED/);
243
+ });
244
+
245
+ t('describeRoster: partial loss is surfaced — a healthy-looking sweep that silently skips a row', () => {
246
+ const dir = scratchWith({ instances: [{ slug: 'cb', dir: '/srv/cb' }, { slug: 'ghost' }] });
247
+ const d = uc.describeRoster(uc.loadSubscriptions({ instanceDir: dir }));
248
+ assert.equal(d.level, 'warn');
249
+ assert.equal(d.dropped, 1);
250
+ assert.match(d.message, /DROPPED/);
251
+ });
252
+
253
+ t('describeRoster: a clean roster is ok and says how many it holds', () => {
254
+ const dir = scratchWith({ instances: [{ slug: 'cb', dir: '/srv/cb' }] });
255
+ const d = uc.describeRoster(uc.loadSubscriptions({ instanceDir: dir }), { controlPlane: true });
256
+ assert.equal(d.level, 'ok');
257
+ assert.equal(d.count, 1);
258
+ });
259
+
260
+ // ---- subscriptionStaleness: watching the watcher ---------------------------
261
+
262
+ const RELEASES = Array.from({ length: 41 }, (_, i) => `1.19.${250 + i}`); // 1.19.250 … 1.19.290
263
+
264
+ t('subscriptionStaleness: reproduces the incident — 1.19.254 on patch is 36 releases behind, stale', () => {
265
+ const st = uc.subscriptionStaleness({ installed: '1.19.254', channel: 'patch', available: RELEASES });
266
+ assert.equal(st.behind, 36);
267
+ assert.equal(st.target, '1.19.290');
268
+ assert.equal(st.stale, true);
269
+ });
270
+
271
+ t('subscriptionStaleness: a swept instance is 0 behind and not stale', () => {
272
+ const st = uc.subscriptionStaleness({ installed: '1.19.290', channel: 'patch', available: RELEASES });
273
+ assert.equal(st.behind, 0);
274
+ assert.equal(st.target, null);
275
+ assert.equal(st.stale, false);
276
+ });
277
+
278
+ t('subscriptionStaleness: a busy publish-on-merge day under the threshold does NOT cry wolf', () => {
279
+ const st = uc.subscriptionStaleness({ installed: '1.19.287', channel: 'patch', available: RELEASES });
280
+ assert.equal(st.behind, 3);
281
+ assert.equal(st.stale, false);
282
+ });
283
+
284
+ t('subscriptionStaleness: a pinned instance is never stale (declining is not neglect)', () => {
285
+ const st = uc.subscriptionStaleness({ installed: '1.19.254', channel: 'pinned', available: RELEASES });
286
+ assert.equal(st.behind, 0);
287
+ assert.equal(st.stale, false);
288
+ });
289
+
290
+ t('subscriptionStaleness: counts only what the CHANNEL may take — minors/majors are not a backlog', () => {
291
+ const st = uc.subscriptionStaleness({ installed: '1.19.254', channel: 'patch', available: ['1.20.0', '1.20.7', '2.0.0'] });
292
+ assert.equal(st.behind, 0);
293
+ assert.equal(st.stale, false);
294
+ // the same list on the minor channel IS a backlog
295
+ assert.equal(uc.subscriptionStaleness({ installed: '1.19.254', channel: 'minor', available: ['1.20.0', '1.20.7', '2.0.0'] }).behind, 2);
296
+ });
297
+
298
+ t('subscriptionStaleness: threshold is overridable; a bad threshold falls back to the default', () => {
299
+ assert.equal(uc.subscriptionStaleness({ installed: '1.19.287', channel: 'patch', available: RELEASES, threshold: 1 }).stale, true);
300
+ assert.equal(uc.subscriptionStaleness({ installed: '1.19.287', channel: 'patch', available: RELEASES, threshold: 'nonsense' }).threshold, uc.DEFAULT_STALENESS_THRESHOLD);
301
+ });
302
+
303
+ t('describeStaleness: silent when healthy, explicit about "the upgrader stopped" when not', () => {
304
+ assert.equal(uc.describeStaleness(uc.subscriptionStaleness({ installed: '1.19.290', channel: 'patch', available: RELEASES })), null);
305
+ const msg = uc.describeStaleness(uc.subscriptionStaleness({ installed: '1.19.254', channel: 'patch', available: RELEASES }), { slug: 'cloudbongos.com' });
306
+ assert.match(msg, /cloudbongos\.com is 36 patch releases behind/);
307
+ assert.match(msg, /upgrader has most likely stopped/);
308
+ });
309
+
185
310
  console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
186
311
  process.exit(failed ? 1 : 0);
187
312