@bongos/core 1.19.1038 → 1.19.1040

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.bongos-core.json +98 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +39 -1
  8. package/docs/api-reference.md +7 -1
  9. package/docs/copy-inventory.md +2 -2
  10. package/docs/copy-registry.json +2 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/recipes/core-release-pipeline.md +1 -1
  14. package/docs/recipes/private-npm-distribution.md +1 -1
  15. package/modules/hall-ui/public/deploy.html +9 -0
  16. package/modules/hall-ui/public/deploy.js +14 -1
  17. package/modules/hall-ui/public/deploy.states.json +14 -0
  18. package/modules/lifecycle/conflict-resolve.js +9 -1
  19. package/modules/npm-release/module.json +19 -0
  20. package/modules/npm-release/public/work.css +8 -0
  21. package/modules/npm-release/public/work.js +261 -0
  22. package/modules/npm-release/registry.js +271 -0
  23. package/modules/npm-release/routes/work.js +46 -0
  24. package/modules/npm-release/work.js +252 -0
  25. package/modules/provisioning/starter-bundles.js +6 -2
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +24 -0
  29. package/scripts/gds/exec-path-guard.js +1 -1
  30. package/scripts/gds/install-git-hooks.js +18 -2
  31. package/scripts/gds/plain-cards.js +5 -0
  32. package/scripts/gds/publish-integration-gate.js +142 -0
  33. package/scripts/gds/publish-manifest.js +1 -0
  34. package/scripts/gds/ship-land.js +72 -6
  35. package/src/bongos/module-scope-map.js +5 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/conflict_resolve.mjs +15 -1
  38. package/tests/merge_driver_register.mjs +28 -0
  39. package/tests/module_loader.mjs +1 -1
  40. package/tests/npm_release_page.mjs +173 -0
  41. package/tests/npm_release_work.mjs +350 -0
  42. package/tests/publish_integration_gate.mjs +198 -0
  43. package/tests/render_check.mjs +3 -2
  44. package/tests/ship_land_ci_not_started.mjs +168 -0
