@bongos/core 1.19.863 → 1.19.865

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.
@@ -0,0 +1,325 @@
1
+ // tests/blockers_page_ui.mjs — the /blockers queue page, EXECUTED (task
2
+ // 1003456, following tests/task_detail_ui.mjs's precedent).
3
+ //
4
+ // A ship-time grade on this task found two real bugs in blockers.js that a
5
+ // source-regex test (tests/blockers_hall.mjs) could not have caught, because
6
+ // both are about ORDER and STATE rather than presence:
7
+ // - a boot race: GET /me and GET /blockers fire together, and the row
8
+ // renderer reads the reviewer flag at RENDER time — if the queue rendered
9
+ // before the rank arrived, a Metic/Archon saw no Resolve/Link buttons
10
+ // until an unrelated re-render happened.
11
+ // - a CSS-family misuse (.ov-row--lead on a row with no lead column) that a
12
+ // regex cannot see is wrong without knowing what the class means.
13
+ // task_detail_ui.mjs's own header says it best: "browser code fails when a
14
+ // human clicks, and only a harness that clicks can see that." This is that
15
+ // harness for the sibling surface.
16
+ //
17
+ // Real hall-kit.js runs INSIDE the vm (it touches document/window at call
18
+ // time, so it must share the sandbox's stubs) — blockers-lib.js is pure and is
19
+ // require()'d directly, the same as tests/blockers_lib.mjs already trusts it.
20
+ //
21
+ // Run: node tests/blockers_page_ui.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import vm from 'node:vm';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { createRequire } from 'node:module';
29
+ import { makeRunner, makeEl, walk } from './helpers.mjs';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
33
+ const HALL_KIT_SRC = fs.readFileSync(path.join(HALL, 'hall-kit.js'), 'utf8');
34
+ const BLOCKERS_JS_SRC = fs.readFileSync(path.join(HALL, 'blockers.js'), 'utf8');
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const OTBBlockersLib = require(path.join(HALL, 'blockers-lib.js'));
38
+
39
+ const { test, summary } = makeRunner();
40
+
41
+ const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
42
+ async function settle(extraMs = 0) {
43
+ if (extraMs) await tick(extraMs);
44
+ for (let i = 0; i < 30; i++) await tick(0);
45
+ }
46
+
47
+ const DEFAULT_ROWS = [
48
+ { id: 12, title: 'Need the owner’s AWS card on file', body_md: '', status: 'open', created_at: '2026-09-10T00:00:00.000Z', blocking_task_ids: [] },
49
+ ];
50
+
51
+ function ok(data) { return { status: 200, ok: true, data }; }
52
+ function fail(status, code, message) { return { status, ok: false, data: { error: { code, message } } }; }
53
+
54
+ async function boot(opts = {}) {
55
+ const {
56
+ rank = 'archon',
57
+ rows = DEFAULT_ROWS,
58
+ meDelayMs = 0,
59
+ meFails = false,
60
+ blockersDelayMs = 0,
61
+ fileResponse = null, // null → default success; or a fail(...) envelope
62
+ linkResponse = null,
63
+ resolveResponse = null,
64
+ } = opts;
65
+
66
+ const requests = [];
67
+ const toasts = [];
68
+ const navigations = [];
69
+ const winListeners = {};
70
+
71
+ const api = {
72
+ async request(method, p, init) {
73
+ const body = (init && init.body) || {};
74
+ const url = p.replace('/api/bongos', '');
75
+ if (method === 'GET' && url === '/me') {
76
+ if (meDelayMs) await tick(meDelayMs);
77
+ requests.push({ method, path: p, body });
78
+ if (meFails) return fail(401, 'auth_needed', 'sign in');
79
+ return ok({ builder: { id: 1, rank } });
80
+ }
81
+ requests.push({ method, path: p, body });
82
+ if (method === 'POST' && url === '/blockers') {
83
+ return fileResponse || ok({ blocker: { id: 900, title: body.title, body_md: body.body_md, status: 'open', created_at: '2026-09-17T00:00:00.000Z', blocking_task_ids: [] } });
84
+ }
85
+ if (method === 'POST' && /^\/blockers\/\d+\/link$/.test(url)) {
86
+ return linkResponse || ok({ ok: true, blocked: true });
87
+ }
88
+ if (method === 'POST' && /^\/blockers\/\d+\/resolve$/.test(url)) {
89
+ return resolveResponse || ok({ blocker: { id: Number(url.split('/')[2]), status: 'resolved' } });
90
+ }
91
+ return ok({});
92
+ },
93
+ };
94
+
95
+ const locationObj = { hostname: 'builders.example.test', pathname: '/blockers', assign(u) { navigations.push(['assign', u]); } };
96
+ // ONE root holding the page's known containers as PERSISTENT children —
97
+ // getElementById WALKS it (the task-detail.js harness's own pattern), so an
98
+ // id introduced later by `container.innerHTML = '...'` (the file form, a
99
+ // row's inline resolve/link form) is found the same way a real querySelector
100
+ // would find it, rather than handing back a phantom detached element.
101
+ const root = makeEl('div');
102
+ function registerRoot(id) { const el = makeEl('div', { id }); root.appendChild(el); return el; }
103
+ registerRoot('blockers-body');
104
+ registerRoot('blockers-count');
105
+ registerRoot('blockers-sub');
106
+ registerRoot('blockers-file-area');
107
+ const documentObj = {
108
+ getElementById(id) { return walk(root, (el) => (el.id === id ? el : null)); },
109
+ };
110
+ const windowObj = {
111
+ OTB: { toast: (m) => toasts.push(m) },
112
+ OTBBlockersLib,
113
+ OTBTaskDetail: { href: (id) => '/task/' + id },
114
+ BongosClient: { createClient: () => api },
115
+ location: locationObj,
116
+ localStorage: { getItem: () => null, setItem() {} },
117
+ addEventListener(type, fn) { (winListeners[type] = winListeners[type] || []).push(fn); },
118
+ dispatchEvent(ev) { (winListeners[ev.type] || []).forEach((fn) => fn(ev)); return true; },
119
+ };
120
+
121
+ const sandbox = {
122
+ window: windowObj, document: documentObj, location: locationObj,
123
+ console, setTimeout, clearTimeout, Promise, Date, Math, JSON, Error,
124
+ Number, String, Object, Array, Boolean, Set, Map, RegExp, encodeURIComponent,
125
+ };
126
+ sandbox.globalThis = sandbox;
127
+ // fetch() is blockers.js's OWN call for GET /blockers — separate from the
128
+ // BongosClient api used for writes and /me, exactly as the real file does.
129
+ sandbox.fetch = async (url) => {
130
+ if (blockersDelayMs) await tick(blockersDelayMs);
131
+ requests.push({ method: 'GET', path: url });
132
+ return { ok: true, json: async () => ({ blockers: rows, page: { limit: 200, offset: 0, total: rows.length } }) };
133
+ };
134
+ vm.createContext(sandbox);
135
+ vm.runInContext(HALL_KIT_SRC, sandbox, { filename: 'hall-kit.js' });
136
+ vm.runInContext(BLOCKERS_JS_SRC, sandbox, { filename: 'blockers.js' });
137
+ await settle();
138
+
139
+ const body = () => documentObj.getElementById('blockers-body');
140
+ const fileArea = () => documentObj.getElementById('blockers-file-area');
141
+ const rowsOf = () => body().querySelectorAll('.ov-row');
142
+ const requestsTo = (method, re) => requests.filter((r) => r.method === method && re.test(r.path));
143
+
144
+ // The FILE form's open/cancel buttons and its own submit carry a DIRECT
145
+ // listener (wireFileForm addEventListener's each element itself). The
146
+ // per-row Resolve/Link open/cancel/submit are DELEGATED on #blockers-body
147
+ // (module-scope body.addEventListener) — makeEl's fire() does not bubble,
148
+ // so a delegated handler must be fired ON THE DELEGATE with the real
149
+ // target, exactly as a browser's own event dispatch would deliver it.
150
+ async function submitDirect(form) {
151
+ let prevented = false;
152
+ form.fire('submit', { preventDefault() { prevented = true; }, target: form });
153
+ await settle();
154
+ assert.equal(prevented, true, 'the submit handler did not execute');
155
+ }
156
+ async function submitDelegated(form) {
157
+ let prevented = false;
158
+ body().fire('submit', { preventDefault() { prevented = true; }, target: form });
159
+ await settle();
160
+ assert.equal(prevented, true, 'the delegated submit handler did not execute');
161
+ }
162
+ function clickDelegated(el) { body().fire('click', { target: el }); }
163
+
164
+ return {
165
+ requests, requestsTo, toasts, navigations, settle,
166
+ body, fileArea, rowsOf,
167
+ fileOpenBtn: () => fileArea().querySelector('#blockers-file-open'),
168
+ fileForm: () => fileArea().querySelector('#blockers-file-form'),
169
+ submit: submitDirect,
170
+ submitRow: submitDelegated,
171
+ click: (el) => { el.fire('click', { target: el }); },
172
+ clickRow: clickDelegated,
173
+ dispatchWindowEvent: (type) => windowObj.dispatchEvent({ type }),
174
+ };
175
+ }
176
+
177
+ // ===========================================================================
178
+ // The boot race (the FIRST bug the grade found)
179
+ // ===========================================================================
180
+
181
+ await test('BUG REGRESSION: GET /blockers resolving BEFORE GET /me still ends with Resolve/Link offered to a reviewer', async () => {
182
+ const h = await boot({ rank: 'archon', blockersDelayMs: 0, meDelayMs: 15 });
183
+ const row = h.rowsOf()[0];
184
+ assert.ok(row, 'the row rendered');
185
+ assert.ok(row.querySelector('[data-resolve-open]'), 'once the viewer resolves, the row must re-render with the reviewer controls — this is exactly the boot race the grade found');
186
+ });
187
+
188
+ await test('the reverse order (viewer known first) already worked, and still does', async () => {
189
+ const h = await boot({ rank: 'archon', blockersDelayMs: 15, meDelayMs: 0 });
190
+ const row = h.rowsOf()[0];
191
+ assert.ok(row.querySelector('[data-resolve-open]'));
192
+ });
193
+
194
+ await test('a non-reviewer sees the row with no Resolve/Link controls at all', async () => {
195
+ const h = await boot({ rank: 'xenos' });
196
+ const row = h.rowsOf()[0];
197
+ assert.equal(row.querySelector('[data-resolve-open]'), null);
198
+ assert.equal(row.querySelector('[data-link-open]'), null);
199
+ });
200
+
201
+ await test('a signed-out visitor (GET /me fails) sees the queue with no reviewer controls, not an error', async () => {
202
+ const h = await boot({ meFails: true });
203
+ const row = h.rowsOf()[0];
204
+ assert.equal(row.querySelector('[data-resolve-open]'), null);
205
+ assert.ok(h.fileOpenBtn(), 'the File button still renders — filing needs only a sign-in the write itself will demand');
206
+ });
207
+
208
+ // ===========================================================================
209
+ // Filing — any signed-in builder, and the file+link one-action flow
210
+ // ===========================================================================
211
+
212
+ await test('filing alone (no task #) posts once and toasts the filed id', async () => {
213
+ const h = await boot({ rank: 'xenos' });
214
+ h.click(h.fileOpenBtn());
215
+ const form = h.fileForm();
216
+ form.querySelector('#bf-title').value = 'Need a decision';
217
+ await h.submit(form);
218
+ const posts = h.requestsTo('POST', /\/blockers$/);
219
+ assert.equal(posts.length, 1);
220
+ assert.equal(posts[0].body.title, 'Need a decision');
221
+ assert.ok(h.toasts.some((t) => /Blocker #900 filed\.$/.test(t)));
222
+ assert.equal(h.requestsTo('POST', /\/link$/).length, 0, 'no task # given — no link attempted');
223
+ });
224
+
225
+ await test('filing WITH a task # does both in the one submit, and toasts success', async () => {
226
+ const h = await boot({ rank: 'xenos' });
227
+ h.click(h.fileOpenBtn());
228
+ const form = h.fileForm();
229
+ form.querySelector('#bf-title').value = 'Need a decision';
230
+ form.querySelector('#bf-task-id').value = '5001';
231
+ await h.submit(form);
232
+ const linkPosts = h.requestsTo('POST', /\/blockers\/900\/link$/);
233
+ assert.equal(linkPosts.length, 1);
234
+ assert.equal(linkPosts[0].body.task_id, 5001);
235
+ assert.ok(h.toasts.some((t) => /filed and linked to task #5001\./.test(t)));
236
+ });
237
+
238
+ await test('a non-reviewer filer who names a task the link 403s on still gets an HONEST message — filing is not a silent no-op', async () => {
239
+ const h = await boot({ rank: 'xenos', linkResponse: fail(403, 'permission_forbidden', 'nope') });
240
+ h.click(h.fileOpenBtn());
241
+ const form = h.fileForm();
242
+ form.querySelector('#bf-title').value = 'Need a decision';
243
+ form.querySelector('#bf-task-id').value = '5001';
244
+ await h.submit(form);
245
+ assert.ok(h.toasts.some((t) => t === 'Blocker #900 filed. You need Metic+ access to do that.'),
246
+ `expected the honest fallback, got: ${JSON.stringify(h.toasts)}`);
247
+ });
248
+
249
+ await test('a deduped (skipped) file with a task # typed says the link was NOT attempted', async () => {
250
+ const h = await boot({ fileResponse: ok({ skipped: true, reason: 'duplicate_source_ref' }) });
251
+ h.click(h.fileOpenBtn());
252
+ const form = h.fileForm();
253
+ form.querySelector('#bf-title').value = 'Need a decision';
254
+ form.querySelector('#bf-task-id').value = '5001';
255
+ await h.submit(form);
256
+ assert.equal(h.requestsTo('POST', /\/link$/).length, 0, 'no blocker id came back — nothing to link');
257
+ assert.ok(h.toasts.some((t) => /already filed\. It was NOT linked to task #5001/.test(t)),
258
+ `expected the honest skip+link message, got: ${JSON.stringify(h.toasts)}`);
259
+ });
260
+
261
+ await test('an empty title is refused in the browser before a doomed request', async () => {
262
+ const h = await boot();
263
+ h.click(h.fileOpenBtn());
264
+ const form = h.fileForm();
265
+ await h.submit(form);
266
+ assert.equal(h.requestsTo('POST', /\/blockers$/).length, 0);
267
+ assert.equal(form.querySelector('#bf-err').textContent, 'A title is required.');
268
+ });
269
+
270
+ // ===========================================================================
271
+ // Resolve / Link — reviewer-only, cosmetic mirror of blocker.review
272
+ // ===========================================================================
273
+
274
+ await test('a reviewer resolving a row posts to the real route, toasts, and reloads the queue', async () => {
275
+ const h = await boot({ rank: 'metic' });
276
+ h.clickRow(h.rowsOf()[0].querySelector('[data-resolve-open]'));
277
+ await h.settle();
278
+ // The click's own rerender() replaced the row's markup — re-query from the
279
+ // (stable) delegate rather than the now-detached `row` reference.
280
+ const form = h.body().querySelector('[data-resolve-form]');
281
+ assert.ok(form, 'the resolve form opened');
282
+ form.note.value = 'raised the budget';
283
+ await h.submitRow(form);
284
+ const posts = h.requestsTo('POST', /\/blockers\/12\/resolve$/);
285
+ assert.equal(posts.length, 1);
286
+ assert.equal(posts[0].body.resolution_note, 'raised the budget');
287
+ assert.ok(h.toasts.some((t) => t === 'Blocker #12 resolved.'));
288
+ // reload() re-fetches — a second GET /blockers beyond the initial boot fetch.
289
+ assert.ok(h.requestsTo('GET', /\/blockers\?limit=200$/).length >= 2, 'the queue reloads after a mutation');
290
+ });
291
+
292
+ await test('a reviewer linking a row posts task_id as a NUMBER', async () => {
293
+ const h = await boot({ rank: 'metic' });
294
+ h.clickRow(h.rowsOf()[0].querySelector('[data-link-open]'));
295
+ await h.settle();
296
+ const form = h.body().querySelector('[data-link-form]');
297
+ form.taskId.value = '777';
298
+ await h.submitRow(form);
299
+ const posts = h.requestsTo('POST', /\/blockers\/12\/link$/);
300
+ assert.equal(posts.length, 1);
301
+ assert.strictEqual(posts[0].body.task_id, 777, 'a string field value must be coerced to Number, not sent as text');
302
+ });
303
+
304
+ await test('a 403 on resolve renders HONEST copy and leaves the form open — never a silent no-op', async () => {
305
+ const h = await boot({ rank: 'metic', resolveResponse: fail(403, 'permission_forbidden', 'nope') });
306
+ h.clickRow(h.rowsOf()[0].querySelector('[data-resolve-open]'));
307
+ await h.settle();
308
+ const form = h.body().querySelector('[data-resolve-form]');
309
+ await h.submitRow(form);
310
+ assert.equal(form.querySelector('[data-err]').textContent, 'You need Metic+ access to do that.');
311
+ });
312
+
313
+ await test('resolving through the shared #/blocker/N overlay (otb:blocker-resolved) reloads this page\'s queue too', async () => {
314
+ const h = await boot({ rank: 'metic' });
315
+ const before = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
316
+ // Simulate task-detail.js's blockerPanel firing the event on THIS window —
317
+ // the SAME event the overlay dispatches when a builder resolves a blocker
318
+ // from the deep-link card opened on top of this page.
319
+ h.dispatchWindowEvent('otb:blocker-resolved');
320
+ await h.settle();
321
+ const after = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
322
+ assert.equal(after, before + 1, 'the queue re-fetched exactly once in response to the event');
323
+ });
324
+
325
+ summary();
@@ -0,0 +1,111 @@
1
+ // tests/docs_scrubber_damage.mjs — the secret-scrubber ate real prose in
2
+ // docs/recipes/managed-settings-remote-control.md (task 1004027, part 4 of the
3
+ // damage family after 1004021 / 1004022).
4
+ //
5
+ // Two distinct repairs, and the SECOND is the trap. A scrubber pass replaced
6
+ // two pieces of ordinary content with the literal `<redacted>`:
7
+ // - an in-page anchor target, so the link went nowhere;
8
+ // - an `infra/*.sh` script name, so the doc pointed at no script at all.
9
+ // But four OTHER `<redacted>` occurrences in the same file are DELIBERATE — they
10
+ // stand in for real secrets (a tunnel credential, two tokens, a trust seed).
11
+ // A global sweep "fixes" the two and destroys the four, and nothing in the repo
12
+ // would notice, because the anchor lint only ever looks at link targets.
13
+ //
14
+ // So this file pins BOTH halves: the anchor and the script path resolve, AND the
15
+ // placeholder count stays at exactly four. The second assertion is the one that
16
+ // makes a future sweep fail loudly instead of quietly.
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { parseFile } = require(path.join(ROOT, 'scripts', 'gds', 'docs-entropy.js'));
27
+
28
+ const REL = 'docs/recipes/managed-settings-remote-control.md';
29
+ const ABS = path.join(ROOT, REL);
30
+ const raw = () => fs.readFileSync(ABS, 'utf8');
31
+
32
+ // The doc is only worth asserting against while it exists at this path.
33
+ test('the recipe under repair is still tracked at its documented path', () => {
34
+ assert.ok(fs.existsSync(ABS), `${REL} is missing — move this test with it`);
35
+ });
36
+
37
+ test('every in-page anchor in the recipe resolves to a heading it actually has', () => {
38
+ // The damaged link read `[named-tunnel automation](#<redacted>)`. Resolve
39
+ // through the SAME parser docs-entropy.js uses, so the slug this asserts is
40
+ // the slug the lint computes — a test with its own slug rule would let the
41
+ // doc pass here and still red the ratchet.
42
+ const { headings, links } = parseFile(ABS);
43
+ const slugs = new Set(headings.map((h) => h.slug));
44
+ const inPage = links.filter((l) => l.href.startsWith('#'));
45
+
46
+ assert.ok(inPage.length > 0, 'fixture sanity: the recipe does link to its own sections');
47
+ const broken = inPage.filter((l) => !slugs.has(l.href.slice(1)));
48
+ assert.deepEqual(
49
+ broken.map((l) => `${l.line}: ${l.href}`), [],
50
+ 'an in-page anchor points at no heading in this file — this is what doc_broken_anchors counts',
51
+ );
52
+ });
53
+
54
+ test('the key-sync cron names a script the cloud-init renderer actually installs', () => {
55
+ // The damaged line read `infra/<redacted>.sh`. The authority for the real name
56
+ // is not prose anywhere: it is the renderer that bakes the script into the
57
+ // cloud-init, so assert the doc against THAT rather than against a literal.
58
+ const line = raw().split(/\r?\n/).find((l) => l.includes('GET /box/authorized-keys'))
59
+ ?? raw().split(/\r?\n/).find((l) => l.includes('keeps `authorized_keys` in sync'));
60
+ assert.ok(line, 'fixture sanity: the recipe still describes the authorized_keys sync cron');
61
+
62
+ const named = [...raw().matchAll(/`infra\/([a-z0-9-]+\.sh)`/g)].map((m) => m[1]);
63
+ assert.ok(
64
+ named.includes('box-authorized-keys-fetch.sh'),
65
+ `the recipe names no authorized-keys script; found ${JSON.stringify(named)}`,
66
+ );
67
+
68
+ // Scoped to the repaired name on purpose. Other `infra/*.sh` the recipe cites
69
+ // (box-connect.sh) are builder-side scripts absent from this checkout for an
70
+ // unrelated, pre-existing reason (idea 1000895) — asserting over all of them
71
+ // would red this test on a gap it is not about.
72
+ const renderer = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'box-infra.js'), 'utf8');
73
+ assert.ok(
74
+ renderer.includes('box-authorized-keys-fetch.sh'),
75
+ 'box-infra.js no longer bakes box-authorized-keys-fetch.sh into the cloud-init — '
76
+ + 'the recipe now names a script nothing installs, so fix the doc to match the renderer',
77
+ );
78
+ });
79
+
80
+ test('the four deliberate <redacted> secret placeholders survive — a global sweep must fail here', () => {
81
+ // This is the anti-fix assertion. Each of these stands in for a real secret and
82
+ // is correct as written; "repairing" them is the damage, not the cure.
83
+ const lines = raw().split(/\r?\n/);
84
+ const placeholders = lines
85
+ .map((text, i) => ({ line: i + 1, text }))
86
+ .filter((l) => l.text.includes('<redacted>'));
87
+
88
+ assert.equal(
89
+ placeholders.length, 4,
90
+ 'expected exactly the four secret placeholders, got:\n'
91
+ + placeholders.map((p) => ` ${p.line}: ${p.text.trim().slice(0, 110)}`).join('\n'),
92
+ );
93
+
94
+ // Named by CONTEXT, not by line number, so ordinary edits above them don't red this.
95
+ const contexts = ['"credential"', 'BOX_TUNNEL_TOKEN', 'BOX_SOURCE_FULL_TOKEN', '/root/.claude.json'];
96
+ for (const ctx of contexts) {
97
+ assert.ok(
98
+ placeholders.some((p) => p.text.includes(ctx)),
99
+ `the deliberate placeholder for ${ctx} is gone — a <redacted> sweep took a real secret stand-in with it`,
100
+ );
101
+ }
102
+
103
+ // And no placeholder may sit in a link target or a path again: those two shapes
104
+ // are exactly what the scrubber damaged.
105
+ for (const p of placeholders) {
106
+ assert.ok(
107
+ !/\(#<redacted>\)/.test(p.text) && !/infra\/<redacted>/.test(p.text),
108
+ `line ${p.line} carries scrubber damage, not a secret placeholder: ${p.text.trim().slice(0, 110)}`,
109
+ );
110
+ }
111
+ });
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)) {
@@ -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
  // ===========================================================================