@bongos/core 1.21.178 → 1.21.180

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.
@@ -240,6 +240,85 @@ function localKnipPath() {
240
240
  let knipSkipReason = null;
241
241
  function lastKnipSkipReason() { return knipSkipReason; }
242
242
 
243
+ // ---- scoring knip's JSON (task 1004875) ----
244
+ //
245
+ // TWO THINGS THE OLD INLINE COUNT GOT WRONG, both found by putting modules/ in scope:
246
+ //
247
+ // 1. UNUSED FILES WERE NEVER COUNTED. It read a top-level `files` array; knip 6's
248
+ // JSON reporter has none — an unused file is an issue whose own `files` array is
249
+ // non-empty. So a brand-new dead file anywhere scored 0 and the ratchet waved it
250
+ // through. `files` is now one of the per-issue keys (the legacy top-level array is
251
+ // still read, for an older knip).
252
+ //
253
+ // 2. knip DOES NOT CREDIT A CommonJS NAMESPACE USE. Measured on a two-file repro:
254
+ // `const a = require('./a'); a.used()` leaves `used` reported as an unused export;
255
+ // only a destructured `const { used } = require('./a')` counts. src/ is mostly
256
+ // destructured (module-api.js says so), but modules/ is written the namespace way,
257
+ // so knip reports ~1,600 exports there that ARE used. Counting those would make
258
+ // every ordinary new export under modules/ red CI. So for a finding under
259
+ // modules/, an unused export counts only when its NAME appears in no other code
260
+ // file at all (a word match over .js/.mjs/.cjs/.html/.sh). That is the case knip
261
+ // is right about: exported, and nothing else even spells it. It under-counts (a
262
+ // name that happens to be spelled elsewhere hides) and never over-counts, which
263
+ // is the direction a gate that fails CI must err in. src/, scripts/ and tests/
264
+ // keep knip's own verdict, so their part of the baseline is unchanged.
265
+ const KNIP_ISSUE_KEYS = ['files', 'dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates'];
266
+ const NAME_FILTERED_PREFIX = 'modules/';
267
+ const WORD_SCAN_EXT_RE = /\.(m?js|cjs|html|sh)$/;
268
+ const WORD_SCAN_SKIP = new Set(['node_modules', '.git', 'docs', 'public-docs', 'worktrees']);
269
+ const WORD_RE = /[A-Za-z_$][\w$]*/g;
270
+
271
+ // word -> the one repo-relative file that uses it, or true when 2+ files do.
272
+ function codeWordIndex(root = ROOT) {
273
+ const index = new Map();
274
+ const walk = (dir) => {
275
+ let ents = [];
276
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
277
+ for (const e of ents) {
278
+ if (WORD_SCAN_SKIP.has(e.name)) continue;
279
+ const abs = path.join(dir, e.name);
280
+ if (e.isDirectory()) { walk(abs); continue; }
281
+ if (!WORD_SCAN_EXT_RE.test(e.name)) continue;
282
+ let text;
283
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
284
+ const rel = path.relative(root, abs).split(path.sep).join('/');
285
+ for (const w of new Set(text.match(WORD_RE) || [])) {
286
+ const seen = index.get(w);
287
+ if (seen === undefined) index.set(w, rel);
288
+ else if (seen !== true && seen !== rel) index.set(w, true);
289
+ }
290
+ }
291
+ };
292
+ walk(root);
293
+ return index;
294
+ }
295
+
296
+ function nameUsedOutside(index, name, file) {
297
+ const seen = index.get(name);
298
+ return seen === true || (seen !== undefined && seen !== file);
299
+ }
300
+
301
+ // knip JSON -> the integer the ratchet scores. `wordIndex` is built on demand, and
302
+ // only when a modules/ export finding needs it.
303
+ function countKnipFindings(json, { root = ROOT, wordIndex = null } = {}) {
304
+ let total = Array.isArray(json && json.files) ? json.files.length : 0;
305
+ let index = wordIndex;
306
+ for (const issue of (json && json.issues) || []) {
307
+ const filtered = typeof issue.file === 'string' && issue.file.startsWith(NAME_FILTERED_PREFIX);
308
+ for (const key of KNIP_ISSUE_KEYS) {
309
+ const v = issue[key];
310
+ if (!Array.isArray(v)) continue;
311
+ if (filtered && (key === 'exports' || key === 'types')) {
312
+ if (!index) index = codeWordIndex(root);
313
+ total += v.filter((e) => !(e && nameUsedOutside(index, e.name, issue.file))).length;
314
+ } else {
315
+ total += v.length;
316
+ }
317
+ }
318
+ }
319
+ return total;
320
+ }
321
+
243
322
  function knipIssueCount() {
244
323
  knipSkipReason = null;
245
324
  if (!localKnipPath()) {
@@ -259,22 +338,14 @@ function knipIssueCount() {
259
338
  if (e && e.stdout) { raw = e.stdout.toString(); }
260
339
  else { knipSkipReason = `npx knip failed to run in ${ROOT}`; return null; }
261
340
  }
341
+ let j;
262
342
  try {
263
- const j = JSON.parse(raw);
264
- // knip's json reporter: { files: [...], issues: [{ file, owners, exports:[], types:[], ... }] }
265
- const files = Array.isArray(j.files) ? j.files.length : 0;
266
- let items = 0;
267
- for (const issue of j.issues || []) {
268
- for (const key of ['dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates']) {
269
- const v = issue[key];
270
- items += Array.isArray(v) ? v.length : 0;
271
- }
272
- }
273
- return files + items;
343
+ j = JSON.parse(raw);
274
344
  } catch {
275
345
  knipSkipReason = 'knip produced output this script could not parse as JSON';
276
346
  return null;
277
347
  }
348
+ return countKnipFindings(j);
278
349
  }
279
350
 
280
351
  // ---------- baseline + ratchet ----------
@@ -586,6 +657,8 @@ module.exports = {
586
657
  consoleCallCount,
587
658
  isServerPath,
588
659
  knipIssueCount,
660
+ countKnipFindings,
661
+ codeWordIndex,
589
662
  localKnipPath,
590
663
  lastKnipSkipReason,
591
664
  OVERSIZE_LINES,
@@ -71,12 +71,14 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
71
71
  // droplet machine without Postgres reachable).
72
72
  function loadDeps() {
73
73
  // eslint-disable-next-line global-require
74
- const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
74
+ const { pool } = require('../../src/bongos/pool');
75
75
  // eslint-disable-next-line global-require
76
76
  // pulse.js (the lib) carved → modules/sessions/ (BV1.R82 / ADR 0093 §1); the CLI
77
77
  // shell stays here and requires the module lib directly (scripts are outside the
78
- // kernel boundary, so a direct module require is fine).
79
- const { sweepStaleClaims } = require(path.join(REPO_ROOT, 'modules/sessions/pulse'));
78
+ // kernel boundary, so a direct module require is fine). A LITERAL path, not
79
+ // path.join: the dead-code scanner (knip) cannot follow a computed require, and
80
+ // reported the lib as an unused file while this was its only caller (task 1004875).
81
+ const { sweepStaleClaims } = require('../../modules/sessions/pulse');
80
82
  return { pool, sweepStaleClaims };
81
83
  }
82
84
 
@@ -121,7 +121,9 @@ function scanDeadCode() {
121
121
  configured =
122
122
  !!pkg.knip ||
123
123
  fs.existsSync(path.join(REPO_ROOT, 'knip.json')) ||
124
- fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc'));
124
+ fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc')) ||
125
+ // The config moved to a JS file so it can generate modules/ entries (task 1004875).
126
+ fs.existsSync(path.join(REPO_ROOT, 'knip.config.js'));
125
127
  } catch (_) {
126
128
  configured = false;
127
129
  }
@@ -163,7 +165,8 @@ function scanDeadCode() {
163
165
  return acc + ex + ty;
164
166
  }, 0);
165
167
  deadFiles = report.issues.reduce(
166
- (acc, issue) => acc + (issue.files === true ? 1 : 0),
168
+ // knip 6 gives an ARRAY here ([{ name }]), not a bool (task 1004875).
169
+ (acc, issue) => acc + (issue.files === true || (Array.isArray(issue.files) && issue.files.length) ? 1 : 0),
167
170
  0
168
171
  );
169
172
  }
@@ -427,6 +427,9 @@ const MODULE_GLOBS = {
427
427
  // scanner config above (task 1004146).
428
428
  '.gitignore', '.env.local.example',
429
429
  'package.json', 'package-lock.json',
430
+ // The dead-code scanner's config, moved out of package.json's "knip" key so it
431
+ // can generate modules/ entries from each module.json (task 1004875).
432
+ 'knip.config.js',
430
433
  'scripts/bongos/fitness.js', 'scripts/bongos/docs-entropy.js',
431
434
  'scripts/bongos/redteam-patrol.js', 'scripts/bongos/audit-gate.js',
432
435
  'scripts/bongos/fitness-ratchets.js', 'scripts/bongos/control-manifest.js',
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.178'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.180'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -737,8 +737,14 @@ test('this proof runs in a lane that actually executes', () => {
737
737
  const listed = runners.some((f) => {
738
738
  try { return fs.readFileSync(path.join(process.cwd(), 'tests', f), 'utf8').includes(self); } catch { return false; }
739
739
  });
740
- const globbed = fs.existsSync(path.join(process.cwd(), 'package.json'))
741
- && /tests\/\*|tests\/\*\*|\*\.mjs/.test(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
740
+ // `npm test` is scripts/bongos/run-unit-tests.js, which readdir()s EVERY
741
+ // tests/*.mjs into the unit or the integration lane — no allowlist. (This used to
742
+ // regex package.json for a tests/** glob, which only ever matched the knip
743
+ // config's entry list; task 1004875 moved that out and the check went red.)
744
+ let testScript = '';
745
+ try { testScript = (JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).scripts || {}).test || ''; } catch { /* no package.json */ }
746
+ const globbed = /scripts\/bongos\/run-unit-tests\.js/.test(testScript)
747
+ && /readdirSync\(TESTS_DIR\)/.test(fs.readFileSync(path.join(process.cwd(), 'scripts/bongos/run-unit-tests.js'), 'utf8'));
742
748
  assert.ok(listed || globbed,
743
749
  `${self} is in no runner list and no glob — it would assert nothing forever (learning 1000218)`);
744
750
  });
@@ -195,7 +195,7 @@ await test('the rollup activity clock is the SAME max(updated_at, shipped_at)',
195
195
 
196
196
  await test('Creator and Age are sent to the server, on BOTH board feeds', () => {
197
197
  assert.match(WORK_SRC, /function serverFilterQuery\(\)/);
198
- assert.match(WORK_SRC, /tasks\/claimable\$\{serverFilterQuery\(\)\}/,
198
+ assert.match(WORK_SRC, /tasks\/claimable\?limit=\$\{CLAIMABLE_LIMIT\}\$\{serverFilterQuery\(\)/,
199
199
  'the claimable feed narrows at the source, not in the browser');
200
200
  assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}\$\{q\}/,
201
201
  'and so does the backlog read');
@@ -0,0 +1,145 @@
1
+ // tests/knip_modules_scope.mjs — the dead-code scanner covers modules/ (task 1004875).
2
+ //
3
+ // Three layers:
4
+ // 1. the scorer (countKnipFindings) on synthetic knip JSON — hermetic;
5
+ // 2. the generated entry list (knip.config.js) against the real modules/ tree —
6
+ // every route and poller a manifest declares is an entry, by the loader's rule;
7
+ // 3. REAL knip on a throwaway fixture repo that uses the real config builder: a
8
+ // new unused file, and a new unused export, under modules/ each raise the score
9
+ // and the ratchet then fails. That layer needs knip in THIS checkout's
10
+ // node_modules (CI's `npm ci` provides it); without it, it skips and says why.
11
+ import assert from 'node:assert/strict';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import { execFileSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const fr = require(path.join(ROOT, 'scripts/bongos/fitness-ratchets.js'));
23
+ const knipConfig = require(path.join(ROOT, 'knip.config.js'));
24
+
25
+ const issue = (file, extra) => ({ file, files: [], exports: [], types: [], dependencies: [], unlisted: [], ...extra });
26
+
27
+ test('scorer: an unused FILE counts (knip 6 reports it per issue, not top-level)', () => {
28
+ const json = { issues: [issue('modules/x/orphan.js', { files: [{ name: 'modules/x/orphan.js' }] })] };
29
+ assert.equal(fr.countKnipFindings(json, { wordIndex: new Map() }), 1);
30
+ });
31
+
32
+ test('scorer: a modules/ export named by no other code file counts; one named elsewhere does not', () => {
33
+ const wordIndex = new Map([
34
+ ['usedViaNamespace', true], // two+ files spell it
35
+ ['onlyHere', 'modules/x/lib.js'], // only its own file
36
+ ['spelledInConsumer', 'modules/y/consumer.js'], // one OTHER file
37
+ ]);
38
+ const json = {
39
+ issues: [issue('modules/x/lib.js', {
40
+ exports: [{ name: 'usedViaNamespace' }, { name: 'onlyHere' }, { name: 'spelledInConsumer' }, { name: 'neverSpelled' }],
41
+ })],
42
+ };
43
+ assert.equal(fr.countKnipFindings(json, { wordIndex }), 2);
44
+ });
45
+
46
+ test('scorer: src/ exports keep knip\'s own verdict (no name filter outside modules/)', () => {
47
+ const wordIndex = new Map([['usedViaNamespace', true]]);
48
+ const json = { issues: [issue('src/x.js', { exports: [{ name: 'usedViaNamespace' }] })] };
49
+ assert.equal(fr.countKnipFindings(json, { wordIndex }), 1);
50
+ });
51
+
52
+ test('config: every manifest-declared route and poller is an entry, and the file exists', () => {
53
+ const entries = new Set(knipConfig.generatedModuleEntries(ROOT));
54
+ const modulesDir = path.join(ROOT, 'modules');
55
+ let checked = 0;
56
+ for (const key of fs.readdirSync(modulesDir)) {
57
+ const mf = path.join(modulesDir, key, 'module.json');
58
+ if (!fs.existsSync(mf)) continue;
59
+ const c = JSON.parse(fs.readFileSync(mf, 'utf8')).contributes || {};
60
+ for (const [dir, list] of [['routes', c.routes], ['pollers', c.pollers]]) {
61
+ for (const k of list || []) {
62
+ const rel = `modules/${key}/${dir}/${k}.js`;
63
+ assert.ok(entries.has(rel), `${rel} is declared in ${key}/module.json but is not a knip entry`);
64
+ assert.ok(fs.existsSync(path.join(ROOT, rel)), `${rel} is declared but missing`);
65
+ checked++;
66
+ }
67
+ }
68
+ }
69
+ assert.ok(checked > 50, `expected the real tree's routes+pollers, saw ${checked}`);
70
+ assert.ok(knipConfig.project.includes('modules/**/*.{js,mjs}'), 'modules/ must be in knip "project"');
71
+ assert.ok(entries.has('modules/ui-design/kit/render.js'), 'a #! CLI under modules/ is an entry');
72
+ });
73
+
74
+ // ---------- real knip on a fixture ----------
75
+
76
+ const KNIP_BIN = path.join(ROOT, 'node_modules', 'knip', 'bin', 'knip.js');
77
+ const HAVE_KNIP = fs.existsSync(KNIP_BIN);
78
+
79
+ function write(root, rel, text) {
80
+ const abs = path.join(root, rel);
81
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
82
+ fs.writeFileSync(abs, text);
83
+ }
84
+
85
+ function makeFixture() {
86
+ // realpath: on macOS os.tmpdir() is a /var -> /private/var symlink, and knip then
87
+ // resolves a require to a path outside its own project set and calls the target an
88
+ // unused file.
89
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'knip-modules-')));
90
+ write(dir, 'package.json', JSON.stringify({ name: 'knip-fixture', version: '1.0.0', private: true }));
91
+ // The fixture uses the REAL config builder, pointed at itself.
92
+ write(dir, 'knip.config.js',
93
+ `module.exports = require(${JSON.stringify(path.join(ROOT, 'knip.config.js'))}).buildConfig(__dirname);\n`);
94
+ write(dir, 'modules/demo/module.json', JSON.stringify({ key: 'demo', contributes: { routes: ['demo'] } }));
95
+ // The route requires the lib the namespace way — the shape knip is blind to.
96
+ write(dir, 'modules/demo/routes/demo.js',
97
+ "'use strict';\nconst lib = require('../lib');\nmodule.exports = function route() { return lib.hello(); };\n");
98
+ write(dir, 'modules/demo/lib.js',
99
+ "'use strict';\nfunction hello() { return 'hi'; }\nmodule.exports = { hello };\n");
100
+ return dir;
101
+ }
102
+
103
+ function score(dir) {
104
+ const raw = execFileSync(process.execPath, [KNIP_BIN, '--directory', dir, '--reporter', 'json', '--no-exit-code'], {
105
+ cwd: dir, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
106
+ });
107
+ return fr.countKnipFindings(JSON.parse(raw), { root: dir });
108
+ }
109
+
110
+ const ratchetFails = (value, baseline) =>
111
+ fr.compareToBaselines({ knip_issue_count: value }, { knip_issue_count: baseline }).violations.length > 0;
112
+
113
+ test('real knip: a new unused file or export under modules/ fails the ratchet; a namespace-used export does not',
114
+ { skip: HAVE_KNIP ? false : `no knip at ${KNIP_BIN} — run \`npm ci\` (CI always has it)` }, () => {
115
+ const dir = makeFixture();
116
+ try {
117
+ const baseline = score(dir);
118
+ assert.equal(baseline, 0, 'the clean fixture scores 0 — the route is a generated entry, lib.hello is namespace-used');
119
+ assert.equal(ratchetFails(baseline, baseline), false);
120
+
121
+ // A new export that the route uses through the namespace: knip alone flags it;
122
+ // the scorer must not, or every ordinary export under modules/ would red CI.
123
+ write(dir, 'modules/demo/lib.js',
124
+ "'use strict';\nfunction hello() { return 'hi'; }\nfunction greet() { return 'yo'; }\nmodule.exports = { hello, greet };\n");
125
+ write(dir, 'modules/demo/routes/demo.js',
126
+ "'use strict';\nconst lib = require('../lib');\nmodule.exports = function route() { return lib.hello() + lib.greet(); };\n");
127
+ assert.equal(score(dir), baseline, 'a namespace-used export is not a finding');
128
+
129
+ // A new unused EXPORT.
130
+ write(dir, 'modules/demo/lib.js',
131
+ "'use strict';\nfunction hello() { return 'hi'; }\nfunction greet() { return 'yo'; }\n"
132
+ + "function zzDeadExport() { return 0; }\nmodule.exports = { hello, greet, zzDeadExport };\n");
133
+ const withExport = score(dir);
134
+ assert.equal(withExport, baseline + 1);
135
+ assert.equal(ratchetFails(withExport, baseline), true, 'an unused export under modules/ must fail the ratchet');
136
+
137
+ // A new unused FILE.
138
+ write(dir, 'modules/demo/orphan.js', "'use strict';\nmodule.exports = { nobodyCalls() { return 1; } };\n");
139
+ const withFile = score(dir);
140
+ assert.equal(withFile, withExport + 1);
141
+ assert.equal(ratchetFails(withFile, withExport), true, 'an unused file under modules/ must fail the ratchet');
142
+ } finally {
143
+ fs.rmSync(dir, { recursive: true, force: true });
144
+ }
145
+ });
@@ -0,0 +1,236 @@
1
+ // tests/work_board_live_refresh.mjs — the Work board refreshes on the hall's live
2
+ // signal, not a 20-second timer, and asks for a bounded claimable list (task 1004877).
3
+ //
4
+ // Before: an idle, visible Work tab re-fetched the WHOLE claimable feed every 20 s
5
+ // (30 requests per 10 minutes) whether or not anything had changed. Now it listens
6
+ // on the same live channel the hall home uses (GET /api/bongos/live, an
7
+ // EventSource that says "something changed" and nothing else), re-fetches a
8
+ // second after a change (at most one read per 20 s while changes keep coming), and
9
+ // keeps a slow 2-minute timer as a backstop in case the live connection is down. Every claimable read carries ?limit= so the list it
10
+ // returns has a ceiling.
11
+ //
12
+ // work.js is BOOTED in a vm here (the harness shape tests/hall_mine_lens.mjs uses)
13
+ // against a fake clock and a fake EventSource, so the counts below are the real
14
+ // page's timers, not a reading of its source.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { settle } from './helpers.mjs';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
26
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
27
+
28
+ const MIN = 60 * 1000;
29
+
30
+ // A virtual clock: setTimeout/setInterval queue here and only fire on advance(),
31
+ // so ten minutes of page life run in milliseconds.
32
+ function makeClock() {
33
+ let now = 1_000_000;
34
+ let seq = 0;
35
+ const timers = new Map();
36
+ const add = (fn, ms, every) => { seq += 1; timers.set(seq, { at: now + Math.max(0, Number(ms) || 0), fn, every }); return seq; };
37
+ return {
38
+ now: () => now,
39
+ setTimeout: (fn, ms) => add(fn, ms, 0),
40
+ setInterval: (fn, ms) => add(fn, ms, Math.max(1, Number(ms) || 1)),
41
+ clear: (id) => { timers.delete(id); },
42
+ async advance(ms) {
43
+ const end = now + ms;
44
+ for (;;) {
45
+ let next = null;
46
+ for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
47
+ if (!next) break;
48
+ const [id, t] = next;
49
+ now = t.at;
50
+ if (t.every) t.at += t.every; else timers.delete(id);
51
+ t.fn();
52
+ await settle(); await settle();
53
+ }
54
+ now = end;
55
+ await settle();
56
+ },
57
+ };
58
+ }
59
+
60
+ function bootWorkBoard() {
61
+ const clock = makeClock();
62
+ const els = new Map();
63
+ const makeEl = (id = '') => ({
64
+ id, dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
65
+ checked: false, value: '', title: '', _html: '',
66
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
67
+ setAttribute() {}, getAttribute() { return null; },
68
+ listeners: {},
69
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
70
+ querySelector() { return null; }, querySelectorAll() { return []; },
71
+ appendChild() {}, remove() {}, closest() { return null; },
72
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
73
+ });
74
+ const el = (key) => { if (!els.has(key)) els.set(key, makeEl(key)); return els.get(key); };
75
+ const documentObj = {
76
+ getElementById: (id) => el(id),
77
+ querySelector: (sel) => el(String(sel).replace(/^#/, '')),
78
+ querySelectorAll: () => [],
79
+ createElement: () => makeEl(),
80
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
81
+ listeners: {},
82
+ body: { dataset: {} }, documentElement: { dataset: {} },
83
+ hidden: false,
84
+ };
85
+
86
+ const requests = [];
87
+ const client = {
88
+ request: async (method, url) => {
89
+ requests.push(url);
90
+ const ok = (data) => ({ status: 200, ok: true, data });
91
+ if (url.endsWith('/me')) return ok({ builder: { id: 7, github_login: 'percy', rank: 'metic' } });
92
+ if (url.endsWith('/goals')) return ok({ goals: [] });
93
+ if (url.includes('/tasks/claimable')) {
94
+ return ok({ claimable: [{ id: 501, title: 'a ready task', status: 'ready', version_id: 'V1' }], claimable_total: 1234, active_claims: [] });
95
+ }
96
+ return ok({});
97
+ },
98
+ };
99
+
100
+ // The fake live stream: one instance per `new EventSource(url)`.
101
+ const streams = [];
102
+ class FakeEventSource {
103
+ constructor(url) { this.url = url; this.readyState = 1; this.listeners = {}; streams.push(this); }
104
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
105
+ emit(type) { (this.listeners[type] || []).forEach((fn) => fn({ data: '{}' })); }
106
+ close() { this.readyState = 2; }
107
+ }
108
+ FakeEventSource.CLOSED = 2;
109
+
110
+ const tabCounts = {};
111
+ const kit = {
112
+ makePager: (prefix) => ({ prefix, page: 1, perPage: 100 }),
113
+ paginate: (rows) => rows,
114
+ wirePager() {},
115
+ pagerBarHtml: () => '',
116
+ makeTabs: ({ tabs }) => ({ active: () => tabs[0].id, setCount(id, n) { tabCounts[id] = n; }, activate() {} }),
117
+ makeFilterBar: ({ search, primary = [], overflow = [] }) => {
118
+ [search, ...primary, ...overflow].filter(Boolean).forEach((c) => { el(c.id).value = c.value ?? c.default ?? ''; });
119
+ return { values: () => ({}), get: () => '', set() {} };
120
+ },
121
+ ageChipHtml: () => '',
122
+ };
123
+ const windowObj = {
124
+ __BRANDING__: { currency: { label: 'credits' } },
125
+ BongosClient: { createClient: () => client },
126
+ OTBKit: kit,
127
+ OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
128
+ OTBTaskLib: {
129
+ priorityRank: () => 0, touchesConflict: () => false, conflictPeers: () => [], conflictChipHtml: () => '', rankTagHtml: () => '',
130
+ creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
131
+ taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
132
+ focusVersionDefault: () => null,
133
+ },
134
+ OTB: {
135
+ escapeHtml: (s) => String(s ?? ''), $: (sel) => documentObj.querySelector(sel),
136
+ fmtNum: (n) => String(n ?? 0), fmtDate: (d) => String(d ?? ''),
137
+ errCode: () => null, errDetails: () => ({}), toast() {}, categoryChip: () => '', fillKindFilter() {},
138
+ kindIsUnset: () => true, KIND_UNSET: 'unclassified', roleAxisLabel: () => 'Role',
139
+ roleOptions: () => [{ value: '', label: '— any —' }], roleIsUnset: () => true, roleLabel: () => '', ROLE_UNSET: 'unclassified',
140
+ },
141
+ location: { hostname: 'builders.example.com', pathname: '/work', search: '', hash: '', href: 'https://builders.example.com/work' },
142
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
143
+ confirm: () => true,
144
+ addEventListener() {},
145
+ };
146
+ class FakeDate extends Date {}
147
+ FakeDate.now = () => clock.now();
148
+ const sandbox = {
149
+ window: windowObj, document: documentObj, location: windowObj.location,
150
+ localStorage: windowObj.localStorage, console,
151
+ setTimeout: clock.setTimeout, clearTimeout: clock.clear, setInterval: clock.setInterval, clearInterval: clock.clear,
152
+ Date: FakeDate, URL, URLSearchParams, navigator: {}, history: { replaceState() {} },
153
+ EventSource: FakeEventSource,
154
+ };
155
+ sandbox.globalThis = sandbox;
156
+ vm.createContext(sandbox);
157
+ for (const f of ['mine-lens.js', 'work-summary.js', 'claim-action.js', 'bulk-toolbar.js']) vm.runInContext(read(f), sandbox, { filename: f });
158
+ windowObj.OTBMineLens = sandbox.OTBMineLens;
159
+ windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
160
+ vm.runInContext(read('work.js'), sandbox, { filename: 'work.js' });
161
+ (documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
162
+
163
+ return {
164
+ clock, requests, streams, tabCounts,
165
+ claimableReads: () => requests.filter((u) => u.includes('/tasks/claimable')),
166
+ setHidden(h) { documentObj.hidden = h; (documentObj.listeners.visibilitychange || []).forEach((fn) => fn()); },
167
+ };
168
+ }
169
+
170
+ const boot = async () => {
171
+ const b = bootWorkBoard();
172
+ for (let i = 0; i < 8; i += 1) await settle();
173
+ await b.clock.advance(0);
174
+ return b;
175
+ };
176
+
177
+ test('every claimable read asks for a bounded list', async () => {
178
+ const b = await boot();
179
+ await b.clock.advance(10 * MIN);
180
+ const reads = b.claimableReads();
181
+ assert.ok(reads.length >= 1);
182
+ for (const u of reads) assert.match(u, /[?&]limit=\d+/, `unbounded claimable read: ${u}`);
183
+ });
184
+
185
+ test('the Ready tab count is the server total, not the length of the bounded list', async () => {
186
+ const b = await boot();
187
+ assert.equal(b.tabCounts.ready, 1234);
188
+ });
189
+
190
+ test('an idle visible tab makes at most 5 claimable reads in 10 minutes (was 30)', async () => {
191
+ const b = await boot();
192
+ const atLoad = b.claimableReads().length;
193
+ await b.clock.advance(10 * MIN);
194
+ const idle = b.claimableReads().length - atLoad;
195
+ assert.ok(idle <= 5, `expected ≤ 5 background reads per 10 min, got ${idle}`);
196
+ assert.ok(idle >= 4, `the slow fallback timer must still run (got ${idle})`);
197
+ });
198
+
199
+ test('the page subscribes to the live channel', async () => {
200
+ const b = await boot();
201
+ assert.equal(b.streams.length, 1);
202
+ assert.equal(b.streams[0].url, '/api/bongos/live');
203
+ });
204
+
205
+ test('a live nudge refreshes the claimable list within seconds', async () => {
206
+ const b = await boot();
207
+ const before = b.claimableReads().length;
208
+ b.streams[0].emit('nudge');
209
+ await b.clock.advance(3000);
210
+ assert.equal(b.claimableReads().length, before + 1);
211
+ });
212
+
213
+ test('a burst of nudges is coalesced: a busy minute costs at most 3 reads', async () => {
214
+ const b = await boot();
215
+ const before = b.claimableReads().length;
216
+ for (let s = 0; s < 60; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
217
+ const n = b.claimableReads().length - before;
218
+ assert.ok(n >= 1 && n <= 3, `expected 1–3 reads for 60 nudges in a minute, got ${n}`);
219
+ });
220
+
221
+ test('a project changing every second never costs more than the old 20 s timer (30 per 10 min)', async () => {
222
+ const b = await boot();
223
+ const before = b.claimableReads().length;
224
+ for (let s = 0; s < 600; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
225
+ const n = b.claimableReads().length - before;
226
+ assert.ok(n <= 30, `expected ≤ 30 reads in a busy 10 minutes, got ${n}`);
227
+ });
228
+
229
+ test('a hidden tab fetches nothing, on the timer or on a nudge', async () => {
230
+ const b = await boot();
231
+ b.setHidden(true);
232
+ const before = b.claimableReads().length;
233
+ b.streams[0].emit('nudge');
234
+ await b.clock.advance(10 * MIN);
235
+ assert.equal(b.claimableReads().length, before);
236
+ });