@bongos/core 1.19.971 → 1.19.973

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 (77) hide show
  1. package/.bongos-core.json +116 -76
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/api/openapi.json +1 -1
  8. package/docs/api-reference.md +1 -1
  9. package/docs/architecture.md +6 -6
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/canonical-permissions.md +24 -24
  12. package/docs/copy-inventory.md +193 -187
  13. package/docs/copy-registry.json +320 -276
  14. package/docs/design/projects-hub-direction-v2.md +1 -1
  15. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  16. package/docs/file-map.md +7 -7
  17. package/docs/handoff-template.md +1 -1
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/modules-contract.md +1 -1
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/packs/engineer.md +5 -5
  22. package/docs/packs/ideator.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/audit-replay.md +2 -2
  25. package/docs/recipes/gds-db-backup.md +3 -3
  26. package/docs/recipes/managed-settings-remote-control.md +9 -9
  27. package/docs/recipes/memory-sync-conflicts.md +2 -2
  28. package/docs/recipes/ops-gotchas.md +24 -5
  29. package/docs/recipes/overnight-builder.md +3 -3
  30. package/docs/recipes/parallel-goal-waves.md +3 -3
  31. package/docs/recipes/reranking-a-goals-board.md +2 -2
  32. package/docs/recipes/search-index-ingestion.md +1 -1
  33. package/docs/recipes/ship-and-regrade.md +2 -2
  34. package/docs/recipes/staging-environment.md +1 -1
  35. package/docs/recipes/windows-builders.md +1 -1
  36. package/modules/hall-ui/public/deploy.html +10 -5
  37. package/modules/hall-ui/public/deploy.js +199 -110
  38. package/modules/hall-ui/public/goals-page.js +6 -0
  39. package/modules/hall-ui/public/goals-render.js +16 -0
  40. package/modules/hall-ui/public/mine-lens.js +172 -0
  41. package/modules/hall-ui/public/project-settings.css +36 -0
  42. package/modules/hall-ui/public/work-summary.js +90 -0
  43. package/modules/hall-ui/public/work.css +50 -0
  44. package/modules/hall-ui/public/work.html +24 -0
  45. package/modules/hall-ui/public/work.js +38 -51
  46. package/modules/lifecycle/db-goals.js +20 -3
  47. package/modules/lifecycle/routes/goals.js +2 -2
  48. package/modules/provisioning/core-upgrade.js +61 -2
  49. package/modules/public-landing/public/contact.html +1 -1
  50. package/modules/public-landing/public/index.html +1 -1
  51. package/modules/public-landing/public/privacy.html +1 -1
  52. package/modules/public-landing/public/projects.html +1 -1
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/api-alias-caller-scan.js +47 -9
  57. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  58. package/scripts/gds/fitness.js +3 -0
  59. package/scripts/gds/provision-core-upgrade.js +132 -1
  60. package/scripts/gds/run-unit-tests.js +14 -9
  61. package/scripts/gds/scope-roster-guard.js +118 -0
  62. package/scripts/hall-preview/server.js +5 -0
  63. package/src/bongos/module-scope-map.js +1 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/api_alias_caller_ratchet.mjs +68 -2
  66. package/tests/core_upgrade_door.mjs +81 -0
  67. package/tests/core_upgrade_runner.mjs +132 -0
  68. package/tests/deploy_page_projects.mjs +234 -79
  69. package/tests/fitness_scope_roster.mjs +117 -0
  70. package/tests/goal_mine_lens.mjs +233 -0
  71. package/tests/goal_tier.mjs +30 -4
  72. package/tests/hall_mine_lens.mjs +381 -0
  73. package/tests/idea_spark_hall.mjs +101 -12
  74. package/tests/known_failing_ratchet.mjs +19 -9
  75. package/tests/landing_legal_pages.mjs +5 -1
  76. package/tests/projects_hub_pre_uat.mjs +12 -6
  77. package/tests/served_paths_shipped.mjs +36 -2
