@bongos/core 1.19.864 → 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.
- package/.bongos-core.json +25 -20
- package/docs/copy-inventory.md +40 -18
- package/docs/copy-registry.json +260 -18
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/blockers.html +4 -1
- package/modules/hall-ui/public/blockers.js +283 -18
- package/modules/hall-ui/public/goal-inbox.js +6 -0
- package/modules/hall-ui/public/task-detail.js +92 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +92 -0
- package/tests/blockers_page_ui.mjs +325 -0
- package/tests/helpers.mjs +13 -0
- package/tests/task_detail_ui.mjs +79 -1
|
@@ -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();
|
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)) {
|
package/tests/task_detail_ui.mjs
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:
|
|
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
|
// ===========================================================================
|