@bongos/core 1.19.503 → 1.19.505

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,710 @@
1
+ // tests/task_detail_ui.mjs — the hall's idea overlay, EXECUTED (task 1003516).
2
+ //
3
+ // modules/hall-ui/public/task-detail.js carries the whole in-hall idea-triage
4
+ // surface: the Promote / Merge / Discard disclosure forms (task 1002447), the
5
+ // version picker, and the goal picker with its hint-beats-suggester precedence
6
+ // and out-of-order-fetch race guard (task 1003071). Until this file its only
7
+ // test asserted on the SOURCE TEXT, so a ReferenceError inside a submit handler
8
+ // passed the suite and 500'd at click time — the class of gap behind the task
9
+ // 1003102 blocker. Browser code fails when a human clicks, and only a harness
10
+ // that clicks can see that.
11
+ //
12
+ // Why a vm harness rather than jsdom: there is no jsdom in this repo. The
13
+ // precedent is tests/goal_criterion_confirm_ui.mjs (after tests/hall_signout.mjs),
14
+ // which runs the real page script inside `vm` under a hand-built DOM stub. This
15
+ // stub goes one step further than those: it PARSES the markup the file writes
16
+ // into innerHTML, so `querySelector('[data-goal-select]')` resolves only if the
17
+ // element is genuinely there, form fields carry the real values the file
18
+ // rendered (escaping included), and every handler runs for real.
19
+ //
20
+ // Covers:
21
+ // • the overlay boots from the hash and renders the Metic triage panel
22
+ // • promote with a goal chosen — POST /tasks carries goal_id, the idea is then
23
+ // PATCHed promoted, and the panel links the created task
24
+ // • promote opting out — NO goal_id key at all (the server's catch-all bucket
25
+ // applies only when the key is absent)
26
+ // • the version change re-fetches that version's goals and repaints the picker;
27
+ // a raced pair of changes lets only the newest write
28
+ // • the two-write failure path — the task is created, the idea PATCH fails, and
29
+ // the created task id survives in the message the builder reads
30
+ // • merge and discard submit their PATCH, announce, and close the overlay
31
+ // • a 403 is read out of the NESTED error envelope rather than swallowed
32
+ //
33
+ // Run: node tests/task_detail_ui.mjs
34
+
35
+ import { strict as assert } from 'node:assert';
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import vm from 'node:vm';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { makeRunner } from './helpers.mjs';
41
+
42
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-detail.js'), 'utf8');
44
+
45
+ const { test, summary } = makeRunner();
46
+
47
+ // ===========================================================================
48
+ // A minimal DOM that actually parses what the file writes.
49
+ // ===========================================================================
50
+
51
+ const VOID_TAGS = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']);
52
+
53
+ function decodeEntities(s) {
54
+ return String(s)
55
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
56
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
57
+ .replace(/&amp;/g, '&');
58
+ }
59
+
60
+ function parseAttrs(text) {
61
+ const out = {};
62
+ const re = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
63
+ let m;
64
+ while ((m = re.exec(text))) {
65
+ const raw = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : '';
66
+ out[m[1].toLowerCase()] = decodeEntities(raw);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Compound selectors only (tag / .class / #id / [attr] / [attr="v"], comma lists)
72
+ // — that is every selector task-detail.js uses, and refusing anything richer
73
+ // keeps the stub honest about what it can prove.
74
+ function matchesOne(el, sel) {
75
+ let ok = true;
76
+ const re = /([a-zA-Z][\w-]*)|\.([\w-]+)|#([\w-]+)|\[([\w:-]+)(?:=["']([^"']*)["'])?\]/g;
77
+ let m;
78
+ while ((m = re.exec(sel))) {
79
+ if (m[1]) ok = ok && el.tagName === m[1].toLowerCase();
80
+ else if (m[2]) ok = ok && String(el.className || '').split(/\s+/).includes(m[2]);
81
+ else if (m[3]) ok = ok && el.id === m[3];
82
+ else if (m[4]) {
83
+ const have = el.attrs[m[4].toLowerCase()];
84
+ ok = ok && (m[5] === undefined ? have !== undefined : have === m[5]);
85
+ }
86
+ }
87
+ return ok;
88
+ }
89
+ const matches = (el, sel) => String(sel).split(',').map((s) => s.trim()).filter(Boolean).some((s) => matchesOne(el, s));
90
+
91
+ function walk(el, fn) {
92
+ for (const child of el.children) {
93
+ const hit = fn(child);
94
+ if (hit) return hit;
95
+ const deeper = walk(child, fn);
96
+ if (deeper) return deeper;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function makeEl(tagName, attrs = {}) {
102
+ const el = {
103
+ tagName: String(tagName).toLowerCase(),
104
+ attrs,
105
+ children: [],
106
+ parentNode: null,
107
+ className: attrs.class || '',
108
+ id: attrs.id || '',
109
+ name: attrs.name,
110
+ dataset: {},
111
+ disabled: 'disabled' in attrs,
112
+ style: {},
113
+ listeners: {},
114
+ _text: '',
115
+ _html: null,
116
+ _value: null,
117
+
118
+ get value() {
119
+ if (this._value !== null) return this._value;
120
+ if (this.tagName === 'select') {
121
+ const opts = this.querySelectorAll('option');
122
+ const chosen = opts.find((o) => 'selected' in o.attrs) || opts[0];
123
+ return chosen ? (chosen.attrs.value !== undefined ? chosen.attrs.value : chosen.textContent) : '';
124
+ }
125
+ return this.attrs.value !== undefined ? this.attrs.value : '';
126
+ },
127
+ set value(v) { this._value = String(v); },
128
+
129
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
130
+ set textContent(v) { this._text = String(v == null ? '' : v); this.children = []; this._html = null; },
131
+
132
+ get innerHTML() { return this._html === null ? '' : this._html; },
133
+ set innerHTML(v) { this._html = String(v); this._value = null; parseInto(this, this._html); },
134
+
135
+ setAttribute(k, v) { this.attrs[String(k).toLowerCase()] = String(v); },
136
+ getAttribute(k) { const v = this.attrs[String(k).toLowerCase()]; return v === undefined ? null : v; },
137
+ removeAttribute(k) { delete this.attrs[String(k).toLowerCase()]; },
138
+ appendChild(child) { child.parentNode = this; this.children.push(child); return child; },
139
+ remove() {
140
+ const p = this.parentNode;
141
+ if (!p) return;
142
+ p.children = p.children.filter((c) => c !== this);
143
+ this.parentNode = null;
144
+ },
145
+ querySelector(sel) { return walk(this, (c) => (matches(c, sel) ? c : null)); },
146
+ querySelectorAll(sel) {
147
+ const out = [];
148
+ walk(this, (c) => { if (matches(c, sel)) out.push(c); return null; });
149
+ return out;
150
+ },
151
+ closest(sel) {
152
+ let node = this;
153
+ while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
154
+ return null;
155
+ },
156
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
157
+ focus() {},
158
+ fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
159
+ };
160
+ for (const [k, v] of Object.entries(attrs)) {
161
+ if (!k.startsWith('data-')) continue;
162
+ el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
163
+ }
164
+ return el;
165
+ }
166
+
167
+ // The reserved names a <form>'s named-field shorthand (form.title.value) must
168
+ // never shadow — everything else on the element is fair game, same as the DOM.
169
+ const FORM_FIELD_RESERVED = new Set(['tagName', 'attrs', 'children', 'parentNode', 'className', 'id', 'dataset',
170
+ 'disabled', 'style', 'listeners', 'value', 'textContent', 'innerHTML', 'name']);
171
+
172
+ function parseInto(host, html) {
173
+ host.children = [];
174
+ host._text = '';
175
+ const stack = [host];
176
+ const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
177
+ let last = 0;
178
+ let m;
179
+ const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
180
+ while ((m = re.exec(html))) {
181
+ addText(html.slice(last, m.index));
182
+ last = re.lastIndex;
183
+ const tag = m[2].toLowerCase();
184
+ if (m[1]) {
185
+ for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; }
186
+ continue;
187
+ }
188
+ const el = makeEl(tag, parseAttrs(m[3]));
189
+ stack[stack.length - 1].appendChild(el);
190
+ if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
191
+ }
192
+ addText(html.slice(last));
193
+ // Named-field access on every form the markup just produced.
194
+ walk(host, (el) => {
195
+ if (el.tagName !== 'form') return null;
196
+ for (const field of el.querySelectorAll('input, select, textarea')) {
197
+ if (!field.name || FORM_FIELD_RESERVED.has(field.name) || field.name in el) continue;
198
+ el[field.name] = field;
199
+ }
200
+ return null;
201
+ });
202
+ }
203
+
204
+ // ===========================================================================
205
+ // The harness: run the real file against a scripted API and hand back handles.
206
+ // ===========================================================================
207
+
208
+ const IDEA_ID = 1000817;
209
+ const DEFAULT_IDEA = {
210
+ id: IDEA_ID,
211
+ status: 'open',
212
+ title: 'Cover the overlay\'s "promote" & <merge> handlers',
213
+ body_md: 'The overlay has no executing test.',
214
+ kind: 'blocker-resolution',
215
+ suggested_version: 'BV2',
216
+ suggested_priority: 2,
217
+ suggested_goal_id: 1000071,
218
+ };
219
+ const DEFAULT_VERSIONS = [
220
+ { id: 'BV1', name: 'Platform one', status: 'planning' },
221
+ { id: 'BV2', name: 'Platform two', status: 'active' },
222
+ { id: 'OLD', name: 'Already out', status: 'shipped' },
223
+ ];
224
+ const DEFAULT_SUGGESTIONS = {
225
+ BV1: [{ id: 1000058, title: 'Consumer path', score: 4, matched_terms: ['overlay'] }],
226
+ BV2: [{ id: 1000062, title: 'Idea surfaces', score: 6, matched_terms: ['promote', 'merge'] }],
227
+ };
228
+
229
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
230
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
231
+
232
+ const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
233
+ async function settle(extraMs = 0) {
234
+ if (extraMs) await tick(extraMs);
235
+ for (let i = 0; i < 30; i++) await tick(0);
236
+ }
237
+
238
+ function fail(status, code, message) {
239
+ return { status, ok: false, data: { error: { code, message } } };
240
+ }
241
+
242
+ async function boot(opts = {}) {
243
+ const {
244
+ idea = DEFAULT_IDEA,
245
+ rank = 'metic',
246
+ versions = DEFAULT_VERSIONS,
247
+ suggestions = DEFAULT_SUGGESTIONS,
248
+ suggestDelayMs = {},
249
+ suggestThrows = false,
250
+ createdTask = { id: 1003600, title: 'Cover the overlay handlers' },
251
+ createFails = null,
252
+ patchFails = null,
253
+ mergeTargets = { 1000900: { id: 1000900, title: 'The canonical idea' } },
254
+ } = opts;
255
+
256
+ const requests = [];
257
+ const toasts = [];
258
+ const events = [];
259
+ const navigations = [];
260
+
261
+ const ok = (data) => ({ status: 200, ok: true, data });
262
+ const api = {
263
+ async request(method, p, init) {
264
+ const body = init && init.body;
265
+ requests.push({ method, path: p, body });
266
+ const url = p.replace('/api/bongos', '');
267
+ const [route, query] = url.split('?');
268
+ if (method === 'GET' && route === '/me') return ok({ builder: { id: 1, rank } });
269
+ if (method === 'GET' && route === '/versions') return ok({ versions });
270
+ if (method === 'GET' && route === '/goals/suggestions') {
271
+ const version = new URLSearchParams(query || '').get('version');
272
+ const delay = suggestDelayMs[version] || 0;
273
+ if (delay) await tick(delay);
274
+ if (suggestThrows) return { status: 503, ok: false, data: null };
275
+ return ok({ suggestions: suggestions[version] || [] });
276
+ }
277
+ if (method === 'GET' && route.startsWith('/inbox/')) {
278
+ const id = Number(route.slice('/inbox/'.length));
279
+ if (id === Number(idea.id)) return ok({ idea });
280
+ if (mergeTargets[id]) return ok({ idea: mergeTargets[id] });
281
+ return { status: 404, ok: false, data: null };
282
+ }
283
+ if (method === 'POST' && route === '/tasks') return createFails || ok({ task: { ...createdTask, title: body.title } });
284
+ if (method === 'PATCH' && route.startsWith('/inbox/')) return patchFails || ok({ idea: { ...idea, status: body.status } });
285
+ return ok({});
286
+ },
287
+ };
288
+
289
+ const locationObj = {
290
+ hostname: 'builders.example.test',
291
+ pathname: '/builders',
292
+ search: '',
293
+ hash: `#/idea/${idea.id}`,
294
+ href: '',
295
+ assign(u) { navigations.push(['assign', u]); },
296
+ replace(u) { navigations.push(['replace', u]); },
297
+ };
298
+ const documentObj = {
299
+ readyState: 'complete',
300
+ body: makeEl('body'),
301
+ documentElement: makeEl('html'),
302
+ createElement: (tag) => makeEl(tag),
303
+ getElementById(id) { return walk(this.body, (el) => (el.id === id ? el : null)); },
304
+ querySelector(sel) { return this.body.querySelector(sel); },
305
+ querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
306
+ createTextNode(text) { const n = makeEl('#text'); n._text = String(text); return n; },
307
+ addEventListener() {},
308
+ };
309
+ const windowObj = {
310
+ OTB: { escapeHtml, toast: (m) => toasts.push(m) },
311
+ BongosClient: { createClient: () => api },
312
+ location: locationObj,
313
+ addEventListener() {},
314
+ dispatchEvent(ev) { events.push(ev); return true; },
315
+ };
316
+ class CustomEvent {
317
+ constructor(type, init) { this.type = type; this.detail = init && init.detail; }
318
+ }
319
+
320
+ const sandbox = {
321
+ window: windowObj, document: documentObj, location: locationObj, CustomEvent,
322
+ history: { replaceState() {} },
323
+ sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
324
+ console, setTimeout, clearTimeout, Promise, Date, Math, JSON, Error,
325
+ Number, String, Object, Array, Boolean, Set, Map, RegExp, encodeURIComponent,
326
+ };
327
+ sandbox.globalThis = sandbox;
328
+ vm.createContext(sandbox);
329
+ vm.runInContext(SRC, sandbox, { filename: 'task-detail.js' });
330
+ await settle();
331
+
332
+ const overlay = () => documentObj.getElementById('deeplink-card');
333
+ const panel = () => { const o = overlay(); return o ? o.querySelector('.td-triage') : null; };
334
+ const errText = () => { const p = panel(); const e = p && p.querySelector('[data-err]'); return e ? e.textContent : null; };
335
+ const requestsTo = (method, re) => requests.filter((r) => r.method === method && re.test(r.path));
336
+
337
+ // A real delegated click: the panel's own listener, handed a target whose
338
+ // closest() walks the parsed tree — exactly what the browser passes it.
339
+ async function clickGo(mode) {
340
+ const p = panel();
341
+ assert.ok(p, 'the triage panel must be rendered before a click');
342
+ const btn = p.querySelector(`[data-go="${mode}"]`);
343
+ assert.ok(btn, `no [data-go="${mode}"] control in the panel — got: ${p.innerHTML}`);
344
+ p.fire('click', { target: btn });
345
+ await settle();
346
+ return btn;
347
+ }
348
+
349
+ async function submitForm(which) {
350
+ const form = panel().querySelector(`[data-form="${which}"]`);
351
+ assert.ok(form, `no ${which} form rendered`);
352
+ let prevented = false;
353
+ form.fire('submit', { preventDefault() { prevented = true; }, target: form });
354
+ await settle();
355
+ // The handler is wired with preventDefault() first: if this is false the
356
+ // submit listener never ran and every assertion below would be vacuous.
357
+ assert.equal(prevented, true, `the ${which} submit handler did not execute`);
358
+ return form;
359
+ }
360
+
361
+ async function changeVersion(versionId, { wait = 0 } = {}) {
362
+ const sel = panel().querySelector('select[name="version"]');
363
+ assert.ok(sel, 'the promote form must offer a version picker');
364
+ sel.value = versionId;
365
+ sel.fire('change', {});
366
+ if (wait) await settle(wait);
367
+ return sel;
368
+ }
369
+
370
+ return {
371
+ requests, requestsTo, toasts, events, navigations,
372
+ overlay, panel, errText, clickGo, submitForm, changeVersion, settle,
373
+ goalSelect: () => panel().querySelector('[data-goal-select]'),
374
+ field: (which, name) => panel().querySelector(`[data-form="${which}"]`)[name],
375
+ };
376
+ }
377
+
378
+ // ===========================================================================
379
+ // The overlay boots and offers the triage panel
380
+ // ===========================================================================
381
+
382
+ await test('the overlay boots off the hash and renders the panel for a Metic', async () => {
383
+ const h = await boot();
384
+ assert.ok(h.overlay(), 'a #deeplink-card overlay is mounted');
385
+ assert.ok(h.panel(), 'and an open idea carries the triage panel');
386
+ const verbs = h.panel().querySelectorAll('[data-go]').map((b) => b.dataset.go);
387
+ assert.deepEqual(verbs, ['promote', 'merge', 'discard'], 'all three verdicts are reachable');
388
+ });
389
+
390
+ await test('a Xenos gets the card but no triage panel (the server gate is mirrored, not trusted)', async () => {
391
+ const h = await boot({ rank: 'xenos' });
392
+ assert.ok(h.overlay(), 'the record is still readable');
393
+ assert.equal(h.panel(), null, 'the actions are not offered below Metic');
394
+ });
395
+
396
+ await test('a closed idea offers no triage panel', async () => {
397
+ const h = await boot({ idea: { ...DEFAULT_IDEA, status: 'promoted' } });
398
+ assert.equal(h.panel(), null, 'a settled idea has nothing left to decide');
399
+ });
400
+
401
+ // ===========================================================================
402
+ // Promote — the form the version and goal pickers live on
403
+ // ===========================================================================
404
+
405
+ await test('opening Promote loads open versions only, and preselects the idea\'s suggested one', async () => {
406
+ const h = await boot();
407
+ await h.clickGo('promote');
408
+ const vsel = h.panel().querySelector('select[name="version"]');
409
+ const ids = vsel.querySelectorAll('option').map((o) => o.attrs.value);
410
+ assert.deepEqual(ids, ['BV1', 'BV2'], 'a shipped version would 409 the create, so it is not offered');
411
+ assert.equal(vsel.value, 'BV2', 'the idea\'s suggested_version is honoured');
412
+ });
413
+
414
+ await test('the goal picker leads with the STORED hint, then the suggester, then the opt-out', async () => {
415
+ const h = await boot();
416
+ await h.clickGo('promote');
417
+ const opts = h.goalSelect().querySelectorAll('option');
418
+ assert.equal(opts[0].attrs.value, '1000071', 'the hint carried from the filing comes first');
419
+ assert.ok('selected' in opts[0].attrs, 'and it is what the form will submit');
420
+ assert.match(opts[0].textContent, /carried from the filing/);
421
+ assert.equal(opts[1].attrs.value, '1000062', 'the suggester fills in below it');
422
+ assert.match(opts[1].textContent, /matched: promote, merge/, 'with the evidence for judging it');
423
+ assert.equal(opts[opts.length - 1].attrs.value, '', 'the opt-out is always available');
424
+ });
425
+
426
+ await test('with no stored hint the top suggestion is preselected instead', async () => {
427
+ const h = await boot({ idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
428
+ await h.clickGo('promote');
429
+ assert.equal(h.goalSelect().value, '1000062', 'the suggester supplies the default when nothing was named');
430
+ });
431
+
432
+ await test('a suggester outage degrades to the opt-out rather than blocking the verdict', async () => {
433
+ const h = await boot({ suggestThrows: true, idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
434
+ await h.clickGo('promote');
435
+ const opts = h.goalSelect().querySelectorAll('option');
436
+ assert.equal(opts.length, 1, 'only the opt-out remains');
437
+ assert.equal(h.goalSelect().value, '', 'and it is selected, so the form is still submittable');
438
+ });
439
+
440
+ await test('promote with a goal chosen POSTs goal_id, then marks the idea promoted', async () => {
441
+ const h = await boot();
442
+ await h.clickGo('promote');
443
+ await h.submitForm('promote');
444
+
445
+ const posts = h.requestsTo('POST', /\/tasks$/);
446
+ assert.equal(posts.length, 1, `exactly one create — got ${JSON.stringify(h.requests.map((r) => r.method + ' ' + r.path))}`);
447
+ const body = posts[0].body;
448
+ assert.equal(body.goal_id, 1000071, 'the chosen goal reaches the server as a number');
449
+ assert.equal(body.version_id, 'BV2');
450
+ assert.equal(body.priority, 2, 'suggested_priority is carried, clamped into the 1-5 the column allows');
451
+ assert.equal(body.source_ref, `idea:${IDEA_ID}`, 'provenance makes a retried promote idempotent');
452
+ assert.equal(body.kind, 'blocker-resolution', 'the idea\'s kind saves the classifier a guess');
453
+ assert.match(body.description, /Promoted from idea/, 'the task says where it came from');
454
+ assert.ok(!('est_minutes' in body) && !('credits_reward' in body), 'blank number fields are omitted, not sent as 0');
455
+
456
+ const patches = h.requestsTo('PATCH', new RegExp(`/inbox/${IDEA_ID}$`));
457
+ assert.equal(patches.length, 1, 'the idea is linked to the task it became');
458
+ assert.equal(patches[0].body.status, 'promoted');
459
+ assert.equal(patches[0].body.promoted_to_task_id, 1003600);
460
+
461
+ assert.match(h.panel().innerHTML, /Promoted to task/, 'the panel reports the result');
462
+ assert.match(h.panel().innerHTML, /href="\/task\/1003600"/, 'and links the task it made');
463
+ assert.ok(h.events.some((e) => e.type === 'otb:idea-triaged' && e.detail.verdict === 'promoted'),
464
+ 'home\'s attention inbox is told so the queue advances behind the overlay');
465
+ });
466
+
467
+ await test('the title survives escaping — what the form renders is what gets created', async () => {
468
+ const h = await boot();
469
+ await h.clickGo('promote');
470
+ await h.submitForm('promote');
471
+ assert.equal(h.requestsTo('POST', /\/tasks$/)[0].body.title, DEFAULT_IDEA.title,
472
+ 'quotes and angle brackets must round-trip through the value attribute unmangled');
473
+ });
474
+
475
+ await test('promote opting OUT of a goal sends no goal_id key at all', async () => {
476
+ const h = await boot();
477
+ await h.clickGo('promote');
478
+ h.goalSelect().value = '';
479
+ await h.submitForm('promote');
480
+ const body = h.requestsTo('POST', /\/tasks$/)[0].body;
481
+ assert.ok(!('goal_id' in body),
482
+ 'the version\'s general bucket applies only on an ABSENT key — sending null or "" would be a different create');
483
+ });
484
+
485
+ await test('an empty title is refused in the browser, before a doomed request', async () => {
486
+ const h = await boot();
487
+ await h.clickGo('promote');
488
+ h.panel().querySelector('[name="title"]').value = ' ';
489
+ await h.submitForm('promote');
490
+ assert.equal(h.requestsTo('POST', /\/tasks$/).length, 0, 'nothing is sent');
491
+ assert.match(h.errText(), /A task needs a title/);
492
+ });
493
+
494
+ // ===========================================================================
495
+ // The version change re-fetches goals (task 1003071's race guard)
496
+ // ===========================================================================
497
+
498
+ await test('changing the version re-fetches THAT version\'s goals and repaints the picker', async () => {
499
+ const h = await boot();
500
+ await h.clickGo('promote');
501
+ const before = h.requestsTo('GET', /\/goals\/suggestions/).length;
502
+ await h.changeVersion('BV1', { wait: 5 });
503
+
504
+ const fetches = h.requestsTo('GET', /\/goals\/suggestions/);
505
+ assert.equal(fetches.length, before + 1, 'exactly one re-fetch');
506
+ assert.match(fetches[fetches.length - 1].path, /version=BV1/, 'for the version now selected');
507
+
508
+ const opts = h.goalSelect().querySelectorAll('option').map((o) => o.attrs.value);
509
+ assert.ok(opts.includes('1000058'), `BV1's goals are painted — got ${JSON.stringify(opts)}`);
510
+ assert.ok(!opts.includes('1000062'), 'and BV2\'s are gone — offering them would be a home the server rejects');
511
+ assert.equal(h.goalSelect().disabled, false, 'the control is handed back to the builder');
512
+ });
513
+
514
+ await test('two quick version changes: only the NEWEST is allowed to write', async () => {
515
+ // The slow reply belongs to the abandoned selection; if it were allowed to
516
+ // land it would paint the wrong version's goals over the right ones.
517
+ const h = await boot({ suggestDelayMs: { BV1: 40 } });
518
+ await h.clickGo('promote');
519
+ await h.changeVersion('BV1');
520
+ await h.changeVersion('BV2');
521
+ await h.settle(80);
522
+
523
+ const opts = h.goalSelect().querySelectorAll('option').map((o) => o.attrs.value);
524
+ assert.ok(opts.includes('1000062'), `the newest request's goals stand — got ${JSON.stringify(opts)}`);
525
+ assert.ok(!opts.includes('1000058'), 'the stale reply is dropped, not painted');
526
+ assert.equal(h.goalSelect().disabled, false, 'and the newest request re-enabled the control');
527
+ });
528
+
529
+ await test('after a version change the promoted task is created against the NEW version', async () => {
530
+ const h = await boot();
531
+ await h.clickGo('promote');
532
+ await h.changeVersion('BV1', { wait: 5 });
533
+ await h.submitForm('promote');
534
+ const body = h.requestsTo('POST', /\/tasks$/)[0].body;
535
+ assert.equal(body.version_id, 'BV1');
536
+ assert.equal(body.goal_id, 1000058, 'and against a goal that belongs to it');
537
+ });
538
+
539
+ await test('the filed goal hint does NOT survive a version change — it would 400', async () => {
540
+ // The defect this harness was built to find. A goal belongs to exactly one
541
+ // version and POST /tasks rejects a foreign one with bad_goal_id, so carrying
542
+ // the filing's hint onto another version offered the one option guaranteed to
543
+ // fail — pre-selected, as the default the builder would submit.
544
+ const h = await boot();
545
+ await h.clickGo('promote');
546
+ await h.changeVersion('BV1', { wait: 5 });
547
+ const opts = h.goalSelect().querySelectorAll('option').map((o) => o.attrs.value);
548
+ assert.ok(!opts.includes('1000071'), `the other version's hint is not offered — got ${JSON.stringify(opts)}`);
549
+ });
550
+
551
+ await test('changing BACK to the filed version restores the hint as the default', async () => {
552
+ const h = await boot();
553
+ await h.clickGo('promote');
554
+ await h.changeVersion('BV1', { wait: 5 });
555
+ await h.changeVersion('BV2', { wait: 5 });
556
+ assert.equal(h.goalSelect().value, '1000071', 'the filing\'s guess is honoured wherever it is valid');
557
+ });
558
+
559
+ // ===========================================================================
560
+ // The two-write failure path
561
+ // ===========================================================================
562
+
563
+ await test('create succeeds but the idea PATCH fails: the task id is NOT lost', async () => {
564
+ const h = await boot({ patchFails: fail(500, 'internal_error', 'boom') });
565
+ await h.clickGo('promote');
566
+ await h.submitForm('promote');
567
+
568
+ assert.equal(h.requestsTo('POST', /\/tasks$/).length, 1, 'the task was created');
569
+ const msg = h.errText();
570
+ assert.match(msg, /1003600/, 'the created task id is in front of the builder — "could not create" would send them to make a twin');
571
+ assert.match(msg, /Press Create task again/, 'and the retry is named as the fix');
572
+ assert.match(msg, /it does not make a second one/, 'because the create is idempotent on source_ref');
573
+ assert.match(h.panel().innerHTML, /data-form="promote"/, 'the form is still standing to retry from');
574
+ assert.equal(h.panel().querySelector('[data-submit]').disabled, false, 'and the button is usable again');
575
+ assert.equal(h.panel().querySelector('[data-submit]').textContent, 'Create task', 'restored from its busy label');
576
+ assert.ok(!h.events.some((e) => e.type === 'otb:idea-triaged'),
577
+ 'nothing is announced — the idea is still open');
578
+ });
579
+
580
+ await test('retrying after that failure re-sends the SAME create rather than a second one', async () => {
581
+ const h = await boot({ patchFails: fail(500, 'internal_error', 'boom') });
582
+ await h.clickGo('promote');
583
+ await h.submitForm('promote');
584
+ await h.submitForm('promote');
585
+ const posts = h.requestsTo('POST', /\/tasks$/);
586
+ assert.equal(posts.length, 2, 'the retry goes back through the create');
587
+ assert.equal(posts[0].body.source_ref, posts[1].body.source_ref,
588
+ 'with the same idempotency key, so the server resolves it to one task');
589
+ });
590
+
591
+ await test('a failed create reports the server\'s reason and leaves the idea alone', async () => {
592
+ const h = await boot({ createFails: fail(409, 'version_closed', 'nope') });
593
+ await h.clickGo('promote');
594
+ await h.submitForm('promote');
595
+ assert.equal(h.requestsTo('PATCH', /\/inbox\//).length, 0, 'no idea is marked promoted against a task that does not exist');
596
+ assert.match(h.errText(), /That version has shipped/, 'the code is mapped to something a human can act on');
597
+ });
598
+
599
+ await test('a 403 is read out of the NESTED error envelope, not swallowed', async () => {
600
+ // err.data.error is an OBJECT: reading it as a string would make every code
601
+ // comparison silently miss and fall through to the generic message.
602
+ const h = await boot({ createFails: fail(403, 'permission_forbidden', 'no') });
603
+ await h.clickGo('promote');
604
+ await h.submitForm('promote');
605
+ assert.match(h.errText(), /Only Metic\+ can triage ideas/);
606
+ });
607
+
608
+ // ===========================================================================
609
+ // Merge and Discard
610
+ // ===========================================================================
611
+
612
+ await test('merge PATCHes the fold, announces it, and closes the overlay', async () => {
613
+ const h = await boot();
614
+ await h.clickGo('merge');
615
+ h.field('merge', 'target').value = '1000900';
616
+ h.field('merge', 'note').value = 'same request, twice';
617
+ await h.submitForm('merge');
618
+
619
+ const patch = h.requestsTo('PATCH', new RegExp(`/inbox/${IDEA_ID}$`))[0];
620
+ assert.equal(patch.body.status, 'merged');
621
+ assert.equal(patch.body.merged_into_idea_id, 1000900);
622
+ assert.equal(patch.body.triage_note, 'same request, twice');
623
+ assert.ok(h.events.some((e) => e.detail.verdict === 'merged'), 'the queue behind the overlay is told');
624
+ assert.ok(h.toasts.some((t) => /merged into/.test(t)), `the verdict is confirmed — got ${JSON.stringify(h.toasts)}`);
625
+ assert.equal(h.overlay(), null, 'a verdict that leaves nothing to look at closes the card');
626
+ });
627
+
628
+ await test('the merge form echoes the target idea\'s title so a typo is visible', async () => {
629
+ const h = await boot();
630
+ await h.clickGo('merge');
631
+ const input = h.field('merge', 'target');
632
+ input.value = '1000900';
633
+ input.fire('change', {});
634
+ await h.settle();
635
+ assert.match(h.panel().querySelector('[data-target-name]').textContent, /The canonical idea/);
636
+
637
+ input.value = '999999';
638
+ input.fire('change', {});
639
+ await h.settle();
640
+ assert.match(h.panel().querySelector('[data-target-name]').textContent, /no idea/, 'an unknown number says so');
641
+ });
642
+
643
+ await test('merge refuses a blank target in the browser', async () => {
644
+ const h = await boot();
645
+ await h.clickGo('merge');
646
+ await h.submitForm('merge');
647
+ assert.equal(h.requestsTo('PATCH', /\/inbox\//).length, 0);
648
+ assert.match(h.errText(), /Give the idea number/);
649
+ });
650
+
651
+ await test('discard PATCHes the reason and closes the overlay', async () => {
652
+ const h = await boot();
653
+ await h.clickGo('discard');
654
+ h.field('discard', 'note').value = 'already covered elsewhere';
655
+ await h.submitForm('discard');
656
+ const patch = h.requestsTo('PATCH', new RegExp(`/inbox/${IDEA_ID}$`))[0];
657
+ assert.equal(patch.body.status, 'discarded');
658
+ assert.equal(patch.body.triage_note, 'already covered elsewhere');
659
+ assert.ok(h.events.some((e) => e.detail.verdict === 'discarded'));
660
+ assert.equal(h.overlay(), null);
661
+ });
662
+
663
+ await test('a discard with no reason sends null, not an empty string', async () => {
664
+ const h = await boot();
665
+ await h.clickGo('discard');
666
+ await h.submitForm('discard');
667
+ assert.equal(h.requestsTo('PATCH', /\/inbox\//)[0].body.triage_note, null);
668
+ });
669
+
670
+ await test('a failed discard keeps the form up with the reason typed into it', async () => {
671
+ // No envelope at all (a proxy 500, a dropped body): the fallback sentence has
672
+ // to stand in, because an empty message would leave the panel silent.
673
+ const h = await boot({ patchFails: { status: 500, ok: false, data: null } });
674
+ await h.clickGo('discard');
675
+ h.field('discard', 'note').value = 'no longer wanted';
676
+ await h.submitForm('discard');
677
+ assert.ok(h.overlay(), 'the overlay stays open on a failure');
678
+ assert.match(h.errText(), /Could not discard that idea/);
679
+ assert.equal(h.field('discard', 'note').value, 'no longer wanted', 'the typed reason is not thrown away');
680
+ });
681
+
682
+ await test('a server message in the envelope is shown verbatim rather than replaced', async () => {
683
+ const h = await boot({ patchFails: fail(422, 'unprocessable', 'That idea was already triaged.') });
684
+ await h.clickGo('discard');
685
+ await h.submitForm('discard');
686
+ assert.equal(h.errText(), 'That idea was already triaged.',
687
+ 'an unmapped code still tells the builder what the server said');
688
+ });
689
+
690
+ // ===========================================================================
691
+ // Getting back out of a form
692
+ // ===========================================================================
693
+
694
+ await test('Cancel returns to the three verbs without sending anything', async () => {
695
+ const h = await boot();
696
+ await h.clickGo('promote');
697
+ await h.clickGo('idle');
698
+ const verbs = h.panel().querySelectorAll('[data-go]').map((b) => b.dataset.go);
699
+ assert.deepEqual(verbs, ['promote', 'merge', 'discard']);
700
+ assert.equal(h.requestsTo('POST', /\/tasks$/).length, 0);
701
+ });
702
+
703
+ await test('a version list that will not load offers a way back rather than a dead panel', async () => {
704
+ const h = await boot({ versions: [] });
705
+ await h.clickGo('promote');
706
+ assert.match(h.panel().textContent, /No open version/);
707
+ assert.ok(h.panel().querySelector('[data-go="idle"]'), 'a builder is never stranded mid-panel');
708
+ });
709
+
710
+ summary();