@@ -0,0 +1,381 @@
1
+ // tests/hall_mine_lens.mjs — the hall's "Only mine" lens: goals you are added
2
+ // on, and tasks you have claimed.
3
+ //
4
+ // The lens is ONE checkbox that four tabs answer differently — Ready and Backlog
5
+ // by goal membership, In Progress by who holds the claim, Completed by who
6
+ // shipped it. That is exactly the shape a source-grep test cannot prove: a regex
7
+ // can see that `mineOn()` is called and still miss that a renderer filters the
8
+ // wrong list, or counts the unfiltered one, or silently empties every tab when
9
+ // the membership read fails. So work.js is BOOTED here, against the stub DOM
10
+ // tests/helpers.mjs already uses for the profile and idea pages, and the
11
+ // assertions read the HTML the page actually wrote.
12
+ //
13
+ // Each test below was verified by deleting the line it guards and watching it go
14
+ // red — the vacuous-guard check this suite has been bitten by before.
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 WORK_JS = fs.readFileSync(path.join(HALL, 'work.js'), 'utf8');
27
+ // The two files work.js was carved into. Run the REAL ones rather than stubbing
28
+ // them: mine-lens.js IS the subject here, and work-summary.js is the piece that
29
+ // left work.js to make room for it — a stub of either would let the carve rot.
30
+ const MINE_LENS_JS = fs.readFileSync(path.join(HALL, 'mine-lens.js'), 'utf8');
31
+ const WORK_SUMMARY_JS = fs.readFileSync(path.join(HALL, 'work-summary.js'), 'utf8');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // The fixture. Two goals, and the viewer (builder 7, @percy) is on ONE of them.
35
+ // Every list below is built so that "mine" and "not mine" are both non-empty —
36
+ // a lens that returned everything and a lens that returned nothing would both
37
+ // pass against a one-sided fixture.
38
+ // ---------------------------------------------------------------------------
39
+ const MINE_GOAL = 1000101;
40
+ const THEIRS_GOAL = 1000102;
41
+
42
+ const GOALS = [
43
+ { id: MINE_GOAL, title: 'A goal I am on', viewer_is_member: true },
44
+ { id: THEIRS_GOAL, title: 'A goal I am not on', viewer_is_member: false },
45
+ ];
46
+
47
+ const CLAIMABLE = [
48
+ { id: 501, title: 'ready in my goal', goal_id: MINE_GOAL, status: 'ready', version_id: 'V1' },
49
+ { id: 502, title: 'ready in their goal', goal_id: THEIRS_GOAL, status: 'ready', version_id: 'V1' },
50
+ // A task with NO goal at all. It is nobody's by membership, and it exists in
51
+ // the fixture to pin that the lens excludes it rather than letting the
52
+ // ungoaled backlog ride along under the word "mine".
53
+ { id: 503, title: 'ready with no goal', goal_id: null, status: 'ready', version_id: 'V1' },
54
+ ];
55
+
56
+ const BACKLOG = [
57
+ { id: 601, title: 'backlog in my goal', goal_id: MINE_GOAL, status: 'backlog', version_id: 'V1' },
58
+ { id: 602, title: 'backlog in their goal', goal_id: THEIRS_GOAL, status: 'backlog', version_id: 'V1' },
59
+ ];
60
+
61
+ const ACTIVE_CLAIMS = [
62
+ { claim_id: 9001, task_id: 701, title: 'a claim of mine', builder_id: 7, github_login: 'percy', display_name: 'Percy', claimed_at: '2026-09-20T00:00:00Z' },
63
+ { claim_id: 9002, task_id: 702, title: 'someone else holds this', builder_id: 8, github_login: 'annabeth', display_name: 'Annabeth', claimed_at: '2026-09-20T00:00:00Z' },
64
+ ];
65
+
66
+ const SHIPPED = [
67
+ { id: 801, title: 'I shipped this', github_login: 'percy', shipped_at: '2026-09-21T00:00:00Z' },
68
+ { id: 802, title: 'they shipped this', github_login: 'annabeth', shipped_at: '2026-09-21T00:00:00Z' },
69
+ ];
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // bootWorkBoard — run the REAL work.js in a vm.
73
+ //
74
+ // `goalsFail: true` makes GET /goals reject, which is the failure mode the lens
75
+ // has to survive honestly: with no membership evidence it must switch itself off
76
+ // rather than narrow both queue tabs to nothing.
77
+ // ---------------------------------------------------------------------------
78
+ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
79
+ const els = new Map();
80
+ const makeEl = (id = '') => ({
81
+ id,
82
+ dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
83
+ checked: false, value: '', title: '',
84
+ _html: '',
85
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
86
+ setAttribute() {}, getAttribute() { return null; },
87
+ listeners: {},
88
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
89
+ fire(type, ev = {}) { (this.listeners[type] || []).forEach((fn) => fn(ev)); },
90
+ querySelector() { return null; }, querySelectorAll() { return []; },
91
+ appendChild() {}, remove() {}, closest() { return null; },
92
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
93
+ });
94
+ // Keyed by the selector text with the leading '#' stripped, so a compound
95
+ // selector like '#wip-table tbody' gets its own stable element — which is
96
+ // what work.js reads the in-flight rows out of.
97
+ const el = (key) => { if (!els.has(key)) els.set(key, makeEl(key)); return els.get(key); };
98
+ const documentObj = {
99
+ getElementById: (id) => el(id),
100
+ querySelector: (sel) => el(String(sel).replace(/^#/, '')),
101
+ querySelectorAll: () => [],
102
+ createElement: () => makeEl(),
103
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
104
+ listeners: {},
105
+ body: { dataset: {} }, documentElement: { dataset: {} },
106
+ hidden: false,
107
+ };
108
+
109
+ const requests = [];
110
+ const client = {
111
+ request: async (method, url) => {
112
+ requests.push(`${method} ${url}`);
113
+ const ok = (data) => ({ status: 200, ok: true, data });
114
+ if (url.endsWith('/me')) {
115
+ // `signedIn: false` is a 200 that carries NO builder, not a 401: a 401
116
+ // bounces this page to sign-in before anything renders (and the server
117
+ // page gate bars an unauthenticated visitor from /work outright), so the
118
+ // identity-less state the lens actually has to survive is this one.
119
+ return signedIn
120
+ ? ok({ builder: { id: 7, display_name: 'Percy', github_login: 'percy', rank: 'metic' }, task_kinds: ['build'] })
121
+ : ok({ builder: null });
122
+ }
123
+ if (url.endsWith('/goals')) {
124
+ return goalsFail ? { status: 500, ok: false, data: null } : ok({ goals: GOALS });
125
+ }
126
+ if (url.includes('/tasks/claimable')) return ok({ claimable: CLAIMABLE, active_claims: ACTIVE_CLAIMS });
127
+ if (url.includes('/tasks?status=backlog')) return ok({ tasks: BACKLOG });
128
+ if (url.includes('/public/recent-shipped')) return ok({ tasks: SHIPPED });
129
+ if (url.includes('/versions')) return ok({ versions: [{ version_id: 'V1' }] });
130
+ if (url.includes('/public/progress')) return ok({ progress: [{ version_id: 'V1', lifecycle_shipped_count: 42 }] });
131
+ if (url.includes('/public/cost-summary')) return ok({ total_value_credits: 1234 });
132
+ return ok({});
133
+ },
134
+ };
135
+
136
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
137
+
138
+ let tabsOnChange = null;
139
+ const kit = {
140
+ makePager: (prefix) => ({ prefix, page: 1, perPage: 100 }),
141
+ paginate: (rows) => rows,
142
+ wirePager() {},
143
+ pagerBarHtml: () => '',
144
+ makeTabs: ({ tabs, onChange }) => {
145
+ let current = tabs[0].id;
146
+ tabsOnChange = onChange;
147
+ return { active: () => current, setCount() {}, activate(id) { current = id; onChange(id); } };
148
+ },
149
+ // The real bar is hall-kit's; here it only has to MAKE the controls readable,
150
+ // because what this suite asserts is what the renderers do with their values.
151
+ makeFilterBar: ({ search, primary = [], overflow = [] }) => {
152
+ [search, ...primary, ...overflow].filter(Boolean).forEach((c) => {
153
+ el(c.id).value = c.value ?? c.default ?? '';
154
+ });
155
+ return { values: () => ({}), get: () => '', set() {} };
156
+ },
157
+ ageChipHtml: () => '',
158
+ };
159
+
160
+ const windowObj = {
161
+ __BRANDING__: { currency: { label: 'credits' } },
162
+ BongosClient: { createClient: () => client },
163
+ OTBKit: kit,
164
+ OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
165
+ OTBTaskLib: {
166
+ priorityRank: () => 0, touchesConflict: () => false, rankTagHtml: () => '',
167
+ creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
168
+ taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
169
+ },
170
+ OTB: {
171
+ escapeHtml,
172
+ $: (sel) => documentObj.querySelector(sel),
173
+ fmtNum: (n) => String(n ?? 0),
174
+ fmtDate: (d) => String(d ?? '').slice(0, 10),
175
+ errCode: () => null, errDetails: () => ({}),
176
+ toast() {},
177
+ categoryChip: () => '',
178
+ fillKindFilter() {},
179
+ kindIsUnset: () => true, KIND_UNSET: 'unclassified',
180
+ roleAxisLabel: () => 'Role',
181
+ roleOptions: () => [{ value: '', label: '— any —' }],
182
+ roleIsUnset: () => true, roleLabel: () => '', ROLE_UNSET: 'unclassified',
183
+ },
184
+ location: { hostname: 'builders.example.com', pathname: '/work', search: '', hash: '', href: 'https://builders.example.com/work' },
185
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
186
+ confirm: () => true,
187
+ addEventListener() {},
188
+ };
189
+
190
+ const sandbox = {
191
+ window: windowObj, document: documentObj, location: windowObj.location,
192
+ localStorage: windowObj.localStorage, console, setTimeout, clearTimeout,
193
+ setInterval: () => 0, clearInterval() {}, Date, URL, URLSearchParams, navigator: {},
194
+ history: { replaceState() {} },
195
+ };
196
+ sandbox.globalThis = sandbox;
197
+ vm.createContext(sandbox);
198
+ // Both are UMD modules that attach to the sandbox global rather than to
199
+ // `window` (there is no `module` here), so they are bridged across by hand.
200
+ vm.runInContext(MINE_LENS_JS, sandbox, { filename: 'mine-lens.js' });
201
+ vm.runInContext(WORK_SUMMARY_JS, sandbox, { filename: 'work-summary.js' });
202
+ windowObj.OTBMineLens = sandbox.OTBMineLens;
203
+ windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
204
+ vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
205
+ (documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
206
+
207
+ return {
208
+ el,
209
+ requests,
210
+ // Flip the lens the way a click does: set the box, then fire its listener.
211
+ setMine: (on) => { el('f-mine').checked = on; el('f-mine').fire('change'); },
212
+ switchTab: (id) => { if (tabsOnChange) tabsOnChange(id); },
213
+ };
214
+ }
215
+
216
+ // settle() twice: loadAll awaits /me, then the goals read, then the claimable
217
+ // read, and the history feed lands on its own promise chain.
218
+ const ready = async (board) => { for (let i = 0; i < 6; i += 1) await settle(); return board; };
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // 1. The queue tabs: "mine" is GOAL MEMBERSHIP
222
+ // ---------------------------------------------------------------------------
223
+
224
+ test('Ready narrows to tasks in a goal the viewer is a member of', async () => {
225
+ const b = await ready(bootWorkBoard());
226
+ const before = b.el('task-list').innerHTML;
227
+ assert.match(before, /ready in my goal/, 'the unfiltered board shows both goals');
228
+ assert.match(before, /ready in their goal/);
229
+
230
+ b.setMine(true);
231
+ const after = b.el('task-list').innerHTML;
232
+ assert.match(after, /ready in my goal/, 'the task in my goal survives');
233
+ assert.doesNotMatch(after, /ready in their goal/, 'the task in a goal I am not on is gone');
234
+ });
235
+
236
+ test('a task with NO goal is not "mine" — the lens is membership, not leftovers', async () => {
237
+ const b = await ready(bootWorkBoard());
238
+ assert.match(b.el('task-list').innerHTML, /ready with no goal/, 'it is there unfiltered');
239
+ b.setMine(true);
240
+ assert.doesNotMatch(b.el('task-list').innerHTML, /ready with no goal/,
241
+ 'an ungoaled task belongs to nobody, so it cannot arrive under "only mine"');
242
+ });
243
+
244
+ test('Backlog narrows through the SAME predicate, so the lens cannot reach one tab only', async () => {
245
+ const b = await ready(bootWorkBoard());
246
+ assert.match(b.el('backlog-list').innerHTML, /backlog in their goal/);
247
+ b.setMine(true);
248
+ assert.match(b.el('backlog-list').innerHTML, /backlog in my goal/);
249
+ assert.doesNotMatch(b.el('backlog-list').innerHTML, /backlog in their goal/);
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // 2. In Progress: "mine" is THE CLAIM
254
+ // ---------------------------------------------------------------------------
255
+
256
+ test('In Progress narrows to the claims the viewer is holding', async () => {
257
+ const b = await ready(bootWorkBoard());
258
+ const tbody = b.el('wip-table tbody');
259
+ assert.match(tbody.innerHTML, /someone else holds this/, 'unfiltered, the whole board is in flight');
260
+
261
+ b.setMine(true);
262
+ assert.match(tbody.innerHTML, /a claim of mine/);
263
+ assert.doesNotMatch(tbody.innerHTML, /someone else holds this/,
264
+ "another builder's live claim is not mine");
265
+ });
266
+
267
+ test('the in-flight SECTION pill counts what is shown; the TAB pill keeps the whole board', async () => {
268
+ // The distinction is the point: narrowing to my own claims must not make the
269
+ // board look idle. The tab pill is the project's in-flight count either way.
270
+ const b = await ready(bootWorkBoard());
271
+ assert.equal(b.el('wip-count').textContent, '2');
272
+ b.setMine(true);
273
+ assert.equal(b.el('wip-count').textContent, '1', 'the section pill follows the narrowed list');
274
+ });
275
+
276
+ test('holding nothing says so, instead of reading as a quiet board', async () => {
277
+ const b = await ready(bootWorkBoard());
278
+ // Hand the page an in-flight list with none of the viewer's own claims by
279
+ // flipping the lens on a fixture where builder 7 holds one — then assert the
280
+ // OTHER direction: a viewer who holds nothing gets the specific sentence.
281
+ ACTIVE_CLAIMS[0].builder_id = 8;
282
+ try {
283
+ b.setMine(true);
284
+ assert.match(b.el('wip-table tbody').innerHTML, /You are not holding any claims right now/,
285
+ 'the empty state names the lens, not the project');
286
+ } finally {
287
+ ACTIVE_CLAIMS[0].builder_id = 7;
288
+ }
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // 3. Completed: "mine" is WHO SHIPPED IT
293
+ // ---------------------------------------------------------------------------
294
+
295
+ test('Completed narrows to the viewer\'s own ships, matched on the login the public feed carries', async () => {
296
+ const b = await ready(bootWorkBoard());
297
+ assert.match(b.el('history-list').innerHTML, /they shipped this/);
298
+ b.setMine(true);
299
+ assert.match(b.el('history-list').innerHTML, /I shipped this/);
300
+ assert.doesNotMatch(b.el('history-list').innerHTML, /they shipped this/);
301
+ });
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // 4. The two ways the lens must refuse rather than lie
305
+ // ---------------------------------------------------------------------------
306
+
307
+ test('with no identity the lens is disabled — never a checkbox that empties every tab', async () => {
308
+ const b = await ready(bootWorkBoard({ signedIn: false }));
309
+ assert.equal(b.el('f-mine').disabled, true);
310
+ assert.equal(b.el('f-mine').checked, false);
311
+ assert.match(b.el('f-mine').title, /Sign in/);
312
+ });
313
+
314
+ test('a failed membership read disarms the lens instead of answering "you are on no goals"', async () => {
315
+ const b = await ready(bootWorkBoard({ goalsFail: true }));
316
+ assert.equal(b.el('f-mine').disabled, true,
317
+ 'with no evidence the lens is off the table');
318
+ assert.match(b.el('f-mine').title, /could not be loaded/,
319
+ 'and it says the read failed rather than implying an answer');
320
+ // The board itself is untouched — losing the lens must not lose the queue.
321
+ assert.match(b.el('task-list').innerHTML, /ready in their goal/);
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // 5. The word means something different on each tab, so the hint is rewritten
326
+ // ---------------------------------------------------------------------------
327
+
328
+ test('the hint beside the checkbox is re-read from the tab, not left generic', async () => {
329
+ const b = await ready(bootWorkBoard());
330
+ const hint = b.el('work-mine-hint');
331
+ assert.equal(hint.textContent, 'tasks in goals you are on', 'Ready opens the board');
332
+ b.switchTab('in-progress');
333
+ assert.equal(hint.textContent, 'claims you are holding');
334
+ b.switchTab('completed');
335
+ assert.equal(hint.textContent, 'tasks you shipped');
336
+ b.switchTab('backlog');
337
+ assert.equal(hint.textContent, 'tasks in goals you are on');
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // 6. It costs nothing to ask
342
+ // ---------------------------------------------------------------------------
343
+
344
+ test('the lens is answered from data the board already fetched — no read per goal', async () => {
345
+ const b = await ready(bootWorkBoard());
346
+ const before = b.requests.length;
347
+ b.setMine(true);
348
+ assert.equal(b.requests.length, before,
349
+ 'flipping the lens makes no request: viewer_is_member rides the goal list');
350
+ assert.ok(b.requests.some((r) => r.endsWith('/goals')),
351
+ 'and the goal list is the one read it depends on');
352
+ assert.ok(!b.requests.some((r) => /\/goals\/\d+\/members/.test(r)),
353
+ 'never a membership read per goal');
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // 7. The carve: work-summary.js
358
+ //
359
+ // The momentum card left work.js so the lens could fit under the size ceiling.
360
+ // Two things have to stay true across that move — it still paints, and it still
361
+ // reports the PROJECT, not the viewer. The second is the risk the carve created:
362
+ // the card sits directly above a list the lens narrows, and a card that quietly
363
+ // restated one builder's output as the project's totals would look right.
364
+ // ---------------------------------------------------------------------------
365
+
366
+ test('the momentum card still renders the project-wide figures after the carve', async () => {
367
+ const b = await ready(bootWorkBoard());
368
+ const card = b.el('works-summary');
369
+ assert.equal(card.hidden, false, 'the card is shown once the shipped counts land');
370
+ assert.match(card.innerHTML, /Works shipped/);
371
+ assert.match(card.innerHTML, /42/, 'the all-time shipped count');
372
+ assert.match(card.innerHTML, /1234/, 'and the credits awarded');
373
+ });
374
+
375
+ test('"Only mine" does NOT narrow the project totals above the list it narrows', async () => {
376
+ const b = await ready(bootWorkBoard());
377
+ const before = b.el('works-summary').innerHTML;
378
+ b.setMine(true);
379
+ assert.equal(b.el('works-summary').innerHTML, before,
380
+ 'the card reports the project; the lens reports the reader');
381
+ });
@@ -65,6 +65,15 @@ function boot({
65
65
  // `{ outcome: null }`, which is the server saying "nothing to state yet"; the
66
66
  // line must go quiet for both, and there is a case below for each.
67
67
  landing = undefined,
68
+ // OUT-OF-ORDER READS (task 1004108). Names of fakes whose answers are PARKED
69
+ // instead of returned: the call is recorded and its response computed at once,
70
+ // but the caller's `await` does not resolve until the test releases it. That is
71
+ // the only way two reads of the same endpoint are ever in flight together here
72
+ // — every other fake is an `async` function that settles before the next call
73
+ // is made, so a monotonic-sequence guard sits on an unreachable branch and
74
+ // deleting it reds nothing. Pass e.g. `defer: ['preview']` and drive
75
+ // `r.deferred` in whatever order the race being pinned requires.
76
+ defer = [],
68
77
  } = {}) {
69
78
  const els = new Map();
70
79
  const makeEl = () => ({
@@ -88,6 +97,21 @@ function boot({
88
97
  };
89
98
 
90
99
  const calls = [];
100
+
101
+ // Parked calls, in REQUEST order. Each carries the name it was made under, the
102
+ // arguments it was made with, and `release()` — which hands the caller the
103
+ // answer the fake had already computed. Releasing out of order is the point.
104
+ const deferred = [];
105
+ const deferSet = new Set(defer);
106
+ const gate = (name, fn) => (...args) => {
107
+ const answer = fn(...args); // record the call and compute the reply NOW...
108
+ if (!deferSet.has(name)) return answer;
109
+ let release;
110
+ const held = new Promise((resolve) => { release = resolve; });
111
+ deferred.push({ name, args, release: () => release(answer) }); // ...hand it over LATER
112
+ return held;
113
+ };
114
+
91
115
  const client = {
92
116
  inbox: {
93
117
  getInboxTemplate: async () => ({ status: 200, ok: true, data: { template: TEMPLATE } }),
@@ -117,7 +141,7 @@ function boot({
117
141
  // real matrix for the fixtures this file already files with: a goal named
118
142
  // ⇒ it lands there, no goal ⇒ it floats. A test that cares about a
119
143
  // particular verdict passes `landing` and overrides the lot.
120
- postInboxPreviewLanding: async (a) => {
144
+ postInboxPreviewLanding: gate('preview', async (a) => {
121
145
  calls.push(['preview', a]);
122
146
  if (landing === null) return { status: 500, ok: false, data: null };
123
147
  if (landing !== undefined) return { status: 200, ok: true, data: landing };
@@ -127,7 +151,7 @@ function boot({
127
151
  return { status: 200, ok: true, data: { outcome: 'routed', reason: 'bug_trusted_filer', status: 'ready', goal: { id: String(gid), title: g ? g.title : `goal ${gid}` }, version_id: 'BV1', refusal: null } };
128
152
  }
129
153
  return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } };
130
- },
154
+ }),
131
155
  },
132
156
  // task 1003069 (R08) — the quick-filing goal picker's reads: the building
133
157
  // versions (to scope the suggester) and the open goal list (the picker's
@@ -137,7 +161,7 @@ function boot({
137
161
  },
138
162
  goals: {
139
163
  getGoals: async () => { calls.push(['goals', null]); return { status: 200, ok: true, data: { goals } }; },
140
- getGoalsSuggestions: async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; },
164
+ getGoalsSuggestions: gate('suggest', async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; }),
141
165
  },
142
166
  };
143
167
 
@@ -173,7 +197,7 @@ function boot({
173
197
 
174
198
  const el = (id) => documentObj.getElementById(id);
175
199
  return {
176
- calls, toasts, store, el,
200
+ calls, toasts, store, el, deferred,
177
201
  queueHtml: () => el('fi-queue').innerHTML,
178
202
  fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
179
203
  submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
@@ -678,11 +702,67 @@ await test('filing clears the line — gravity describes a draft, and the draft
678
702
  assert.equal(r.el('fq-receipts').hidden, false, 'the RECEIPT is what survives instead (task 1004082)');
679
703
  });
680
704
 
705
+ // --- out-of-order reads (task 1004108) -----------------------------------
706
+ //
707
+ // Everything above this line drives fakes that settle before the next call is
708
+ // made, so `refreshGravity`'s monotonic `gravitySeq` guard sat on a branch no
709
+ // case could reach: deleting it redded NOTHING, which the closing note below
710
+ // used to record as an honest gap. `boot({ defer: [...] })` parks a named fake's
711
+ // answer until the test hands it back, so two reads are genuinely in flight at
712
+ // once and the slower one can land last.
713
+
714
+ await test('two previews are genuinely in flight at once — the harness can interleave them', async () => {
715
+ // The non-vacuity guard for the case beneath it. If `defer` ever stopped
716
+ // parking, the race case below would still pass — it would just be testing two
717
+ // sequential reads again, which is the exact hole this pair exists to close.
718
+ const r = boot({ defer: ['preview'] });
719
+ await settle(); await settle();
720
+ r.el('fq-title').value = 'a loose thought';
721
+ r.el('fq-goal')._handlers.change();
722
+ await settle(); await settle();
723
+ assert.equal(r.deferred.length, 1, 'the first read is parked, not answered');
724
+ assert.equal(r.el('fq-gravity').textContent, '', 'and nothing is painted while it is in flight');
725
+ r.el('fq-goal').value = '77';
726
+ r.el('fq-goal')._handlers.change();
727
+ await settle(); await settle();
728
+ assert.equal(r.deferred.length, 2, 'the second read starts while the first is still unanswered');
729
+ assert.deepEqual(r.deferred.map((d) => d.name), ['preview', 'preview']);
730
+ });
731
+
732
+ await test('a slow preview answering after a faster one never repaints the stale verdict', async () => {
733
+ // The race a builder hits by picking a goal and then changing their mind
734
+ // faster than the server answers. The FIRST request (no goal ⇒ "floats") comes
735
+ // back LAST; the line must keep the SECOND request's verdict.
736
+ const r = boot({ defer: ['preview'] });
737
+ await settle(); await settle();
738
+ r.el('fq-title').value = 'the widget is broken';
739
+ r.el('fq-goal').value = '';
740
+ r.el('fq-goal')._handlers.change();
741
+ await settle();
742
+ r.el('fq-goal').value = '77';
743
+ r.el('fq-goal')._handlers.change();
744
+ await settle();
745
+ assert.equal(r.deferred.length, 2, 'both reads are in flight');
746
+ assert.equal(r.deferred[0].args[0].body.goal_id, undefined, 'the first asked with no goal');
747
+ assert.equal(r.deferred[1].args[0].body.goal_id, 77, 'the second asked with the goal picked');
748
+
749
+ r.deferred[1].release(); // the NEWER answer lands first
750
+ await settle(); await settle();
751
+ assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the current verdict paints');
752
+
753
+ r.deferred[0].release(); // and now the older, slower one arrives
754
+ await settle(); await settle();
755
+ assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the stale answer does not repaint the line');
756
+ assert.doesNotMatch(r.el('fq-gravity').textContent, /floats in the inbox/, 'the superseded verdict never reaches the builder');
757
+ assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
758
+ });
759
+
681
760
  // Every guard above was reverted and each redded its named case (RUN, and these
682
761
  // are the measured counts, not the predicted ones):
683
762
  // drop renderGravity from the goal-change handler -> 2 red (named-goal + filing-clears)
684
763
  // infer from the select instead of the verdict -> 4 red (both lies, + backlog + refusal)
685
- // drop the seq guard and paint every answer -> 0 red <-- see below
764
+ // drop the seq guard and paint every answer -> 1 red (the race case; task 1004108)
765
+ // make boot's `defer` never park (gate returns at once) -> 2 red (both race cases)
686
766
  // rename the id in ideas.html -> 1 red (the markup pin ONLY)
687
767
  //
688
768
  // THE FIRST LINE COST A TEST FIX, and it is the reason to run these rather than
@@ -692,13 +772,22 @@ await test('filing clears the line — gravity describes a draft, and the draft
692
772
  // rendered at all. It asserts on the TEXT now. A stub's defaults can agree with
693
773
  // a broken page, which is the same lesson the markup pin below teaches twice.
694
774
  //
695
- // THE THIRD LINE IS AN HONEST GAP, recorded rather than papered over. The
696
- // out-of-order guard in refreshGravity is not covered: the fake resolves
697
- // synchronously, so two previews cannot interleave here and a test that removed
698
- // the guard would still pass. Pinning it needs a fake that delays per call,
699
- // which is a different harness from this one. Filed as task 1004108 rather than
700
- // claimed as tested — a green test over an unreachable branch would be a false
701
- // claim of coverage, which is the failure this file's own note above warns about.
775
+ // THE THIRD LINE WAS AN HONEST GAP UNTIL TASK 1004108, and the shape of the fix
776
+ // is worth keeping: the guard was not wrong, it was UNREACHABLE. Every fake here
777
+ // was an `async` function that settled before the next call was made, so two
778
+ // previews could never overlap and deleting `if (seq !== gravitySeq) return;`
779
+ // redded nothing — a green suite over a branch no case could enter. `boot({
780
+ // defer: ['preview'] })` parks a named fake's answer until the test hands it
781
+ // back, which makes the overlap real and the guard a tested property. The FOURTH
782
+ // line above is the guard on the guard: if `defer` ever stops parking, the race
783
+ // case would quietly go back to testing two sequential reads, so a case asserts
784
+ // that both calls are in flight before the ordering is asserted at all.
785
+ //
786
+ // `getGoalsSuggestions` is gated the same way and for the same reason, but has
787
+ // NO case yet: `refreshGoalSuggestions` carries no sequence guard — only
788
+ // `goalTouchedByUser` — so a test of its ordering would red against the current
789
+ // page rather than pin it. That is a production bug to file, not a test to
790
+ // write, and the harness is now able to reach it the day it is fixed.
702
791
 
703
792
  console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
704
793
  if (failed > 0) process.exit(1);
@@ -38,16 +38,26 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
38
38
  // right all along. The apex flip removed the redirect, so the test passes and
39
39
  // the entry had to go in the same change — this file fails the suite the moment
40
40
  // a parked test starts passing.
41
- const MAX_KNOWN_FAILING = 1;
41
+ //
42
+ // Ratcheted down 1 → 0 by task 1002617, which unparked served_paths_shipped — the last
43
+ // entry. Its own note here had PREDICTED the resolution ("whoever lands that claim should
44
+ // re-check this assertion and remove this entry"); the claim landed (task 1002200, the
45
+ // self-healing /docs/openapi.json endpoint) and the entry outlived it by months. That is
46
+ // the graveyard this file exists to prevent, and it took this ratchet going red to close
47
+ // the loop — which is the ratchet working, just later than it should have.
48
+ //
49
+ // AT ZERO, this file now asserts something stronger than a cap: that NOTHING is parked.
50
+ // Parking the next failure means raising this number in the same diff, which is exactly
51
+ // the deliberate, reviewable act it is meant to be.
52
+ const MAX_KNOWN_FAILING = 0;
42
53
 
43
- const KNOWN_FAILING = {
44
- served_paths_shipped: {
45
- owner: 'task 1002617; collides with the ACTIVE claim "make the /docs/openapi.json endpoint self-healing"',
46
- why: 'Asserts serve-internal.js reads docs/api/openapi.json. That exact path is what '
47
- + 'the in-flight claim is changing, so touching it here would collide. Whoever lands '
48
- + 'that claim should re-check this assertion and remove this entry.',
49
- },
50
- };
54
+ // EMPTY — nothing is parked, and that is the intended steady state.
55
+ //
56
+ // An entry here is invisible coverage: the test still runs, but its failure is expected,
57
+ // so nobody looks at it. The last one sat for months while the very change it was waiting
58
+ // on had already landed. If you add one, give it an owner and a concrete condition for
59
+ // removal, and raise MAX_KNOWN_FAILING in the same diff.
60
+ const KNOWN_FAILING = {};
51
61
 
52
62
  function runsRed(name) {
53
63
  try {
@@ -103,7 +103,11 @@ test('the landing page and the hub footers link privacy, terms and contact', ()
103
103
  for (const href of ['/privacy', '/terms', '/contact']) {
104
104
  assert.match(foot, new RegExp(`<a href="${href}">`), `${file}: footer links ${href}`);
105
105
  }
106
- assert.match(foot, /Open source \(AGPL-3\.0\)/, `${file}: the licence link stays`);
106
+ // task 1003920 split one link into two: the WORDS now open the published code and the licence
107
+ // sits behind its own link beside it, so the claim can be checked by whoever clicks it. Both
108
+ // halves are asserted — a bare /Open source/ would pass again the moment the licence link went.
109
+ assert.match(foot, /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core"[^>]*>Open source<\/a>/, `${file}: the words open the actual code`);
110
+ assert.match(foot, /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html"[^>]*>AGPL-3\.0<\/a>/, `${file}: the licence link stays`);
107
111
  assert.match(foot, /Built by agents/, `${file}: the attribution half stays`);
108
112
  }
109
113
  });
@@ -474,16 +474,22 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
474
474
  });
475
475
 
476
476
  test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
477
- const AGPL = /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">Open source \(AGPL-3\.0\)<\/a>\./;
477
+ // task 1003920 split this into TWO links: the words "Open source" open the published package and
478
+ // the licence sits behind its own link beside it. Both are asserted, and CODE is anchored first
479
+ // because it is the half 1003920 added and the half nothing else in the repo pins.
480
+ const CODE = /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core" target="_blank" rel="noopener">Open source<\/a>/;
481
+ const AGPL = /\(<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">AGPL-3\.0<\/a>\)\./;
478
482
  const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
479
483
  // task 1003298: the chrome-world landing's footer is a real <footer>; the
480
484
  // pre-world page used <div class="foot">. Either element is the footer.
481
485
  const indexFoot = (INDEX.match(/<footer(?:\s[^>]*)?>[\s\S]*?<\/footer>|<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
482
- assert.match(hubFoot, AGPL, 'projects.html');
483
- assert.match(indexFoot, AGPL, 'index.html');
484
- // the link sits OUTSIDE the apex-only span, so a child instance keeps it too
485
- assert.ok(hubFoot.indexOf('<!--apex-only:end-->') < hubFoot.search(AGPL), 'projects.html: after the apex-only block');
486
- assert.ok(indexFoot.indexOf('<!--apex-only:end-->') < indexFoot.search(AGPL), 'index.html: after the apex-only block');
486
+ assert.match(hubFoot, CODE, 'projects.html: the words open the actual code');
487
+ assert.match(indexFoot, CODE, 'index.html: the words open the actual code');
488
+ assert.match(hubFoot, AGPL, 'projects.html: the licence sits beside it');
489
+ assert.match(indexFoot, AGPL, 'index.html: the licence sits beside it');
490
+ // both links sit OUTSIDE the apex-only span, so a child instance keeps them too
491
+ assert.ok(hubFoot.indexOf('<!--apex-only:end-->') < hubFoot.search(CODE), 'projects.html: after the apex-only block');
492
+ assert.ok(indexFoot.indexOf('<!--apex-only:end-->') < indexFoot.search(CODE), 'index.html: after the apex-only block');
487
493
  assert.match(COSMOS, /\.foot a\{color:var\(--link-ink\);font-weight:700;text-decoration:none;\}/, 'never the browser’s blue');
488
494
  });
489
495