@@ -291,7 +291,17 @@ function strandNextStep(taskId, via) {
291
291
  // task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
292
292
  // false: a required check that failed does not pass by waiting, so the banner says the
293
293
  // land is stuck on it and defers to the red lines above for the remedy.
294
- function landPendingLines(taskId, { merged, red } = {}) {
294
+ function landPendingLines(taskId, { merged, red, notStarted } = {}) {
295
+ // task 1004289: CI never RAN (the account cannot run Actions). Not a failure of the
296
+ // work, and not something waiting fixes either: the checks have to be started again.
297
+ if (notStarted) {
298
+ return [
299
+ ' ci-land: Published, but CI never started on the PR (GitHub ran none of its checks), so it has NOT merged',
300
+ ' and cannot until they run. The task stays at \'confirmed\'. Nothing in the work needs to change: once the',
301
+ ` owner restores GitHub Actions, re-run the failed jobs (lines above), then re-run \`node scripts/gds/ship.js ${taskId}\``,
302
+ ' to watch the land — it resumes from here (task 941).',
303
+ ];
304
+ }
295
305
  if (red) {
296
306
  return [
297
307
  ' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
@@ -314,8 +324,9 @@ function printLandPending(taskId, opts = {}) {
314
324
  console.log('');
315
325
  for (const line of landPendingLines(taskId, opts)) console.log(line);
316
326
  // The land_pending card says there is nothing to do, which a red PR makes untrue; the
317
- // red line already printed the failing_check card (task 1003764).
318
- if (!opts.red) for (const line of plainCardLines('land_pending')) console.log(line);
327
+ // red line already printed the failing_check card (task 1003764), or the
328
+ // ci_not_started one (task 1004289).
329
+ if (!opts.red && !opts.notStarted) for (const line of plainCardLines('land_pending')) console.log(line);
319
330
  }
320
331
 
321
332
  // task 1002645 — the honest close-out when the PR MERGED but the server refused the
@@ -351,6 +362,11 @@ function prAssigneeLine(login) {
351
362
  // exported so the wording is pinned by tests/unit_report_status.mjs.
352
363
  function redPrLine(md) {
353
364
  const reason = (md && md.reason) || '';
365
+ if (md && md.not_started) {
366
+ const job = (/^ci_not_started:(.+)$/.exec(reason) || [])[1];
367
+ return ` ci-land: CI NEVER RAN on this PR — GitHub did not start ${job ? `the \`${job}\` job` : 'its checks'} ("The job was not started because …", `
368
+ + 'an Actions budget or billing stop on the account). Nothing in the work was tested, so there is nothing in it to fix.';
369
+ }
354
370
  const fix = 'fix it, commit, and re-run the ship — re-pushing unchanged work cannot turn it green.';
355
371
  if (md && md.failing_tests) {
356
372
  return ` ci-land: the PR is RED — ${md.failing_tests}. Reproduce with \`node tests/<name>.mjs\`, ${fix}`;
@@ -369,9 +385,21 @@ function redPrLine(md) {
369
385
  // task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
370
386
  function redPrLines(md, opts = {}) {
371
387
  const red = redPrLine(md);
388
+ if (red && md.not_started) return [red, ...notStartedLines(md), ...plainCardLines('ci_not_started')];
372
389
  return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
373
390
  }
374
391
 
392
+ // notStartedLines — task 1004289. What to do when CI never ran, instead of the merge trap
393
+ // (there is no failure to reproduce). Re-running is the remedy, not re-pushing: the same
394
+ // commit is fine, it simply was never tested. Pure + exported.
395
+ function notStartedLines(md) {
396
+ const rerun = md && /^\d+$/.test(String(md.run_id || '')) ? `\`gh run rerun ${md.run_id} --failed\`` : '"Re-run failed jobs" on the PR';
397
+ return [
398
+ ' ci-land: this is the repo OWNER\'s to fix, on GitHub: the account\'s Actions budget / spending limit or payment method.',
399
+ ` ci-land: once Actions can run again, ${rerun} starts the checks on the SAME commit — do not re-push or edit the work.`,
400
+ ];
401
+ }
402
+
375
403
  // The unit tests that gate GENERATED files — each compares a committed artifact with a
376
404
  // fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
377
405
  // fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
@@ -439,16 +467,42 @@ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', '
439
467
  // poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
440
468
  // status (gh pr view omits a status's description) and is asked only once that status is
441
469
  // itself red, i.e. posted. Null when nothing is red. Pure + exported.
442
- function localMergeDriver(pr, failingTestsFor = () => null) {
470
+ // task 1004289 — `neverStarted(rawCheck)` answers "did this red job never run at all?"
471
+ // (jobNeverStarted below). When EVERY red check is one, nothing was tested, and the
472
+ // answer is { reason: 'ci_not_started:<name>', not_started: true, run_id } rather than
473
+ // a failing check the builder is told to fix.
474
+ function localMergeDriver(pr, failingTestsFor = () => null, neverStarted = () => false) {
443
475
  if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
444
476
  const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
445
477
  const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
446
478
  if (!red.length) return null;
479
+ const redRaw = pr.statusCheckRollup.filter((c) => c && RED_CONCLUSIONS.has(String(c.conclusion || c.state || '').toUpperCase()));
480
+ if (redRaw.length && redRaw.every((c) => neverStarted(c))) {
481
+ const run = /\/runs\/(\d+)\//.exec(String(redRaw[0].detailsUrl || ''));
482
+ return { reason: `ci_not_started:${red[0].name}`, not_started: true, failing_tests: null, run_id: run ? run[1] : null };
483
+ }
447
484
  const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
448
485
  const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
449
486
  return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
450
487
  }
451
488
 
489
+ // jobNeverStarted — task 1004289. When the account cannot run Actions (its budget is
490
+ // spent, or a payment failed), GitHub still marks every job FAILED seconds after it was
491
+ // queued, with no steps and no runner, and one annotation saying why: "The job was not
492
+ // started because an Actions budget is preventing further use." (PR 1211, 2026-09-25).
493
+ // Read as an ordinary red check, that told the builder to fix their code — advice that
494
+ // cannot help, where each retry is another failed run. An Actions job's id (the tail of
495
+ // its detailsUrl) is its check-run id, so its annotations are one read away. FAIL-OPEN:
496
+ // anything unreadable is "no", which keeps the ordinary red wording. `gh` is injectable.
497
+ const NOT_STARTED_RE = /^\s*The job was not started because\b/i;
498
+ function jobNeverStarted(check, gh = ghJson) {
499
+ if (!check || (check.__typename && check.__typename !== 'CheckRun')) return false;
500
+ const m = /\/job\/(\d+)(?:[/?#]|$)/.exec(String(check.detailsUrl || ''));
501
+ if (!m) return false;
502
+ const notes = gh(['api', `repos/{owner}/{repo}/check-runs/${m[1]}/annotations`]);
503
+ return Array.isArray(notes) && notes.some((a) => a && NOT_STARTED_RE.test(String(a.message || '')));
504
+ }
505
+
452
506
  // unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
453
507
  // machine's gh through the same parser the server uses. Null when unreadable; the red line
454
508
  // then names the check instead, as it did before the report existed. `gh` is injectable.
@@ -921,6 +975,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
921
975
  if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
922
976
  return report.text;
923
977
  };
978
+ // task 1004289: a finished job's annotations never change, so each is read once.
979
+ let notStarted = false;
980
+ const startMemo = new Map();
981
+ const neverStarted = (c) => {
982
+ const key = String((c && c.detailsUrl) || '');
983
+ if (!startMemo.has(key)) startMemo.set(key, jobNeverStarted(c, readJson));
984
+ return startMemo.get(key);
985
+ };
924
986
  for (let i = 0; i < MERGE_TRIES; i++) {
925
987
  // task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
926
988
  // named on the poll it appears; the two-field read is the fallback for a gh that
@@ -937,13 +999,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
937
999
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
938
1000
  }
939
1001
  if (pr) {
940
- const md = localMergeDriver(pr, failingTestsFor);
1002
+ const md = localMergeDriver(pr, failingTestsFor, neverStarted);
941
1003
  const red = redPrLine(md);
942
1004
  if (red && red !== redLine) {
943
1005
  redLine = red;
944
1006
  for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
945
1007
  }
946
1008
  stuckRed = !!red && redBlocksMerge(md);
1009
+ notStarted = !!(md && md.not_started);
947
1010
  }
948
1011
  conflicting = noteConflict(taskId, pr, conflicting);
949
1012
  pause();
@@ -954,7 +1017,7 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
954
1017
  // CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
955
1018
  // Or RED (task 1003764): then the banner says the land is stuck on that check.
956
1019
  if (conflicting) return landConflicted(taskId);
957
- printLandPending(taskId, { red: stuckRed });
1020
+ printLandPending(taskId, { red: stuckRed, notStarted });
958
1021
  return landPending();
959
1022
  }
960
1023
  console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
@@ -1036,6 +1099,9 @@ module.exports = {
1036
1099
  redBlocksMerge,
1037
1100
  localMergeDriver,
1038
1101
  unitReportFor,
1102
+ // task 1004289: CI that never ran is said as such, not as a failure to fix.
1103
+ jobNeverStarted,
1104
+ notStartedLines,
1039
1105
  reconciledCardStatus,
1040
1106
  strandCommand,
1041
1107
  strandNextStep,
@@ -335,6 +335,11 @@ const MODULE_GLOBS = {
335
335
  // The instance-side CLIENT half of federation is woven into core auth.js and is
336
336
  // kernel territory (ADR 0141 §Packaging), NOT this key.
337
337
  'platform-identity': ['modules/platform-identity/'],
338
+ // --- the npm-release module (task 1002622): the deploy page's "where the work is" +
339
+ // version history, and later its Release step. Its tests live at the repo root
340
+ // (tests/npm_release_*.mjs) because a default-off module's own tests/ is skipped
341
+ // by the unit runner. The /deploy page shell stays hall-ui's (one key per surface).
342
+ 'npm-release': ['modules/npm-release/', 'tests/npm_release_*.mjs'],
338
343
  // --- instance provisioning: the web-tier REQUEST + STATE surface (module home)
339
344
  // plus the control-plane runner CLI — a command travels with its domain (the
340
345
  // lifecycle/dev-box precedent). Covers the projects-surface work (goal
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1038'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1040'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
+ import path from 'node:path';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
  const cr = require('../modules/lifecycle/conflict-resolve.js');
@@ -90,6 +91,7 @@ function mkExec(scn = {}) {
90
91
 
91
92
  function mkDeps(scn = {}, over = {}) {
92
93
  const reg = [];
94
+ const regOpts = [];
93
95
  const exec = mkExec(scn);
94
96
  const genCalls = [];
95
97
  return {
@@ -105,12 +107,13 @@ function mkDeps(scn = {}, over = {}) {
105
107
  buildAuthUrl: () => 'https://x-access-token:REDACTED@github.com/o/r.git',
106
108
  },
107
109
  repoInfo: over.repoInfo === undefined ? { owner: 'o', repo: 'r' } : over.repoInfo,
108
- registerMergeDriver: (root) => { reg.push(root); },
110
+ registerMergeDriver: (root, opts) => { reg.push(root); regOpts.push(opts); },
109
111
  repoRoot: '/srv/repo',
110
112
  mkdtemp: () => '/tmp/otb-resolve-test',
111
113
  rmtmp: () => {},
112
114
  _exec: exec,
113
115
  _reg: reg,
116
+ _regOpts: regOpts,
114
117
  _gen: genCalls,
115
118
  };
116
119
  }
@@ -129,6 +132,17 @@ test('clean generated/index merge → resolved + pushed; driver registered in th
129
132
  assert.ok(ran(deps._exec, 'push'), 'the resolved branch is pushed');
130
133
  });
131
134
 
135
+ // Task 1003368 (audit B2): the driver command must name the SERVER's copy of the script,
136
+ // never the clone's (which is the builder's branch).
137
+ test('the otb-regen driver is pinned to the server checkout, not the clone', async () => {
138
+ const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'newsha2' });
139
+ await cr.resolveBranchConflicts({ branch: 'claude/wt-1' }, deps);
140
+ const script = deps._regOpts[0] && deps._regOpts[0].driverScript;
141
+ assert.ok(script, 'registration must pass a driverScript');
142
+ assert.equal(path.resolve(script), path.resolve('/srv/repo', 'scripts', 'gds', 'git-merge-regen.js'));
143
+ assert.ok(!path.resolve(script).startsWith(path.resolve('/tmp/otb-resolve-test')), 'never inside the clone');
144
+ });
145
+
132
146
  test('genuine hand-written conflict → NOT resolved, merge aborted, never pushed', async () => {
133
147
  const deps = mkDeps({ merge: 'conflict', conflicted: ['src/bongos/db.js'] });
134
148
  const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-2' }, deps);
@@ -99,6 +99,34 @@ test('registerMergeDriver writes .driver BEFORE .name', () => {
99
99
  'order is the fix: the intermediate state after write #1 must be the SAFE one');
100
100
  });
101
101
 
102
+ // Task 1003368 (audit B2): the default command is RELATIVE, which git resolves against the
103
+ // checked-out tree. The server passes an absolute driverScript so the command never names a
104
+ // file the branch could have written.
105
+ test('registerMergeDriver: default command stays relative, driverScript pins an absolute path', () => {
106
+ const commands = [];
107
+ const run = (_bin, args) => { if (args[1] === `merge.${MERGE_DRIVER_NAME}.driver`) commands.push(args[2]); };
108
+ registerMergeDriver('/nowhere', { run });
109
+ assert.equal(commands[0], 'node scripts/gds/git-merge-regen.js %O %A %B %P');
110
+
111
+ const script = path.join(REPO_ROOT, 'scripts', 'gds', 'git-merge-regen.js');
112
+ registerMergeDriver('/nowhere', { run, driverScript: script });
113
+ const pinned = commands[1];
114
+ const m = pinned.match(/^node "([^"]+)" %O %A %B %P$/);
115
+ assert.ok(m, `pinned command has the quoted-absolute shape, got: ${pinned}`);
116
+ assert.ok(path.isAbsolute(m[1]), 'the pinned path must be absolute');
117
+ assert.ok(!m[1].includes('\\'), 'backslashes would be eaten by sh -c');
118
+ assert.equal(path.resolve(m[1]), script);
119
+ });
120
+
121
+ test('registerMergeDriver: a path that could break out of the quotes is refused, leaving no trace', () => {
122
+ const calls = [];
123
+ const run = (_bin, args) => { calls.push(args); };
124
+ const res = registerMergeDriver('/nowhere', { run, driverScript: '/srv/x"; touch pwned; "/a.js' });
125
+ assert.equal(res.ok, false);
126
+ assert.match(res.error, /unsafe merge driver path/);
127
+ assert.ok(!calls.some((a) => a[1] === `merge.${MERGE_DRIVER_NAME}.driver`), 'nothing may be written');
128
+ });
129
+
102
130
  test('a failure mid-registration removes the whole section, leaving no trace', () => {
103
131
  const calls = [];
104
132
  const run = (_bin, args) => {
@@ -276,7 +276,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
276
276
  // That is precisely how the agents regression reached an open PR (task 1002485).
277
277
  loader._reset();
278
278
  const snap = loader.loaderSnapshot();
279
- assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
279
+ assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
280
280
  assert.deepEqual(snap.errors, []);
281
281
  assert.deepEqual(snap.skipped, []);
282
282
  loader._reset();
@@ -0,0 +1,173 @@
1
+ // tests/npm_release_page.mjs — the npm-release module's deploy-page sections, EXECUTED
2
+ // (task 1002622): modules/npm-release/public/work.js run in a DOM stub against a real
3
+ // reading, and deploy.js's loader that decides whether that script is fetched at all.
4
+ //
5
+ // WHAT IS AT RISK. The page's whole reason to exist is naming the work, so the cases pin
6
+ // that the work is named (every task, by title, linked), that a partial reading SAYS it is
7
+ // partial, that a reader the route refuses sees nothing rather than an error, and that a
8
+ // hall without the module never asks for its script.
9
+ //
10
+ // Run: node tests/npm_release_page.mjs
11
+ import assert from 'node:assert/strict';
12
+ import { test } from 'node:test';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const WORK_SRC = fs.readFileSync(path.join(ROOT, 'modules/npm-release/public/work.js'), 'utf8');
20
+ const DEPLOY_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
21
+ // The fixture the hall-preview harness serves — a reading taken off the live registry.
22
+ const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/npm-release__work.json'), 'utf8'));
23
+
24
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
25
+
26
+ function makeEl() {
27
+ return { hidden: true, _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
28
+ }
29
+
30
+ /** Run work.js with `answer(query)` standing in for the route. */
31
+ function bootWork(answer) {
32
+ const els = new Map([['nr-work', makeEl()], ['nr-versions', makeEl()]]);
33
+ const asked = [];
34
+ const clicks = [];
35
+ const head = [];
36
+ const documentObj = {
37
+ getElementById: (id) => els.get(id) || null,
38
+ addEventListener(ev, fn) { if (ev === 'click') clicks.push(fn); },
39
+ createElement: () => ({}),
40
+ head: { appendChild: (n) => head.push(n) },
41
+ };
42
+ const request = async (method, url) => {
43
+ const q = url.includes('?') ? url.slice(url.indexOf('?') + 1) : '';
44
+ asked.push(q);
45
+ return answer(q);
46
+ };
47
+ const windowObj = {
48
+ BongosClient: { createClient: () => ({ request }) },
49
+ OTB: { escapeHtml, toast() {} },
50
+ };
51
+ class Element {}
52
+ const sandbox = { window: windowObj, document: documentObj, Element, console, setTimeout, Date, Set, Map, Promise, encodeURIComponent };
53
+ vm.createContext(sandbox);
54
+ vm.runInContext(WORK_SRC, sandbox, { filename: 'work.js' });
55
+ const settle = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); };
56
+ return { els, asked, head, settle, work: () => els.get('nr-work'), versions: () => els.get('nr-versions') };
57
+ }
58
+
59
+ const ok = (data) => ({ ok: true, status: 200, data });
60
+
61
+ test('every recent task is named, by title, linked to its page', async () => {
62
+ const b = bootWork(() => ok(FIXTURE));
63
+ await b.settle();
64
+ const html = b.work().innerHTML;
65
+ assert.equal(b.work().hidden, false);
66
+ for (const [stage, rows] of Object.entries(FIXTURE.stages)) {
67
+ // Stages longer than the fold show their first rows; the rest sit behind "Show all".
68
+ for (const t of rows.slice(0, 8)) {
69
+ assert.ok(html.includes(`href="/task/${t.id}"`), `${stage}: task ${t.id} is linked`);
70
+ assert.ok(html.includes(escapeHtml(t.title)), `${stage}: task ${t.id} is named`);
71
+ }
72
+ }
73
+ assert.match(html, /Show all 15/);
74
+ });
75
+
76
+ test('the counts strip carries every stage, and the stage sentence sits under its head', async () => {
77
+ const b = bootWork(() => ok(FIXTURE));
78
+ await b.settle();
79
+ const html = b.work().innerHTML;
80
+ for (const fig of ['Not merged', 'Unversioned', 'Not running', 'Unreleased', 'Released', 'No version']) {
81
+ assert.ok(html.includes(`>${fig}</span>`), fig);
82
+ }
83
+ assert.doesNotMatch(html, /ov-group__note/, 'a sentence in the note slot squeezes the head on a phone');
84
+ assert.match(html, /Already offered to other projects\./, 'said once for the group when true of every row');
85
+ });
86
+
87
+ test('the version history lists each version with its tasks, and asks for a short first page', async () => {
88
+ const b = bootWork(() => ok(FIXTURE));
89
+ await b.settle();
90
+ assert.equal(b.asked[0], 'limit=6');
91
+ const html = b.versions().innerHTML;
92
+ for (const v of FIXTURE.history.versions) assert.ok(html.includes(`data-version="${v.version}"`), v.version);
93
+ assert.match(html, /running here|released/);
94
+ assert.match(html, /Show older versions/);
95
+ assert.equal(b.head.length, 1, 'the module stylesheet is loaded once');
96
+ });
97
+
98
+ test('a partial reading says it is partial', async () => {
99
+ const partial = { ...FIXTURE, registry: { ok: false, error: 'the registry answered 503' }, notes: { ...FIXTURE.notes, error: 'could not download 1.19.1039' } };
100
+ const b = bootWork(() => ok(partial));
101
+ await b.settle();
102
+ const html = b.work().innerHTML;
103
+ assert.match(html, /Partial reading/);
104
+ assert.match(html, /registry could not be read \(the registry answered 503\)/);
105
+ assert.match(html, /could not download 1\.19\.1039/);
106
+ });
107
+
108
+ test('a version whose notes could not be read is never shown as a version with no work', async () => {
109
+ const d = JSON.parse(JSON.stringify(FIXTURE));
110
+ d.history.versions[0].entries = null;
111
+ d.history.versions[1].entries = [];
112
+ const b = bootWork(() => ok(d));
113
+ await b.settle();
114
+ const html = b.versions().innerHTML;
115
+ assert.match(html, /notes could not be read/);
116
+ assert.match(html, /housekeeping merges only/);
117
+ });
118
+
119
+ test('a reader the route refuses sees nothing, not an error', async () => {
120
+ for (const status of [401, 403, 404]) {
121
+ const b = bootWork(() => ({ ok: false, status, data: {} }));
122
+ await b.settle();
123
+ assert.equal(b.work().hidden, true, `${status} keeps the section hidden`);
124
+ }
125
+ });
126
+
127
+ test('a server failure is said in words', async () => {
128
+ const b = bootWork(() => ({ ok: false, status: 500, data: { error: { message: 'Could not read where the work is right now.' } } }));
129
+ await b.settle();
130
+ assert.equal(b.work().hidden, false);
131
+ assert.match(b.work().innerHTML, /Could not read where the work is right now\./);
132
+ });
133
+
134
+ test('task titles are escaped', async () => {
135
+ const d = JSON.parse(JSON.stringify(FIXTURE));
136
+ d.stages.not_merged[0].title = '<img src=x onerror=alert(1)>';
137
+ const b = bootWork(() => ok(d));
138
+ await b.settle();
139
+ assert.doesNotMatch(b.work().innerHTML, /<img src=x/);
140
+ });
141
+
142
+ // ---- deploy.js's loader ---------------------------------------------------------
143
+
144
+ function bootDeploy({ modules, search = '' }) {
145
+ const appended = [];
146
+ const documentObj = {
147
+ getElementById: () => ({ hidden: false, textContent: '', innerHTML: '', addEventListener() {} }),
148
+ addEventListener(ev, fn) { if (ev === 'DOMContentLoaded') this._ready = fn; },
149
+ createElement: () => ({}),
150
+ body: { appendChild: (n) => appended.push(n) },
151
+ };
152
+ const request = async () => ({ ok: false, status: 403, data: {} });
153
+ const windowObj = {
154
+ BongosClient: { createClient: () => ({ request }) },
155
+ OTB: { escapeHtml, toast() {} },
156
+ location: { assign() {}, search },
157
+ __MODULES__: modules,
158
+ __deployPollMs: 0,
159
+ };
160
+ const sandbox = { window: windowObj, document: documentObj, console, setTimeout, clearTimeout, Date, Set, Map, Promise, decodeURIComponent };
161
+ vm.createContext(sandbox);
162
+ vm.runInContext(DEPLOY_SRC, sandbox, { filename: 'deploy.js' });
163
+ documentObj._ready();
164
+ return appended;
165
+ }
166
+
167
+ test('the deploy page loads the module script only for its own view, and only with the module on', () => {
168
+ const on = { enabled: { 'npm-release': true } };
169
+ assert.deepEqual(bootDeploy({ modules: on }).map((s) => s.src), ['/modules/npm-release/public/work.js']);
170
+ assert.deepEqual(bootDeploy({ modules: { enabled: { 'npm-release': false } } }), [], 'module off: no request for a script that would 404');
171
+ assert.deepEqual(bootDeploy({ modules: undefined }), [], 'no module record at all');
172
+ assert.deepEqual(bootDeploy({ modules: on, search: '?project=mercury' }), [], 'another project\'s view is not this package\'s');
173
+ });