@bongos/core 1.19.900 → 1.19.902

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,399 @@
1
+ // tests/idea_trail_descent.mjs
2
+ //
3
+ // task 1004089 (BV2.R09, goal 1000095) — the idea trail read to the FULL depth
4
+ // of the descent, and across version boundaries. ADR 0312; spec
5
+ // docs/specs/bongos-v2-ideator-surface.md.
6
+ //
7
+ // WHAT IS PINNED HERE, AND WHY EACH ONE IS INVISIBLE IN REVIEW. The three
8
+ // findings inherited from the abandoned task 1004054 are the spine of this file:
9
+ //
10
+ // 1. THE RENDERED LAYER IS TESTED, not only the read behind it. The prior
11
+ // attempt asserted on the server-side builder alone, and that is exactly
12
+ // how finding 2 survived it. Half of this file boots the REAL
13
+ // modules/hall-ui/public/profile.js (tests/helpers.mjs bootProfilePage)
14
+ // and asserts on the HTML a reader would actually be handed.
15
+ // 2. A MERGED IDEA SHOWS ITS OWN TRIAGE NOTE beside the descent it inherited
16
+ // from the idea it merged INTO. Both halves matter and they pull in
17
+ // opposite directions: the tasks come from the target, the sentence stays
18
+ // on the merged row. Getting this backwards renders one person's triage
19
+ // note under another person's thought, and every row still paints.
20
+ // 3. THE ROUND TRIPS DO NOT SCALE WITH THE PAGE. The count tracks the DEPTH
21
+ // of the deepest descent and nothing else, so a page of thirty ideas costs
22
+ // what a page of one costs. Nothing about the output changes when this
23
+ // regresses — it just gets slower, quietly, in production.
24
+ //
25
+ // DB-free: every reader takes an injected { client }, so the real SQL and the
26
+ // real walk run against a recorded query log. A real-Postgres suite would
27
+ // SELF-SKIP on a machine with no database, and a skipped test prints ok and
28
+ // counts as passed — which is how a green lane hides what never ran.
29
+ //
30
+ // Run: node tests/idea_trail_descent.mjs
31
+
32
+ import { strict as assert } from 'node:assert';
33
+ import { createRequire } from 'node:module';
34
+ import { bootProfilePage, settle } from './helpers.mjs';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const trailDescent = require('../modules/ideas/trail-descent.js');
38
+
39
+ let passed = 0;
40
+ let failed = 0;
41
+ async function test(name, fn) {
42
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
43
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // the fixture world
48
+ // ---------------------------------------------------------------------------
49
+
50
+ // A four-deep chain below one root, with the third hop landing in a LATER
51
+ // version — the case the read has to carry rather than filter.
52
+ const EDGES = [
53
+ { task_id: 5002, parent_task_id: 5001, relation: 'split' },
54
+ { task_id: 5003, parent_task_id: 5002, relation: 'cascade' },
55
+ { task_id: 5004, parent_task_id: 5003, relation: 'fold' },
56
+ ];
57
+ const TASKS = {
58
+ 5001: { id: 5001, title: 'The first task', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
59
+ 5002: { id: 5002, title: 'Split out of the first', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
60
+ 5003: { id: 5003, title: 'A cascade, next version', status: 'ready', version_id: 'BONGOS-V3', kind: 'bug' },
61
+ 5004: { id: 5004, title: 'Folded in', status: 'abandoned', version_id: 'BONGOS-V3', kind: 'cleanup' },
62
+ };
63
+
64
+ // An SQL-aware client that answers the three shapes this read issues, off real
65
+ // data rather than canned rows, and records every query it was asked for.
66
+ function fakeClient({ edges = EDGES, tasks = TASKS, lineage = {}, promoted = {} } = {}) {
67
+ const log = [];
68
+ return {
69
+ log,
70
+ query: async (text, params) => {
71
+ log.push({ text, params });
72
+ if (/FROM idea_inbox/.test(text)) {
73
+ // the merge follow: target idea ids -> their lineage task ids
74
+ const rows = [];
75
+ for (const ideaId of params[0].map(String)) {
76
+ const tasksFor = lineage[ideaId] || [];
77
+ if (tasksFor.length === 0) {
78
+ rows.push({ idea_id: ideaId, promoted_to_task_id: promoted[ideaId] ?? null, task_id: null });
79
+ }
80
+ for (const t of tasksFor) {
81
+ rows.push({ idea_id: ideaId, promoted_to_task_id: promoted[ideaId] ?? null, task_id: t });
82
+ }
83
+ }
84
+ return { rows };
85
+ }
86
+ if (/FROM ideas_task_descent/.test(text)) {
87
+ const frontier = params[0].map(Number);
88
+ return {
89
+ rows: edges
90
+ .filter((e) => frontier.includes(Number(e.parent_task_id)))
91
+ .map((e) => ({ id: e.task_id, from_id: e.parent_task_id, relation: e.relation })),
92
+ };
93
+ }
94
+ if (/FROM tasks/.test(text)) {
95
+ return { rows: params[0].map((id) => tasks[Number(id)]).filter(Boolean) };
96
+ }
97
+ return { rows: [] };
98
+ },
99
+ };
100
+ }
101
+
102
+ const promotedIdea = (id, rootTaskIds) => ({
103
+ id: String(id), title: `Idea ${id}`, status: 'promoted', triage_note: null,
104
+ lineage_tasks: rootTaskIds.map((t) => ({ task_id: String(t), origin: 'promote', title: TASKS[t]?.title ?? '', status: 'shipped' })),
105
+ });
106
+
107
+ const walkQueries = (c) => c.log.filter((q) => /FROM ideas_task_descent/.test(q.text)).length;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // 1. the read — depth, versions, and the round-trip budget
111
+ // ---------------------------------------------------------------------------
112
+
113
+ await test('the descent reads to ARBITRARY DEPTH, not one hop', async () => {
114
+ const c = fakeClient();
115
+ const [idea] = await trailDescent.attachDescentToTrail([promotedIdea(901, [5001])], { client: c });
116
+ assert.deepEqual(idea.descent_tasks.map((t) => t.task_id), ['5002', '5003', '5004'],
117
+ 'every descendant below the idea, nearest first — not just the first hop');
118
+ assert.deepEqual(idea.descent_tasks.map((t) => t.depth), [1, 2, 3]);
119
+ assert.deepEqual(idea.descent_tasks.map((t) => t.relation), ['split', 'cascade', 'fold'],
120
+ 'each edge says HOW the work descended, which is what the surface reads');
121
+ });
122
+
123
+ await test('the descent CROSSES VERSIONS, and says which version it crossed into', async () => {
124
+ const c = fakeClient();
125
+ const [idea] = await trailDescent.attachDescentToTrail([promotedIdea(901, [5001])], { client: c });
126
+ const crossed = idea.descent_tasks.find((t) => t.task_id === '5003');
127
+ assert.ok(crossed, 'a descendant in a LATER version is still in the descent');
128
+ assert.equal(crossed.version_id, 'BONGOS-V3', 'and it carries its own version, so a surface can mark it');
129
+ for (const q of c.log) {
130
+ assert.doesNotMatch(String(q.text), /version_id\s*=/,
131
+ 'no read may filter on version — a version boundary does not end an origin tag');
132
+ }
133
+ });
134
+
135
+ await test('the ROUND TRIPS track DEPTH, not the size of the page', async () => {
136
+ // Thirty ideas, each rooted at its own task, each with the same three-deep
137
+ // chain below it. Walked one idea at a time this is ninety-odd round trips;
138
+ // batched it is the same handful a single idea costs. That difference is the
139
+ // whole of finding 3, and it is invisible in the output.
140
+ const edges = [];
141
+ const tasks = {};
142
+ const many = [];
143
+ for (let i = 0; i < 30; i += 1) {
144
+ const root = 6000 + i * 10;
145
+ tasks[root] = { id: root, title: `Root ${i}`, status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' };
146
+ for (let d = 1; d <= 3; d += 1) {
147
+ tasks[root + d] = { id: root + d, title: `Below ${i}.${d}`, status: 'ready', version_id: 'BONGOS-V2', kind: 'feature' };
148
+ edges.push({ task_id: root + d, parent_task_id: root + d - 1, relation: 'split' });
149
+ }
150
+ many.push(promotedIdea(7000 + i, [root]));
151
+ }
152
+
153
+ const one = fakeClient({ edges, tasks });
154
+ await trailDescent.attachDescentToTrail([many[0]], { client: one });
155
+ const page = fakeClient({ edges, tasks });
156
+ const out = await trailDescent.attachDescentToTrail(many, { client: page });
157
+
158
+ assert.equal(out.length, 30);
159
+ assert.equal(out[29].descent_tasks.length, 3, 'the thirtieth idea got its full descent, not a truncated one');
160
+ assert.equal(walkQueries(page), walkQueries(one),
161
+ `a page of 30 must cost the walk queries a page of 1 costs — got ${walkQueries(page)} vs ${walkQueries(one)}`);
162
+ assert.equal(page.log.length, one.log.length,
163
+ 'and the same total, because the hydration is batched too');
164
+ });
165
+
166
+ await test('a descendant reachable from TWO of one idea roots resolves ONCE, at the shorter depth', async () => {
167
+ // 5001 -> 5002 -> 5009 and 5008 -> 5009: the idea owns both 5001 and 5008, so
168
+ // 5009 is at depth 2 from one root and depth 1 from the other. Counting it
169
+ // twice would pay the same origin twice for one piece of work (ADR 0312).
170
+ const edges = [
171
+ { task_id: 5002, parent_task_id: 5001, relation: 'split' },
172
+ { task_id: 5009, parent_task_id: 5002, relation: 'cascade' },
173
+ { task_id: 5009, parent_task_id: 5008, relation: 'split' },
174
+ ];
175
+ const tasks = {
176
+ ...TASKS,
177
+ 5008: { id: 5008, title: 'The other root', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
178
+ 5009: { id: 5009, title: 'Reachable twice', status: 'ready', version_id: 'BONGOS-V2', kind: 'feature' },
179
+ };
180
+ const c = fakeClient({ edges, tasks });
181
+ const [idea] = await trailDescent.attachDescentToTrail([promotedIdea(901, [5001, 5008])], { client: c });
182
+ const hits = idea.descent_tasks.filter((t) => t.task_id === '5009');
183
+ assert.equal(hits.length, 1, 'once, never twice');
184
+ assert.equal(hits[0].depth, 1, 'at the SHORTEST depth of the two');
185
+ assert.equal(hits[0].origin_task_id, '5008', 'and attributed to the root that reached it soonest');
186
+ });
187
+
188
+ await test('an idea OWN tasks are not repeated below it as their own descendants', async () => {
189
+ // 5001 and 5002 both belong to the idea, and 5002 descends from 5001. The
190
+ // trail already shows 5002 as something the idea BECAME; showing it again as
191
+ // something it CAUSED double-counts the first hop.
192
+ const c = fakeClient();
193
+ const [idea] = await trailDescent.attachDescentToTrail([promotedIdea(901, [5001, 5002])], { client: c });
194
+ assert.deepEqual(idea.descent_tasks.map((t) => t.task_id), ['5003', '5004']);
195
+ });
196
+
197
+ await test('an idea that caused nothing gets an EMPTY descent, not a missing key', async () => {
198
+ const c = fakeClient({ edges: [] });
199
+ const [idea] = await trailDescent.attachDescentToTrail([promotedIdea(901, [5001])], { client: c });
200
+ assert.deepEqual(idea.descent_tasks, [], 'empty is an answer the renderer can act on');
201
+ });
202
+
203
+ await test('an idea with NO tasks costs no walk at all', async () => {
204
+ const c = fakeClient();
205
+ const [idea] = await trailDescent.attachDescentToTrail(
206
+ [{ id: '904', title: 'Still thinking', status: 'open', lineage_tasks: [] }], { client: c });
207
+ assert.deepEqual(idea.descent_tasks, []);
208
+ assert.equal(c.log.length, 0, 'nothing to walk from means nothing is asked of the database');
209
+ });
210
+
211
+ await test('a non-numeric task id never reaches SQL', async () => {
212
+ const c = fakeClient();
213
+ const idea = {
214
+ id: '901', status: 'promoted',
215
+ lineage_tasks: [{ task_id: 'DROP TABLE tasks' }, { task_id: '5001' }],
216
+ };
217
+ await trailDescent.attachDescentToTrail([idea], { client: c });
218
+ for (const q of c.log) {
219
+ const flat = JSON.stringify(q.params);
220
+ assert.doesNotMatch(flat, /DROP TABLE/, 'a root that is not a positive integer is dropped before the query');
221
+ }
222
+ assert.deepEqual(idea.descent_tasks.map((t) => t.task_id), ['5002', '5003', '5004'],
223
+ 'and the legitimate root beside it still walks');
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // 2. the merge — finding 2, in both directions
228
+ // ---------------------------------------------------------------------------
229
+
230
+ await test('a MERGED idea inherits the descent of the idea it merged INTO', async () => {
231
+ const c = fakeClient({ lineage: { 901: [5001] } });
232
+ const merged = {
233
+ id: '903', title: 'A duplicate', status: 'merged',
234
+ merged_into_idea_id: '901', merged_into_idea_title: 'A shared changelog',
235
+ triage_note: 'Same thought as #901.', lineage_tasks: [],
236
+ };
237
+ const [idea] = await trailDescent.attachDescentToTrail([merged], { client: c });
238
+ assert.deepEqual(idea.descent_tasks.map((t) => t.task_id), ['5002', '5003', '5004'],
239
+ 'nothing is lost: a merged thought still sees the work it caused through its target');
240
+ });
241
+
242
+ await test('the merge follow reads TASK IDS only — no prose travels from the target', async () => {
243
+ const c = fakeClient({ lineage: { 901: [5001] } });
244
+ await trailDescent.attachDescentToTrail([{
245
+ id: '903', status: 'merged', merged_into_idea_id: '901',
246
+ triage_note: 'Same thought as #901.', lineage_tasks: [],
247
+ }], { client: c });
248
+ const follow = c.log.find((q) => /FROM idea_inbox/.test(q.text));
249
+ assert.ok(follow, 'the merge is followed in one query');
250
+ assert.doesNotMatch(follow.text, /triage_note|body_md|\bi\.title\b/,
251
+ 'the target row must not hand back prose — that is how a merged idea ends up wearing somebody else triage note');
252
+ });
253
+
254
+ await test('the merge follow is ONE query however many merges the page carries', async () => {
255
+ const c = fakeClient({ lineage: { 901: [5001], 902: [5001] } });
256
+ const page = [];
257
+ for (let i = 0; i < 8; i += 1) {
258
+ page.push({ id: String(910 + i), status: 'merged', merged_into_idea_id: i % 2 ? '901' : '902', lineage_tasks: [] });
259
+ }
260
+ await trailDescent.attachDescentToTrail(page, { client: c });
261
+ assert.equal(c.log.filter((q) => /FROM idea_inbox/.test(q.text)).length, 1);
262
+ });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // 3. the RENDERED layer — finding 1
266
+ // ---------------------------------------------------------------------------
267
+
268
+ // The trail payload as the route now returns it: the existing four outcomes,
269
+ // each carrying the descent the read attached.
270
+ const RENDER_TRAIL = [
271
+ {
272
+ id: '901', title: 'A shared changelog', status: 'promoted', captured_at: '2026-08-01T00:00:00Z',
273
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The first task', status: 'shipped' }],
274
+ descent_tasks: [
275
+ { task_id: '5002', depth: 1, relation: 'split', origin_task_id: '5001', title: 'Split out of the first', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
276
+ { task_id: '5003', depth: 2, relation: 'cascade', origin_task_id: '5001', title: 'A cascade, next version', status: 'ready', version_id: 'BONGOS-V3', kind: 'bug' },
277
+ ],
278
+ },
279
+ {
280
+ id: '903', title: 'A duplicate', status: 'merged', captured_at: '2026-06-01T00:00:00Z',
281
+ merged_into_idea_id: '901', merged_into_idea_title: 'A shared changelog',
282
+ triage_note: 'Same thought as the changelog one.',
283
+ lineage_tasks: [],
284
+ descent_tasks: [
285
+ { task_id: '5002', depth: 1, relation: 'split', origin_task_id: '5001', title: 'Split out of the first', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
286
+ ],
287
+ },
288
+ {
289
+ id: '904', title: 'Still thinking', status: 'open', captured_at: '2026-05-01T00:00:00Z',
290
+ lineage_tasks: [], descent_tasks: [],
291
+ },
292
+ ];
293
+
294
+ const renderTrail = async (trail) => {
295
+ const p = bootProfilePage({ trail });
296
+ await settle(); await settle(); await settle();
297
+ const html = p.htmlFor('activity-ideas');
298
+ assert.ok(html, 'the idea trail rendered');
299
+ return html;
300
+ };
301
+
302
+ await test('RENDERED: the descent is on the page, linked and named', async () => {
303
+ const html = await renderTrail(RENDER_TRAIL);
304
+ assert.ok(html.includes('/task/5002'), 'a descendant is a link, not a bare number');
305
+ assert.ok(html.includes('Split out of the first'), 'and it is named, so the reader need not go look it up');
306
+ assert.ok(html.includes('/task/5003'), 'to the full depth, not just the first hop');
307
+ });
308
+
309
+ await test('RENDERED: a descendant in ANOTHER version says so on the page', async () => {
310
+ const html = await renderTrail(RENDER_TRAIL);
311
+ assert.ok(html.includes('BONGOS-V3'),
312
+ 'the cross-version case is the proof the origin tag survived a version boundary — it is labelled, not hidden');
313
+ });
314
+
315
+ await test('RENDERED: a MERGED idea shows the inherited tasks AND ITS OWN triage note', async () => {
316
+ // The headline scenario, and the one the prior attempt got backwards.
317
+ const html = await renderTrail(RENDER_TRAIL);
318
+ assert.ok(html.includes('Same thought as the changelog one.'),
319
+ "the note beside a merged idea is the note written about THAT idea");
320
+ assert.ok(/Merged into/.test(html) && html.includes('/idea/901'),
321
+ 'and it still says which idea absorbed it');
322
+ const mergedRow = html.split('Merged into')[1] || '';
323
+ assert.ok(mergedRow.includes('/task/5002'),
324
+ 'with the descent it inherited through the target rendered under it');
325
+ });
326
+
327
+ await test('RENDERED: an idea that caused nothing paints NO descent block', async () => {
328
+ const html = await renderTrail(RENDER_TRAIL);
329
+ const openRow = html.split('Still open')[1] || '';
330
+ assert.doesNotMatch(openRow, /Then caused/,
331
+ 'an empty descent is silence, not a row announcing zero');
332
+ });
333
+
334
+ await test('RENDERED: beyond the cap, the overflow is a LINK, not a truncation', async () => {
335
+ const many = {
336
+ id: '905', title: 'A fertile thought', status: 'promoted', captured_at: '2026-04-01T00:00:00Z',
337
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The first task', status: 'shipped' }],
338
+ descent_tasks: Array.from({ length: 9 }, (_, i) => ({
339
+ task_id: String(5100 + i), depth: 1 + i, relation: 'split', origin_task_id: '5001',
340
+ title: `Descendant ${i}`, status: 'ready', version_id: 'BONGOS-V2', kind: 'feature',
341
+ })),
342
+ };
343
+ const html = await renderTrail([many]);
344
+ assert.ok(html.includes('Then caused 9 tasks'), 'the COUNT is honest even where the list is capped');
345
+ assert.ok(/and 3 more/.test(html), 'and the remainder is named');
346
+ assert.ok(html.includes('/idea/905'), 'as a link to the page that shows the whole descent');
347
+ });
348
+
349
+ await test('RENDERED: a hostile descendant title cannot inject markup', async () => {
350
+ const hostile = {
351
+ id: '906', title: 'A thought', status: 'promoted', captured_at: '2026-03-01T00:00:00Z',
352
+ lineage_tasks: [{ task_id: '5001', origin: 'promote', title: 'The first task', status: 'shipped' }],
353
+ descent_tasks: [{
354
+ task_id: '5200', depth: 1, relation: '<img src=x onerror=alert(1)>', origin_task_id: '5001',
355
+ title: '<script>alert(1)</script>', status: 'ready', version_id: '"><b>V</b>', kind: 'feature',
356
+ }],
357
+ };
358
+ const html = await renderTrail([hostile]);
359
+ assert.doesNotMatch(html, /<script>/, 'the title is escaped');
360
+ // The ANGLE BRACKETS are what makes markup, not the attribute text: an
361
+ // escaped `&lt;img … onerror=…&gt;` is inert prose and must stay READABLE, so
362
+ // the assertion is that no live tag opened, never that the substring is gone.
363
+ assert.doesNotMatch(html, /<img/, 'the relation cannot open a tag either, DB enum or not');
364
+ assert.ok(html.includes('&lt;img'), 'it is escaped into visible text, not silently swallowed');
365
+ assert.doesNotMatch(html, /"><b>/, 'and the version, which rides into an attribute-adjacent position');
366
+ assert.ok(html.includes('&lt;script&gt;'), 'escaped, not stripped — the reader still sees what was there');
367
+ });
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // 4. the WHOLE path — the read feeding the renderer, not a fixture between them
371
+ // ---------------------------------------------------------------------------
372
+
373
+ // WHY THIS EXISTS SEPARATELY from the rendered tests above. Those hand-build the
374
+ // payload, which makes them sharp about the PAGE and blind to the READ: a bug
375
+ // that puts the target's triage note on the merged row happens server-side, and
376
+ // a hand-written fixture that does not have that bug renders perfectly. So the
377
+ // headline scenario is asserted once with nothing hand-built in the middle —
378
+ // the real attachDescentToTrail output goes straight into the real page.
379
+ await test('WHOLE PATH: the real read feeds the real page, and the merged note is the merged idea own', async () => {
380
+ const c = fakeClient({ lineage: { 901: [5001] } });
381
+ const trail = await trailDescent.attachDescentToTrail([
382
+ {
383
+ id: '903', title: 'A duplicate', status: 'merged', captured_at: '2026-06-01T00:00:00Z',
384
+ merged_into_idea_id: '901', merged_into_idea_title: 'A shared changelog',
385
+ triage_note: 'Same thought as the changelog one.', lineage_tasks: [],
386
+ },
387
+ ], { client: c });
388
+
389
+ const html = await renderTrail(trail);
390
+ assert.ok(html.includes('Same thought as the changelog one.'),
391
+ 'the sentence on the page is the one written about THIS idea — not the target it merged into');
392
+ assert.ok(html.includes('/task/5002') && html.includes('/task/5004'),
393
+ 'while the tasks under it are the ones inherited through the target, to full depth');
394
+ assert.ok(html.includes('BONGOS-V3'),
395
+ 'including the hop that crossed a version boundary');
396
+ });
397
+
398
+ console.log(`\n${passed} passed, ${failed} failed`);
399
+ process.exit(failed === 0 ? 0 : 1);
@@ -35,7 +35,7 @@ import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import vm from 'node:vm';
37
37
  import { fileURLToPath } from 'node:url';
38
- import { assertCannotSwallow } from './helpers.mjs';
38
+ import { assertCannotSwallow, bootProfilePage, settle } from './helpers.mjs';
39
39
 
40
40
  const require = createRequire(import.meta.url);
41
41
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -421,7 +421,9 @@ await test('a ledger cannot be both client-sliced and server-paged', () => {
421
421
  // 6. the page — the four outcomes, the craft axis, and the signed-out state
422
422
  // ---------------------------------------------------------------------------
423
423
 
424
- const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
424
+ // The harness that boots the real page lives in tests/helpers.mjs, shared with
425
+ // tests/idea_trail_descent.mjs (task 1004089) so one page has one stub DOM.
426
+ const bootProfile = (opts = {}) => bootProfilePage({ ...opts, trail: TRAIL });
425
427
 
426
428
  // The four outcomes the criterion names, one row each. `open` is included
427
429
  // precisely because it must read as an outcome and not as an absence.
@@ -436,127 +438,7 @@ const TRAIL = [
436
438
  { id: '904', title: 'Still thinking', status: 'open', captured_at: '2026-05-01T00:00:00Z' },
437
439
  ];
438
440
 
439
- // Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
440
- // ledger's rowHtml, and return what it rendered plus every request it made.
441
- function bootProfile({ signedIn = true } = {}) {
442
- const els = new Map();
443
- const makeEl = () => ({
444
- dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
445
- classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
446
- setAttribute() {}, getAttribute() { return null; },
447
- addEventListener() {},
448
- querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
449
- set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
450
- });
451
- const documentObj = {
452
- title: '',
453
- body: { dataset: { page: 'profile' } },
454
- documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
455
- getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
456
- querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
457
- querySelectorAll() { return []; },
458
- createElement() { return makeEl(); },
459
- addEventListener() {},
460
- };
461
-
462
- const requests = [];
463
- const client = {
464
- request: async (method, url) => {
465
- requests.push(url);
466
- if (url.endsWith('/me')) {
467
- return signedIn
468
- ? { status: 200, ok: true, data: { builder: { id: 95, rank: 'metic' } } }
469
- : { status: 401, ok: false, data: null };
470
- }
471
- if (/\/builders\/\d+\/profile$/.test(url)) {
472
- return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
473
- }
474
- if (url.includes('/activity?')) {
475
- const isGoals = url.includes('section=goals');
476
- return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
477
- }
478
- if (url.includes('/inbox/by-builder/')) {
479
- return { status: 200, ok: true, data: { ideas: TRAIL, total: TRAIL.length } };
480
- }
481
- return { status: 404, ok: false, data: null };
482
- },
483
- };
484
-
485
- // The captured ledgers, keyed by the mount element they were given.
486
- const ledgers = new Map();
487
- let tabsOnChange = null;
488
- const kit = {
489
- makePager: (prefix, key, opts = {}) => ({
490
- prefix, key, page: 1, perPage: opts.defaultPerPage || 25,
491
- perPageOptions: [10, 25], minPerPage: opts.minPerPage ?? 10,
492
- }),
493
- pageOffset: (p) => Math.max(0, (p.page - 1) * p.perPage),
494
- makeLedger: ({ mount, rowHtml }) => {
495
- const rec = { rows: [], total: 0, html: '' };
496
- const ledger = {
497
- render(rows, opts = {}) {
498
- rec.rows = rows || [];
499
- rec.total = opts.total || 0;
500
- rec.html = rec.rows.map((r, i) => rowHtml(r, i)).join('');
501
- },
502
- };
503
- ledgers.set(mount, rec);
504
- return ledger;
505
- },
506
- makeTabs: ({ tabs, onChange }) => {
507
- let current = tabs[0].id;
508
- tabsOnChange = onChange;
509
- onChange(current);
510
- return { active: () => current, activate(id) { current = id; onChange(id); } };
511
- },
512
- shipVisualThumbHtml: () => '',
513
- };
514
-
515
- const windowObj = {
516
- __BRANDING__: {}, __MODULES__: { enabled: {} },
517
- BongosClient: { createClient: () => client },
518
- OTBKit: kit,
519
- OTB: {
520
- // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
521
- // the same reason categoryChip is: an undefined helper throws mid-render.
522
- roleAxisLabel: () => 'Role',
523
- roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
524
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
525
- roleIsUnset: (d) => !d || d === 'unclassified',
526
- ROLE_UNSET: 'unclassified',
527
- escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
528
- $: (sel) => documentObj.querySelector(sel),
529
- fmtNum: (n) => String(n ?? 0),
530
- fmtDate: (iso) => String(iso ?? '').slice(0, 10),
531
- rankLabel: (r) => r,
532
- toast() {},
533
- },
534
- location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
535
- localStorage: { getItem: () => null, setItem() {} },
536
- addEventListener() {},
537
- };
538
-
539
- const sandbox = {
540
- window: windowObj, document: documentObj, location: windowObj.location,
541
- console, setTimeout, clearTimeout, URLSearchParams,
542
- };
543
- sandbox.globalThis = sandbox;
544
- vm.createContext(sandbox);
545
- vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
546
-
547
- const htmlFor = (mountId) => {
548
- const rec = ledgers.get(documentObj.getElementById(mountId));
549
- return rec ? rec.html : null;
550
- };
551
- return {
552
- requests,
553
- htmlFor,
554
- switchCraft: (id) => { if (tabsOnChange) tabsOnChange(id); },
555
- el: (id) => documentObj.getElementById(id),
556
- };
557
- }
558
441
 
559
- const settle = () => new Promise((r) => setTimeout(r, 0));
560
442
 
561
443
  await test('the ideator view shows EVERY idea filed, whatever became of it', async () => {
562
444
  const p = bootProfile();