@bongos/core 1.19.1042 → 1.19.1043

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.
@@ -305,6 +305,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
305
305
  const requests = [];
306
306
  const writes = [];
307
307
  const toasts = [];
308
+ const dispatched = []; // task 1004301: what the page announced (otb:task-shown)
308
309
  const els = new Map();
309
310
  const makeEl = () => ({
310
311
  dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
@@ -326,6 +327,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
326
327
  querySelectorAll() { return []; },
327
328
  createElement() { return makeEl(); },
328
329
  addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
330
+ dispatchEvent(e) { dispatched.push(e); return true; },
329
331
  };
330
332
  const client = {
331
333
  request: async (method, p, opts) => {
@@ -393,7 +395,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
393
395
  confirm: () => true,
394
396
  navigator: { clipboard: { writeText: async () => {} } },
395
397
  };
396
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams };
398
+ class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
399
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
397
400
  sandbox.globalThis = sandbox;
398
401
  vm.createContext(sandbox);
399
402
  if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
@@ -407,6 +410,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
407
410
  requests,
408
411
  writes,
409
412
  toasts,
413
+ dispatched,
414
+ win: windowObj,
410
415
  };
411
416
  }
412
417
 
@@ -1052,3 +1057,20 @@ test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim"
1052
1057
  `the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
1053
1058
  assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
1054
1059
  });
1060
+
1061
+ // task 1004301 — a slot a MODULE may fill, and the announcement that tells it which task.
1062
+ // The page names no module: it draws an empty #task-contrib under the lifecycle rail and
1063
+ // announces each render, and a module's task-widget.js does the rest.
1064
+ test('the task page renders an empty module slot and announces every render', async () => {
1065
+ const r = renderTask({ ...BASE_TASK });
1066
+ r.boot();
1067
+ for (let i = 0; i < 5; i++) await settle();
1068
+ const html = r.html();
1069
+ assert.match(html, /<div class="tk-contrib" id="task-contrib" aria-live="polite"><\/div>/, 'an empty slot');
1070
+ assert.ok(html.indexOf('id="task-contrib"') > html.indexOf('class="tk-rail"'), 'under the lifecycle rail');
1071
+ const shown = r.dispatched.filter((e) => e.type === 'otb:task-shown');
1072
+ assert.equal(shown.length, 1);
1073
+ assert.deepEqual({ ...shown[0].detail }, { id: '1003305', status: 'shipped' }, 'only the id and the status travel');
1074
+ assert.deepEqual({ ...r.win.OTBTaskShown }, { id: '1003305', status: 'shipped' }, 'left on window for a widget that loads late');
1075
+ assert.doesNotMatch(read('task.js'), /npm-release/, 'the core page names no module');
1076
+ });
@@ -330,6 +330,33 @@ test('hallWidgetScripts: a module with no uiSections is never listed', () => {
330
330
  loader._reset();
331
331
  });
332
332
 
333
+ // task 1004301 — the same seam, one file per page: task-widget.js for a task's own page.
334
+ test('hallWidgetScripts: file task-widget.js lists the modules that ship one, and only those', () => {
335
+ loader._reset();
336
+ const root = fixtureRoot({
337
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/task-widget.js': '// task line' } },
338
+ beta: { manifest: validManifest('beta', { contributes: { uiSections: ['b'] } }), files: { 'public/hall-widget.js': '// index only' } },
339
+ });
340
+ const opts = { roots: [root], core: '1.0.0' };
341
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file: 'task-widget.js' }, opts),
342
+ [{ key: 'alpha', src: '/modules/alpha/public/task-widget.js' }]);
343
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true }, opts),
344
+ [{ key: 'beta', src: '/modules/beta/public/hall-widget.js' }], 'the default is still the index widget');
345
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: (k) => k !== 'alpha', file: 'task-widget.js' }, opts), [], 'disabled: never listed');
346
+ loader._reset();
347
+ });
348
+
349
+ test('hallWidgetScripts: a file name outside the page allowlist lists nothing — it is not a file server', () => {
350
+ loader._reset();
351
+ const root = fixtureRoot({
352
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/secrets.js': '//', 'public/task-widget.js': '//' } },
353
+ });
354
+ for (const file of ['secrets.js', '../module.json', 'public/task-widget.js', 'TASK-WIDGET.JS']) {
355
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file }, { roots: [root], core: '1.0.0' }), [], file);
356
+ }
357
+ loader._reset();
358
+ });
359
+
333
360
  // --- moduleWebSurfaces: an enabled module's host-routed static surface resolves,
334
361
  // a disabled one vanishes (BV1.R84 / task 1635, ADR 0093 §1 / 02-module-map §6) ---
335
362
 
@@ -0,0 +1,323 @@
1
+ // tests/npm_release_where.mjs — a task's own page says where its code is (task 1004301):
2
+ // the npm-release module's one-task reading (readTaskWhere), its route, the browser widget
3
+ // that draws the line, and the core seam that puts the widget on the task page without the
4
+ // page naming the module.
5
+ //
6
+ // WHAT IS AT RISK. The line is read by the person who shipped the task, and the case the
7
+ // task exists for is the ship that never landed: "done, but not merged" must be said for a
8
+ // completed/confirmed task, never a version it is not in. Every other stage must be the
9
+ // deploy page's own answer, the widget must write only into the slot the page gives it and
10
+ // only for the task still on screen, and a failure must draw nothing rather than something
11
+ // wrong.
12
+ //
13
+ // Run: node tests/npm_release_where.mjs
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+ import { npmTarball, npmPackument, fakeHttpsGet } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const { readTaskWhere } = require('../modules/npm-release/work.js');
26
+ const buildRouter = require('../modules/npm-release/routes/task-where.js');
27
+
28
+ const ORIGIN = 'https://registry.npmjs.org';
29
+ const PKG = '@bongos/core';
30
+ const TIMES = {
31
+ '1.0.1': '2026-09-22T10:00:00.000Z',
32
+ '1.0.2': '2026-09-23T10:00:00.000Z',
33
+ '1.0.3': '2026-09-24T10:00:00.000Z',
34
+ '1.0.4': '2026-09-25T10:00:00.000Z',
35
+ };
36
+ const NOTES = {
37
+ '1.0.1': [{ id: '11', text: 'Released long ago.' }],
38
+ '1.0.2': [{ id: '12', text: 'Running here, not released.' }],
39
+ '1.0.3': [{ id: '13', text: 'Published, not running.' }, { text: 'A hand-landed change nobody numbered.' }],
40
+ '1.0.4': [],
41
+ };
42
+ const TASKS = {
43
+ 11: { status: 'shipped', value_summary: 'x', at: '2026-09-21T12:00:00Z' },
44
+ 12: { status: 'shipped', value_summary: 'x', at: '2026-09-22T12:00:00Z' },
45
+ 13: { status: 'shipped', value_summary: 'x', at: '2026-09-23T12:00:00Z' },
46
+ 14: { status: 'shipped', value_summary: 'A hand-landed change nobody numbered.', at: '2026-09-23T13:00:00Z' },
47
+ 15: { status: 'shipped', value_summary: 'Merged after the last version.', at: '2026-09-25T12:00:00Z' },
48
+ 16: { status: 'shipped', value_summary: 'A scope edit.', at: '2026-09-20T12:00:00Z' },
49
+ 20: { status: 'confirmed', value_summary: 'Stranded.', at: '2026-09-25T12:00:00Z' },
50
+ 21: { status: 'completed', value_summary: null, at: '2026-09-25T13:00:00Z' },
51
+ 30: { status: 'active', value_summary: null, at: '2026-09-25T13:00:00Z' },
52
+ };
53
+
54
+ function fakePool() {
55
+ const queries = [];
56
+ return {
57
+ queries,
58
+ async query(sql, params) {
59
+ queries.push({ sql, params });
60
+ if (/FROM tasks WHERE id = \$1::bigint/.test(sql)) {
61
+ const t = TASKS[params[0]];
62
+ return { rows: t ? [{ id: params[0], ...t }] : [] };
63
+ }
64
+ throw new Error(`unexpected query: ${sql}`);
65
+ },
66
+ };
67
+ }
68
+ const freshStore = () => ({ packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } });
69
+
70
+ function setup({ latest = '1.0.1', pkStatus = 200 } = {}) {
71
+ const versions = Object.keys(TIMES);
72
+ const newest = versions[versions.length - 1];
73
+ const fg = fakeHttpsGet({
74
+ [`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ? npmPackument({ versions, latest, times: TIMES }) : 'nope' },
75
+ [`${ORIGIN}/@bongos/core/-/core-${newest}.tgz`]: { body: npmTarball({ notes: NOTES }) },
76
+ });
77
+ const pool = fakePool();
78
+ const store = freshStore();
79
+ const read = (id, over = {}) => readTaskWhere({ pool, id, pkg: PKG, live: '1.0.2', get: fg.get, store, installedNotes: () => null, now: () => Date.parse('2026-09-26T13:00:00Z'), ...over });
80
+ return { read, pool, asked: fg.asked };
81
+ }
82
+
83
+ // ---- the reading ---------------------------------------------------------------------
84
+
85
+ test('each shipped task gets the deploy page\'s own stage and the version that carries it', async () => {
86
+ const { read } = setup();
87
+ const pick = async (id) => { const w = await read(id); return [w.stage, w.version]; };
88
+ assert.deepEqual(await pick(11), ['released', '1.0.1']);
89
+ assert.deepEqual(await pick(12), ['live_unreleased', '1.0.2']);
90
+ assert.deepEqual(await pick(13), ['not_live', '1.0.3']);
91
+ assert.deepEqual(await pick(14), ['not_live', '1.0.3'], 'a hand-landed merge is matched on the ship summary');
92
+ assert.deepEqual(await pick(15), ['unversioned', null], 'merged after the newest version went out');
93
+ assert.deepEqual(await pick(16), ['no_version', null]);
94
+ const w = await read(12);
95
+ assert.equal(w.live, '1.0.2');
96
+ assert.equal(w.released, '1.0.1');
97
+ assert.equal(w.partial, false);
98
+ assert.equal(w.task_id, '12');
99
+ });
100
+
101
+ test('a task marked done whose code never merged says so — the case this line exists for', async () => {
102
+ const { read } = setup();
103
+ for (const id of [20, 21]) {
104
+ const w = await read(id);
105
+ assert.equal(w.stage, 'not_merged', String(id));
106
+ assert.equal(w.version, null);
107
+ }
108
+ });
109
+
110
+ test('a task not yet done has nothing to place, and costs no registry read', async () => {
111
+ const { read, asked } = setup();
112
+ const w = await read(30);
113
+ assert.deepEqual(w, { task_id: '30', package: PKG, status: 'active', stage: null });
114
+ assert.deepEqual(asked, [], 'no packument, no tarball');
115
+ });
116
+
117
+ test('an id that is not a task answers null; one that is not a number is never queried', async () => {
118
+ const { read, pool } = setup();
119
+ assert.equal(await read(999), null);
120
+ for (const bad of ['1; DROP', '', null, '12a', '1234567890123']) assert.equal(await read(bad), null, String(bad));
121
+ assert.equal(pool.queries.length, 1, 'only the real id reached the database');
122
+ });
123
+
124
+ test('a registry that does not answer is a partial reading, said so', async () => {
125
+ const { read } = setup({ pkStatus: 500 });
126
+ const w = await read(12);
127
+ assert.equal(w.partial, true);
128
+ assert.equal(w.stage, 'no_version', 'with no notes nothing can be placed in a version');
129
+ });
130
+
131
+ test('the offered flag follows the deploy page: published before this hall ran it, and already released', async () => {
132
+ const { read } = setup({ latest: '1.0.4' });
133
+ const w = await read(13);
134
+ assert.equal(w.stage, 'not_live');
135
+ assert.equal(w.offered, true);
136
+ });
137
+
138
+ // ---- the route -----------------------------------------------------------------------
139
+
140
+ function layers(read) {
141
+ const router = buildRouter({ read });
142
+ const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/task/:id');
143
+ return layer.route.stack.map((s) => s.handle);
144
+ }
145
+ function call(handler, id) {
146
+ return new Promise((resolve) => {
147
+ const res = { code: 200, status(c) { this.code = c; return this; }, json(v) { resolve({ status: this.code, body: v }); } };
148
+ Promise.resolve(handler({ params: { id } }, res)).catch((e) => resolve({ status: 'threw', body: e }));
149
+ });
150
+ }
151
+
152
+ test('the route asks only for a signed-in builder — nothing on the line is not already public', () => {
153
+ const handles = layers(async () => ({}));
154
+ assert.equal(handles.length, 2, 'requireBuilder, then the handler');
155
+ const src = fs.readFileSync(path.join(ROOT, 'modules/npm-release/routes/task-where.js'), 'utf8');
156
+ assert.match(src, /'\/npm-release\/task\/:id',\s*\n\s*api\.requireBuilder,\s*\n\s*async/);
157
+ assert.doesNotMatch(src, /requirePermission|api\.requireRank\(/);
158
+ });
159
+
160
+ test('the route: 400 for a non-number, 404 for no task, 500 plainly on a failed read', async () => {
161
+ const h = (read) => layers(read)[1];
162
+ assert.deepEqual((await call(h(async () => ({ stage: 'released' })), '12')).body, { stage: 'released' });
163
+ assert.equal((await call(h(async () => { throw new Error('no'); }), 'x')).status, 400);
164
+ assert.equal((await call(h(async () => null), '12')).status, 404);
165
+ const failed = await call(h(async () => { throw new Error('pool gone'); }), '12');
166
+ assert.equal(failed.status, 500);
167
+ assert.equal(failed.body.error.code, 'task_where_failed');
168
+ assert.doesNotMatch(JSON.stringify(failed.body), /pool gone/, 'the internal message stays in the log');
169
+ });
170
+
171
+ // ---- the widget ----------------------------------------------------------------------
172
+
173
+ const WIDGET_SRC = fs.readFileSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js'), 'utf8');
174
+
175
+ function bootWidget(answer, { shown = null } = {}) {
176
+ const slot = { _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
177
+ const listeners = {};
178
+ const asked = [];
179
+ const windowObj = {
180
+ OTBTaskShown: shown,
181
+ BongosClient: { createClient: () => ({ request: async (m, url) => { asked.push(url); return answer(url); } }) },
182
+ };
183
+ const documentObj = {
184
+ getElementById: (id) => (id === 'task-contrib' ? slot : null),
185
+ addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
186
+ };
187
+ const sandbox = { window: windowObj, document: documentObj, console, Map, Promise, encodeURIComponent, String };
188
+ vm.createContext(sandbox);
189
+ vm.runInContext(WIDGET_SRC, sandbox, { filename: 'task-widget.js' });
190
+ const announce = (detail) => { windowObj.OTBTaskShown = detail; for (const fn of listeners['otb:task-shown'] || []) fn({ detail }); };
191
+ const settle = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
192
+ return { slot, asked, announce, settle, win: windowObj };
193
+ }
194
+ const ok = (data) => ({ status: 200, ok: true, data });
195
+
196
+ test('each stage reads as one plain sentence under the rail', async () => {
197
+ const cases = [
198
+ [{ stage: 'released', version: '1.19.1036', live: '1.19.1040' }, /In <b>1\.19\.1036<\/b> · running here · released to other projects\./],
199
+ [{ stage: 'live_unreleased', version: '1.19.1040', live: '1.19.1040' }, /In <b>1\.19\.1040<\/b> · running here · not released to other projects yet\./],
200
+ [{ stage: 'not_live', version: '1.19.1041' }, /In <b>1\.19\.1041<\/b> · published, not running here yet\./],
201
+ [{ stage: 'not_live', version: '1.19.1041', offered: true }, /not running here yet · already offered to other projects\./],
202
+ [{ stage: 'unversioned' }, /Merged, waiting for the next published version\./],
203
+ [{ stage: 'no_version' }, /Shipped, though no published version lists it — usually a change that needed no code/],
204
+ [{ stage: 'not_merged' }, /<b>Done, but not merged\.<\/b> The ledger marks this task done, and its code is not on main yet/],
205
+ ];
206
+ for (const [w, re] of cases) {
207
+ const b = bootWidget(() => ok(w));
208
+ b.announce({ id: '12', status: w.stage === 'not_merged' ? 'confirmed' : 'shipped' });
209
+ await b.settle();
210
+ assert.match(b.slot.innerHTML, re, w.stage);
211
+ }
212
+ });
213
+
214
+ test('a partial reading is said; a version is escaped; an unknown stage draws nothing', async () => {
215
+ const partial = bootWidget(() => ok({ stage: 'no_version', partial: true }));
216
+ partial.announce({ id: '12', status: 'shipped' });
217
+ await partial.settle();
218
+ assert.match(partial.slot.innerHTML, /\(a partial reading: the registry or the release notes could not be read\)/);
219
+
220
+ const evil = bootWidget(() => ok({ stage: 'released', version: '<img src=x>' }));
221
+ evil.announce({ id: '12', status: 'shipped' });
222
+ await evil.settle();
223
+ assert.match(evil.slot.innerHTML, /In <b>&lt;img src=x&gt;<\/b>/);
224
+
225
+ const odd = bootWidget(() => ok({ stage: 'somewhere-else' }));
226
+ odd.announce({ id: '12', status: 'shipped' });
227
+ await odd.settle();
228
+ assert.equal(odd.slot.innerHTML, '');
229
+ });
230
+
231
+ test('work not yet done asks nothing; a failure draws nothing rather than something wrong', async () => {
232
+ const idle = bootWidget(() => ok({ stage: 'released' }));
233
+ idle.announce({ id: '12', status: 'active' });
234
+ await idle.settle();
235
+ assert.deepEqual(idle.asked, []);
236
+ assert.equal(idle.slot.innerHTML, '');
237
+
238
+ for (const answer of [() => ({ status: 403, ok: false, data: null }), () => ({ status: 404, ok: false, data: null }), () => { throw new Error('offline'); }]) {
239
+ const b = bootWidget(answer);
240
+ b.announce({ id: '12', status: 'shipped' });
241
+ await b.settle();
242
+ assert.equal(b.slot.innerHTML, '');
243
+ }
244
+ });
245
+
246
+ test('one read per task, however often the page re-renders — and never for a task no longer shown', async () => {
247
+ const b = bootWidget(() => ok({ stage: 'released', version: '1.0.1' }));
248
+ b.announce({ id: '12', status: 'shipped' });
249
+ b.announce({ id: '12', status: 'shipped' });
250
+ await b.settle();
251
+ assert.deepEqual(b.asked, ['/api/bongos/npm-release/task/12']);
252
+
253
+ // The page moved on to another task while the first read was in flight.
254
+ let release;
255
+ const slow = bootWidget(() => new Promise((r) => { release = () => r(ok({ stage: 'released', version: '9.9.9' })); }));
256
+ slow.announce({ id: '12', status: 'shipped' });
257
+ slow.win.OTBTaskShown = { id: '13', status: 'active' };
258
+ release();
259
+ await slow.settle();
260
+ assert.equal(slow.slot.innerHTML, '', 'task 12\'s line is not written onto task 13\'s page');
261
+ });
262
+
263
+ test('a widget that loads after the first announcement still draws the line', async () => {
264
+ const b = bootWidget(() => ok({ stage: 'released', version: '1.0.1' }), { shown: { id: '12', status: 'shipped' } });
265
+ await b.settle();
266
+ assert.match(b.slot.innerHTML, /In <b>1\.0\.1<\/b>/);
267
+ });
268
+
269
+ // ---- the seam that puts it on the page -----------------------------------------------
270
+
271
+ test('the module declares the task page section and ships its widget; the seam guard counts it wired', () => {
272
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/npm-release/module.json'), 'utf8'));
273
+ assert.deepEqual(manifest.contributes.uiSections, ['task-where']);
274
+ assert.ok(manifest.contributes.routes.includes('task-where'));
275
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js')));
276
+ const guard = fs.readFileSync(path.join(ROOT, 'scripts/gds/seam-wiring-guard.js'), 'utf8');
277
+ assert.match(guard, /alternates: \(dir\) => \[path\.join\(dir, 'public', 'task-widget\.js'\)\]/);
278
+ });
279
+
280
+ test('the server injects task widgets just before task.js, and names no module doing it', () => {
281
+ const { injectModuleHallWidgets } = require('../src/bongos/serve-internal.js');
282
+ const page = '<body><script src="/builders/md-reader.js" defer></script><script src="/builders/task.js?v=abc" defer></script></body>';
283
+ const out = injectModuleHallWidgets(page, [{ key: 'npm-release', src: '/modules/npm-release/public/task-widget.js' }], 'task');
284
+ assert.ok(out.indexOf('/modules/npm-release/public/task-widget.js') < out.indexOf('/builders/task.js'), 'before the consumer, so its listener exists first');
285
+ assert.ok(out.indexOf('/builders/md-reader.js') < out.indexOf('task-widget.js'));
286
+ assert.match(out, /<script src="\/modules\/npm-release\/public\/task-widget\.js" defer><\/script>/);
287
+ assert.equal(injectModuleHallWidgets(page, [], 'task'), page, 'no module, no change');
288
+ const index = '<script src="/builders/builders.js?v=1"></script>';
289
+ assert.match(injectModuleHallWidgets(index, [{ key: 'x', src: '/m.js' }]), /^<script src="\/m\.js" defer><\/script><script src="\/builders\/builders\.js/, 'the index injection is unchanged');
290
+ const si = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
291
+ assert.match(si, /hallWidgetScripts\(\{ isEnabled: isModuleEnabled, file: 'task-widget\.js' \}\),\s*\n\s*'task',/);
292
+ });
293
+
294
+ // ---- the fixes the first grade asked for ---------------------------------------------
295
+
296
+ test('the installed release notes are read from disk once per process, not once per view', async () => {
297
+ const realRead = fs.readFileSync;
298
+ let reads = 0;
299
+ fs.readFileSync = function (p, ...rest) {
300
+ if (String(p).endsWith('release-notes.json')) reads++;
301
+ return realRead.call(this, p, ...rest);
302
+ };
303
+ try {
304
+ const { pool } = setup();
305
+ const fg = fakeHttpsGet({ [`${ORIGIN}/@bongos%2fcore`]: { status: 500, body: 'down' } });
306
+ for (let i = 0; i < 3; i++) {
307
+ // No installedNotes: the module's own default, which is the path every task view takes.
308
+ await readTaskWhere({ pool, id: 12, pkg: PKG, live: '1.0.2', get: fg.get, store: freshStore() });
309
+ }
310
+ } finally { fs.readFileSync = realRead; }
311
+ assert.equal(reads, 1, 'three task views, one disk read');
312
+ });
313
+
314
+ test('the widget keeps only the last few readings', async () => {
315
+ const b = bootWidget((url) => ok({ stage: 'released', version: url.split('/').pop() }));
316
+ for (let i = 1; i <= 12; i++) b.announce({ id: String(i), status: 'shipped' });
317
+ await b.settle();
318
+ b.announce({ id: '12', status: 'shipped' });
319
+ b.announce({ id: '1', status: 'shipped' });
320
+ await b.settle();
321
+ assert.equal(b.asked.filter((u) => u.endsWith('/12')).length, 1, 'a recent task is served from the cache');
322
+ assert.equal(b.asked.filter((u) => u.endsWith('/1')).length, 2, 'an old one was evicted and is read again');
323
+ });