@bongos/core 1.19.864 → 1.19.866

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.
package/tests/helpers.mjs CHANGED
@@ -281,6 +281,10 @@ export function makeEl(tagName, attrs = {}) {
281
281
  attrs,
282
282
  children: [],
283
283
  parentNode: null,
284
+ // parentElement — every parent this stub ever creates IS an element (no
285
+ // text-node parents tracked separately), so it is always the same value
286
+ // as parentNode; a getter keeps the two from drifting if that changes.
287
+ get parentElement() { return this.parentNode; },
284
288
  className: attrs.class || '',
285
289
  id: attrs.id || '',
286
290
  name: attrs.name,
@@ -325,6 +329,10 @@ export function makeEl(tagName, attrs = {}) {
325
329
  walk(this, (c) => { if (matches(c, sel)) out.push(c); return null; });
326
330
  return out;
327
331
  },
332
+ // HTMLSelectElement.options — real <select> elements have it; hall-kit.js's
333
+ // wirePager() reads `.length` off it before deciding whether to (re)build
334
+ // the per-page choices.
335
+ get options() { return this.tagName === 'select' ? this.querySelectorAll('option') : undefined; },
328
336
  closest(sel) {
329
337
  let node = this;
330
338
  while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
@@ -340,6 +348,11 @@ export function makeEl(tagName, attrs = {}) {
340
348
  },
341
349
  addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
342
350
  focus() {},
351
+ // HTMLFormElement.reset() — real forms have it, most elements do not, but a
352
+ // universal no-op is harmless (nothing currently reads a cleared value back)
353
+ // and lets a page script that calls form.reset() run without a stub-specific
354
+ // workaround in every test that exercises one.
355
+ reset() {},
343
356
  fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
344
357
  };
345
358
  for (const [k, v] of Object.entries(attrs)) {
@@ -177,7 +177,17 @@ test('the hall landing renders one door per mode, and its form collapses on the
177
177
  assert.match(door, /mode === 'invite_only'/);
178
178
  assert.match(door, /there is nothing to apply for here/);
179
179
  assert.match(door, /id="access-request-form"/, 'the apply door keeps the request form');
180
- assert.match(hall, /\$\{landingDoorHtml\(joinabilityMode\(\)\)\}/, 'renderLanding paints the door the pack names');
180
+ // This read `${landingDoorHtml(joinabilityMode())}` inline. Task 1004004 gave
181
+ // the apply door a real sign-in button, so renderLanding resolves the mode
182
+ // ONCE and hands the same value to both doors. The invariant is unchanged and
183
+ // now reaches further: BOTH doors are painted from the pack's own answer, so
184
+ // neither can drift onto a second reading of joinability.
185
+ const landing = (hall.match(/function renderLanding\(\) \{[\s\S]*?\n \}\n/) || [''])[0];
186
+ assert.ok(landing, 'renderLanding() must exist');
187
+ assert.match(landing, /const mode = joinabilityMode\(\);/, 'the pack names the mode, once');
188
+ assert.match(landing, /\$\{landingDoorHtml\(mode\)\}/, 'renderLanding paints the door the pack names');
189
+ assert.match(landing, /\$\{returningDoorHtml\(mode\)\}/, 'and the returning-builder door off the same answer');
190
+ assert.equal((landing.match(/joinabilityMode\(\)/g) || []).length, 1, 'joinability is read once, not per door');
181
191
  assert.match(hall, /code === 'invite_only' \|\| code === 'open_enrollment'/, 'the refusal codes are handled, not shown red');
182
192
  });
183
193
 
@@ -73,6 +73,18 @@ const DEFAULT_SUGGESTIONS = {
73
73
  BV1: [{ id: 1000058, title: 'Consumer path', score: 4, matched_terms: ['overlay'] }],
74
74
  BV2: [{ id: 1000062, title: 'Idea surfaces', score: 6, matched_terms: ['promote', 'merge'] }],
75
75
  };
76
+ // task 1003456: the blocker resolve panel rides the SAME overlay, so it rides
77
+ // the same executing harness — a source-regex test cannot catch the exact
78
+ // class of bug this file's own header names (a ReferenceError inside a submit
79
+ // handler that passes review and 500s at click time).
80
+ const DEFAULT_BLOCKER = {
81
+ id: 5000042,
82
+ status: 'open',
83
+ title: 'Need the owner’s AWS card on file',
84
+ body_md: 'Raise the S3 budget or approve a spend.',
85
+ created_at: '2026-09-01T00:00:00.000Z',
86
+ blocking_task_ids: [],
87
+ };
76
88
 
77
89
  const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
78
90
  (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -89,7 +101,9 @@ function fail(status, code, message) {
89
101
 
90
102
  async function boot(opts = {}) {
91
103
  const {
104
+ kind = 'idea',
92
105
  idea = DEFAULT_IDEA,
106
+ blocker = DEFAULT_BLOCKER,
93
107
  rank = 'metic',
94
108
  versions = DEFAULT_VERSIONS,
95
109
  suggestions = DEFAULT_SUGGESTIONS,
@@ -98,8 +112,10 @@ async function boot(opts = {}) {
98
112
  createdTask = { id: 1003600, title: 'Cover the overlay handlers' },
99
113
  createFails = null,
100
114
  patchFails = null,
115
+ resolveFails = null,
101
116
  mergeTargets = { 1000900: { id: 1000900, title: 'The canonical idea' } },
102
117
  } = opts;
118
+ const ref = kind === 'blocker' ? blocker : idea;
103
119
 
104
120
  const requests = [];
105
121
  const toasts = [];
@@ -130,6 +146,15 @@ async function boot(opts = {}) {
130
146
  }
131
147
  if (method === 'POST' && route === '/tasks') return createFails || ok({ task: { ...createdTask, title: body.title } });
132
148
  if (method === 'PATCH' && route.startsWith('/inbox/')) return patchFails || ok({ idea: { ...idea, status: body.status } });
149
+ // task 1003456 — the blocker overlay panel rides the same stub.
150
+ if (method === 'GET' && route.startsWith('/blockers/')) {
151
+ const id = Number(route.slice('/blockers/'.length));
152
+ if (id === Number(blocker.id)) return ok({ blocker });
153
+ return { status: 404, ok: false, data: null };
154
+ }
155
+ if (method === 'POST' && /^\/blockers\/\d+\/resolve$/.test(route)) {
156
+ return resolveFails || ok({ blocker: { ...blocker, status: 'resolved', resolution_note: body.resolution_note } });
157
+ }
133
158
  return ok({});
134
159
  },
135
160
  };
@@ -138,7 +163,7 @@ async function boot(opts = {}) {
138
163
  hostname: 'builders.example.test',
139
164
  pathname: '/builders',
140
165
  search: '',
141
- hash: `#/idea/${idea.id}`,
166
+ hash: `#/${kind}/${ref.id}`,
142
167
  href: '',
143
168
  assign(u) { navigations.push(['assign', u]); },
144
169
  replace(u) { navigations.push(['replace', u]); },
@@ -246,6 +271,59 @@ await test('a closed idea offers no triage panel', async () => {
246
271
  assert.equal(h.panel(), null, 'a settled idea has nothing left to decide');
247
272
  });
248
273
 
274
+ // ===========================================================================
275
+ // task 1003456 — the blocker resolve panel, on the SAME overlay
276
+ // ===========================================================================
277
+
278
+ await test('an open blocker offers a Metic the Resolve panel', async () => {
279
+ const h = await boot({ kind: 'blocker' });
280
+ assert.ok(h.overlay(), 'a #deeplink-card overlay is mounted');
281
+ const p = h.panel();
282
+ assert.ok(p, 'an open blocker carries the resolve panel');
283
+ const verbs = p.querySelectorAll('[data-go]').map((b) => b.dataset.go);
284
+ assert.deepEqual(verbs, ['resolve'], 'one verdict — resolve — not the idea three');
285
+ });
286
+
287
+ await test('a Xenos gets the blocker card but no resolve panel (the server gate is mirrored, not trusted)', async () => {
288
+ const h = await boot({ kind: 'blocker', rank: 'xenos' });
289
+ assert.ok(h.overlay(), 'the record is still readable');
290
+ assert.equal(h.panel(), null, 'the action is not offered below Metic');
291
+ });
292
+
293
+ await test('a resolved blocker offers no resolve panel, and says what closed it', async () => {
294
+ const h = await boot({ kind: 'blocker', blocker: { ...DEFAULT_BLOCKER, status: 'resolved', resolution_note: 'raised the budget' } });
295
+ assert.equal(h.panel(), null, 'a settled blocker has nothing left to decide');
296
+ const note = h.overlay().querySelectorAll('.deeplink-card__note').map((n) => n.textContent).join(' | ');
297
+ assert.match(note, /Resolved: raised the budget/);
298
+ });
299
+
300
+ await test('resolving posts to the real route, toasts, dispatches otb:blocker-resolved, and closes the overlay', async () => {
301
+ const h = await boot({ kind: 'blocker' });
302
+ await h.clickGo('resolve');
303
+ const form = h.panel().querySelector('[data-form="resolve"]');
304
+ form.note.value = 'raised the budget';
305
+ await h.submitForm('resolve');
306
+ const posts = h.requestsTo('POST', /\/blockers\/5000042\/resolve$/);
307
+ assert.equal(posts.length, 1, 'exactly one resolve POST');
308
+ assert.equal(posts[0].body.resolution_note, 'raised the budget');
309
+ assert.ok(h.toasts.some((t) => /Blocker #5000042 resolved\./.test(t)));
310
+ assert.equal(h.events.length, 1, 'the otb:blocker-resolved event fired');
311
+ assert.equal(h.events[0].type, 'otb:blocker-resolved');
312
+ assert.equal(h.events[0].detail.id, 5000042);
313
+ assert.equal(h.overlay(), null, 'the overlay closes on a settled verdict, the idea-panel precedent');
314
+ });
315
+
316
+ await test('a 403 on resolve renders HONEST copy and leaves the form open — never a silent no-op', async () => {
317
+ const h = await boot({
318
+ kind: 'blocker',
319
+ resolveFails: { status: 403, ok: false, data: { error: { code: 'permission_forbidden', message: 'nope' } } },
320
+ });
321
+ await h.clickGo('resolve');
322
+ await h.submitForm('resolve');
323
+ assert.equal(h.errText(), 'You need Metic+ access to resolve blockers.');
324
+ assert.ok(h.overlay(), 'the overlay stays open so the builder can see why it failed');
325
+ });
326
+
249
327
  // ===========================================================================
250
328
  // Promote — the form the version and goal pickers live on
251
329
  // ===========================================================================