@bongos/core 1.19.899 → 1.19.901
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 +48 -18
- package/docs/copy-inventory.md +8 -7
- package/docs/copy-registry.json +17 -8
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile.js +51 -5
- package/modules/ideas/descent.js +285 -0
- package/modules/ideas/migrations/ideas_007_task_descent_edges.sql +111 -0
- package/modules/ideas/module.json +4 -1
- package/modules/ideas/pollers/descent-writer.js +69 -0
- package/modules/ideas/routes/inbox.js +10 -0
- package/modules/ideas/trail-descent.js +224 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/helpers.mjs +137 -0
- package/tests/idea_task_descent.mjs +347 -0
- package/tests/idea_trail_descent.mjs +399 -0
- package/tests/profile_activity.mjs +4 -122
|
@@ -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 `<img … onerror=…>` 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('<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('<script>'), '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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|