@bongos/core 1.19.925 → 1.19.926
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 +31 -21
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/idea.css +154 -0
- package/modules/hall-ui/public/idea.js +192 -0
- package/modules/ideas/inbox.js +1 -0
- package/modules/ideas/routes/inbox.js +24 -1
- package/modules/ideas/trail-removal.js +115 -0
- package/modules/ideas/trail.js +56 -21
- package/modules/provisioning/core-upgrade.js +6 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +190 -0
- package/tests/helpers.mjs +99 -0
- package/tests/idea_descent_surface.mjs +458 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// tests/idea_descent_surface.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004094 (BV2.R10, goal 1000095, criterion `ideator-tag-survives-the-fork`)
|
|
4
|
+
// — THE DESCENT SURFACE on /idea/:id. Spec docs/specs/bongos-v2-ideator-surface.md;
|
|
5
|
+
// ADR 0312 (the edges), 0313 (removal proposed then confirmed), 0322 (the five
|
|
6
|
+
// states).
|
|
7
|
+
//
|
|
8
|
+
// THE DONE-WHEN IS THE FIRST TEST, and it is the only one that matters on its
|
|
9
|
+
// own: a person can trace a thing shipped months later back to the line that
|
|
10
|
+
// caused it, THROUGH rewrites, splits and merges. So §1 renders a real
|
|
11
|
+
// four-deep chain whose hops are a rewrite, two splits and a fold, and asserts
|
|
12
|
+
// that the reader is handed every hop, the word for each edge, and a path back
|
|
13
|
+
// to each task.
|
|
14
|
+
//
|
|
15
|
+
// TESTED AT THE RENDERED LAYER, not at the read behind it. That is the first of
|
|
16
|
+
// the three findings this goal's work inherited from the abandoned task 1004054
|
|
17
|
+
// (see modules/ideas/trail-descent.js): the prior attempt asserted on the
|
|
18
|
+
// server-side builder alone, which is exactly how a renderer bug survived it.
|
|
19
|
+
// Every case below boots the REAL modules/hall-ui/public/idea.js through
|
|
20
|
+
// tests/helpers.mjs bootIdeaPage and reads the HTML a reader would get.
|
|
21
|
+
//
|
|
22
|
+
// THE THREE TRAPS THIS FILE EXISTS TO CATCH, each of which paints a page that
|
|
23
|
+
// looks completely fine:
|
|
24
|
+
//
|
|
25
|
+
// 1. THE ROOTS GO MISSING. attachDescentToTrail deliberately EXCLUDES an
|
|
26
|
+
// idea's own root tasks from descent_tasks (repeating them would
|
|
27
|
+
// double-count the first hop for the payout). A renderer that draws only
|
|
28
|
+
// descent_tasks therefore loses the first thing the thought ever caused —
|
|
29
|
+
// the one hop every other surface already shows — and the rail still
|
|
30
|
+
// renders, just beginning in the middle of the story.
|
|
31
|
+
// 2. UNREADABLE IS DRAWN AS ALIVE. `removed_tasks` is `{}` when nothing went
|
|
32
|
+
// out and `null` when the lifecycle facts could not be read at all. Those
|
|
33
|
+
// are different answers, and collapsing the second into the first promises
|
|
34
|
+
// a reader that every branch survived. Nothing errors when it does.
|
|
35
|
+
// 3. THE EMPTY STATE IS A HEADING OVER NOTHING. A thought that caused nothing
|
|
36
|
+
// must render NO section, not an empty rail — the same judgement the page
|
|
37
|
+
// already makes about a quick idea's five-field skeleton.
|
|
38
|
+
//
|
|
39
|
+
// DB-free: no route, no pool, no browser. Run: node tests/idea_descent_surface.mjs
|
|
40
|
+
|
|
41
|
+
import { strict as assert } from 'node:assert';
|
|
42
|
+
import { createRequire } from 'node:module';
|
|
43
|
+
import { bootIdeaPage, settle } from './helpers.mjs';
|
|
44
|
+
|
|
45
|
+
const require = createRequire(import.meta.url);
|
|
46
|
+
|
|
47
|
+
let passed = 0;
|
|
48
|
+
let failed = 0;
|
|
49
|
+
async function test(name, fn) {
|
|
50
|
+
try { await fn(); passed += 1; console.log(`ok ${name}`); }
|
|
51
|
+
catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// the fixture world
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
// One thought, one root, and a four-deep descent below it whose hops are the
|
|
59
|
+
// three relations the done-when names by name — a rewrite, a split and a merge
|
|
60
|
+
// — plus a fold that later WENT OUT and a cascade that has not landed yet. The
|
|
61
|
+
// deepest hop lives in a LATER version, because the descent crosses version
|
|
62
|
+
// boundaries deliberately (ADR 0312) and a surface that hides that is lying by
|
|
63
|
+
// omission.
|
|
64
|
+
const ROOT = { task_id: '9001', origin: 'promote', title: 'Env vars in project setup', status: 'shipped', goal_id: '600' };
|
|
65
|
+
|
|
66
|
+
const DESCENT = [
|
|
67
|
+
{ task_id: '9002', depth: 1, relation: 'rewrite', origin_task_id: '9001', title: 'Scope widened to secrets', status: 'shipped', version_id: 'BONGOS-V1', kind: 'feature' },
|
|
68
|
+
{ task_id: '9003', depth: 2, relation: 'split', origin_task_id: '9001', title: 'The env var editor', status: 'shipped', version_id: 'BONGOS-V1', kind: 'feature' },
|
|
69
|
+
{ task_id: '9004', depth: 2, relation: 'merge', origin_task_id: '9001', title: 'Encrypted secret storage', status: 'shipped', version_id: 'BONGOS-V2', kind: 'feature' },
|
|
70
|
+
{ task_id: '9005', depth: 3, relation: 'fold', origin_task_id: '9001', title: 'The settings panel it lived in', status: 'shipped', version_id: 'BONGOS-V1', kind: 'refactor' },
|
|
71
|
+
{ task_id: '9006', depth: 1, relation: 'cascade', origin_task_id: '9001', title: 'Import a .env file', status: 'backlog', version_id: 'BONGOS-V2', kind: 'feature' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function anIdea(over = {}) {
|
|
75
|
+
return {
|
|
76
|
+
id: '1',
|
|
77
|
+
title: 'A place to put env vars',
|
|
78
|
+
body_md: 'The thought.',
|
|
79
|
+
grade: 'quick',
|
|
80
|
+
status: 'promoted',
|
|
81
|
+
kind: 'feature',
|
|
82
|
+
suggested_version: 'BONGOS-V1',
|
|
83
|
+
captured_by: '1',
|
|
84
|
+
captured_at: '2026-07-01T12:00:00.000Z',
|
|
85
|
+
promoted_to_task_id: '9001',
|
|
86
|
+
promoted_task_title: ROOT.title,
|
|
87
|
+
promoted_task_status: ROOT.status,
|
|
88
|
+
lineage_tasks: [ROOT],
|
|
89
|
+
descent_tasks: DESCENT,
|
|
90
|
+
removed_tasks: {},
|
|
91
|
+
size: { credits: 278, payments: 5, scale: 3, estimated: 0, historical: false },
|
|
92
|
+
state: { state: 'star', position: 'well', wellGoalId: '600', awaitingNod: false, brightness: 0.82, hasWork: true, attributable: true },
|
|
93
|
+
...over,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function renderIdea(over = {}) {
|
|
98
|
+
const page = bootIdeaPage({ idea: anIdea(over) });
|
|
99
|
+
await settle();
|
|
100
|
+
await settle();
|
|
101
|
+
await settle();
|
|
102
|
+
return page.html();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The rows, in render order, each as { id, mark, meta }.
|
|
106
|
+
function rowsOf(html) {
|
|
107
|
+
const out = [];
|
|
108
|
+
const re = /<li class="ig-desc__row" data-mark="([^"]+)" style="--d:(\d+)">([\s\S]*?)<\/li>/g;
|
|
109
|
+
let m;
|
|
110
|
+
while ((m = re.exec(html)) !== null) {
|
|
111
|
+
const body = m[3];
|
|
112
|
+
const id = (/<span class="ig-desc__id">(\d+)<\/span>/.exec(body) || [])[1];
|
|
113
|
+
const meta = (/<span class="ig-desc__meta">([\s\S]*?)<\/span>\s*$/.exec(body.split('ig-desc__meta')[1] ? body : body) || [])[0];
|
|
114
|
+
out.push({
|
|
115
|
+
id,
|
|
116
|
+
mark: m[1],
|
|
117
|
+
depth: Number(m[2]),
|
|
118
|
+
meta: (/<span class="ig-desc__meta">([\s\S]*?)<\/span>/.exec(body) || [])[1] || '',
|
|
119
|
+
title: (/<a class="ig-desc__title"[^>]*>([\s\S]*?)<\/a>/.exec(body) || [])[1] || '',
|
|
120
|
+
href: (/<a class="ig-desc__title" href="([^"]+)"/.exec(body) || [])[1] || '',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// §1 — the done-when
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
await test('§1 DONE-WHEN: a thing shipped later traces back through the rewrites, splits and merges', async () => {
|
|
131
|
+
const html = await renderIdea();
|
|
132
|
+
const rows = rowsOf(html);
|
|
133
|
+
|
|
134
|
+
// Every hop is there: the root plus all five descendants. Nothing is capped
|
|
135
|
+
// on this page — profile.js caps at three and links HERE for the rest, which
|
|
136
|
+
// is the only thing that makes that cap honest.
|
|
137
|
+
assert.equal(rows.length, 6, `expected the root + 5 descendants, got ${rows.length}`);
|
|
138
|
+
assert.deepEqual(rows.map((r) => r.id), ['9001', '9002', '9003', '9004', '9005', '9006']);
|
|
139
|
+
|
|
140
|
+
// The three relations the done-when names are each stated IN WORDS. A reader
|
|
141
|
+
// tracing 9005 back to the origin has to be able to see what kind of hop each
|
|
142
|
+
// step was, or the rail is a list of tasks rather than a story.
|
|
143
|
+
const meta = (id) => rows.find((r) => r.id === id).meta;
|
|
144
|
+
assert.match(meta('9002'), /rewritten/, 'the rewrite must say so');
|
|
145
|
+
assert.match(meta('9003'), /split off/, 'the split must say so');
|
|
146
|
+
assert.match(meta('9004'), /merged in/, 'the merge must say so');
|
|
147
|
+
assert.match(meta('9005'), /folded in/, 'the fold must say so');
|
|
148
|
+
assert.match(meta('9006'), /caused by/, 'the cascade must say so');
|
|
149
|
+
|
|
150
|
+
// And every hop is a door back to the work itself.
|
|
151
|
+
for (const r of rows) {
|
|
152
|
+
assert.equal(r.href, `/task/${r.id}`, `row ${r.id} must link to its task`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('§1 the depth of a hop survives into the rail, so the shape is readable', async () => {
|
|
157
|
+
const rows = rowsOf(await renderIdea());
|
|
158
|
+
// The server counts hops FROM a root; the rail draws the root at 0, so every
|
|
159
|
+
// descendant sits one step deeper than the number the read carries.
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
rows.map((r) => [r.id, r.depth]),
|
|
162
|
+
[['9001', 0], ['9002', 2], ['9003', 3], ['9004', 3], ['9005', 4], ['9006', 2]],
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('§1 a descendant in a LATER version says which one; one in the same version does not', async () => {
|
|
167
|
+
const rows = rowsOf(await renderIdea());
|
|
168
|
+
const meta = (id) => rows.find((r) => r.id === id).meta;
|
|
169
|
+
// 9004 and 9006 are BONGOS-V2 against an idea filed at BONGOS-V1.
|
|
170
|
+
assert.match(meta('9004'), /in BONGOS-V2/, 'a cross-version hop must name its version');
|
|
171
|
+
assert.match(meta('9006'), /in BONGOS-V2/);
|
|
172
|
+
// 9002 is in the idea's own version, so saying so would be noise on every row.
|
|
173
|
+
assert.doesNotMatch(meta('9002'), /in BONGOS-V1/, 'a same-version hop must not repeat the version');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// §2 — the roots (trap 1)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
await test('§2 TRAP: the ROOT is drawn, though the server leaves it out of descent_tasks', async () => {
|
|
181
|
+
const rows = rowsOf(await renderIdea());
|
|
182
|
+
const root = rows[0];
|
|
183
|
+
assert.equal(root.id, '9001', 'the first thing the thought caused must lead the rail');
|
|
184
|
+
assert.equal(root.depth, 0);
|
|
185
|
+
assert.match(root.meta, /became work/, 'a root has no relation — it became work');
|
|
186
|
+
assert.match(root.title, /Env vars in project setup/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('§2 an idea that became MANY tasks shows every one of them as a root', async () => {
|
|
190
|
+
const rows = rowsOf(await renderIdea({
|
|
191
|
+
lineage_tasks: [
|
|
192
|
+
ROOT,
|
|
193
|
+
{ task_id: '9010', origin: 'develop', title: 'The second thing it became', status: 'ready', goal_id: '600' },
|
|
194
|
+
],
|
|
195
|
+
}));
|
|
196
|
+
const roots = rows.filter((r) => r.depth === 0).map((r) => r.id);
|
|
197
|
+
assert.deepEqual(roots, ['9001', '9010'], 'both lineage tasks are roots');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('§2 a promotion that predates the lineage table still shows its one root', async () => {
|
|
201
|
+
// lineage_tasks empty, promoted_to_task_id set — the only answer for an old
|
|
202
|
+
// promotion, and the reason the legacy column is still read.
|
|
203
|
+
const rows = rowsOf(await renderIdea({ lineage_tasks: [], descent_tasks: [] }));
|
|
204
|
+
assert.deepEqual(rows.map((r) => r.id), ['9001']);
|
|
205
|
+
assert.match(rows[0].meta, /became work/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('§2 an idea carrying BOTH lineage and the legacy column does not list its root twice', async () => {
|
|
209
|
+
const rows = rowsOf(await renderIdea({ descent_tasks: [] }));
|
|
210
|
+
assert.deepEqual(rows.map((r) => r.id), ['9001'], 'lineage wins; the legacy column is not added on top');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// §3 — the branches that went out (trap 2)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
await test('§3 a CONFIRMED removal marks its row out, names the ship that did it, and is counted', async () => {
|
|
218
|
+
const html = await renderIdea({
|
|
219
|
+
removed_tasks: { 9005: { proposal_task_id: '9100', ship_task_id: '9101', paths: ['src/settings/panel.js'], decided_at: '2027-01-14T09:00:00.000Z' } },
|
|
220
|
+
});
|
|
221
|
+
const rows = rowsOf(html);
|
|
222
|
+
const gone = rows.find((r) => r.id === '9005');
|
|
223
|
+
assert.equal(gone.mark, 'out', 'a removed hop is marked out');
|
|
224
|
+
assert.match(gone.meta, /went out/);
|
|
225
|
+
assert.match(gone.meta, /task 9101/, 'the removal names the ship that did it');
|
|
226
|
+
assert.match(gone.meta, /href="\/task\/9101"/, 'and that ship is reachable');
|
|
227
|
+
// Every other hop is unaffected — removal is per-node, not per-thought.
|
|
228
|
+
assert.deepEqual(
|
|
229
|
+
rows.filter((r) => r.id !== '9005').map((r) => r.mark),
|
|
230
|
+
['live', 'live', 'live', 'live', 'pending'],
|
|
231
|
+
);
|
|
232
|
+
assert.match(html, /<span class="ig-desc__n">1<\/span>\s*<span class="ig-desc__flabel">went out<\/span>/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('§3 TRAP: `null` removal facts are NOT drawn as a descent that is wholly alive', async () => {
|
|
236
|
+
const html = await renderIdea({ removed_tasks: null });
|
|
237
|
+
const rows = rowsOf(html);
|
|
238
|
+
// Not one row may claim to be out, and none may claim to be alive either:
|
|
239
|
+
// "we could not look" is a third answer.
|
|
240
|
+
assert.equal(rows.filter((r) => r.mark === 'out').length, 0);
|
|
241
|
+
assert.doesNotMatch(html, /went out<\/span>/, 'no figure may state a removal count that was never read');
|
|
242
|
+
assert.match(html, /could not be read/, 'the page must SAY the liveness is unknown');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await test('§3 an empty `{}` means nothing went out — and says nothing, rather than saying it is unknown', async () => {
|
|
246
|
+
const html = await renderIdea({ removed_tasks: {} });
|
|
247
|
+
assert.doesNotMatch(html, /could not be read/, 'a readable, empty answer is not an unreadable one');
|
|
248
|
+
assert.doesNotMatch(html, /went out/, 'nothing went out, so nothing is said about it');
|
|
249
|
+
assert.equal(rowsOf(html).filter((r) => r.mark === 'out').length, 0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// §4 — the empty state (trap 3)
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
await test('§4 TRAP: a thought that caused NOTHING renders no section at all', async () => {
|
|
257
|
+
const html = await renderIdea({
|
|
258
|
+
status: 'open',
|
|
259
|
+
promoted_to_task_id: null,
|
|
260
|
+
promoted_task_title: null,
|
|
261
|
+
lineage_tasks: [],
|
|
262
|
+
descent_tasks: [],
|
|
263
|
+
size: null,
|
|
264
|
+
state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: false, attributable: true },
|
|
265
|
+
});
|
|
266
|
+
assert.doesNotMatch(html, /ig-desc/, 'no heading, no rail, no figures — the page says "still open" and stops');
|
|
267
|
+
// The page itself still renders: an absent descent must not blank the record.
|
|
268
|
+
assert.match(html, /rec-title/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// §5 — the numbers are the server's, and they never lie about what they are
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
await test('§5 size is printed verbatim and a HISTORICAL size says so', async () => {
|
|
276
|
+
const plain = await renderIdea();
|
|
277
|
+
assert.match(plain, /<span class="ig-desc__n">278<\/span>/);
|
|
278
|
+
assert.match(plain, /size — credits/);
|
|
279
|
+
|
|
280
|
+
// Spec decision 17: a backfilled size was computed from the descent an idea
|
|
281
|
+
// already had, not from payouts actually made. Anywhere the number is shown
|
|
282
|
+
// has to make that clear or it claims to be money that was never spent.
|
|
283
|
+
const hist = await renderIdea({ size: { credits: 278, payments: 0, scale: 3, estimated: 278, historical: true } });
|
|
284
|
+
assert.match(hist, /size — part historical/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('§5 an idea with no size at all still draws its rail', async () => {
|
|
288
|
+
const html = await renderIdea({ size: null });
|
|
289
|
+
assert.doesNotMatch(html, /size — /, 'no size, no size figure');
|
|
290
|
+
assert.equal(rowsOf(html).length, 6, 'but every hop it caused is still drawn');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('§5 the count of things it caused is the rows actually drawn, roots included', async () => {
|
|
294
|
+
const html = await renderIdea();
|
|
295
|
+
assert.match(html, /<span class="ig-desc__n">6<\/span>\s*<span class="ig-desc__flabel">things it caused<\/span>/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// §6 — the vocabulary boundary (ADR 0322)
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
await test('§6 the page does NOT re-derive a five-object state per descendant', async () => {
|
|
303
|
+
const html = await renderIdea();
|
|
304
|
+
// The five objects are an IDEA's property, resolved once in
|
|
305
|
+
// modules/ideas/idea-state.js. A row here is a TASK and carries no object; if
|
|
306
|
+
// one of these words ever appears against a descent row, some renderer has
|
|
307
|
+
// started deciding a state a second time — which is the exact failure ADR 0322
|
|
308
|
+
// exists to prevent, and it would disagree with the sky on the first read.
|
|
309
|
+
const rail = /<ol class="ig-desc__list">([\s\S]*?)<\/ol>/.exec(html)[1];
|
|
310
|
+
for (const object of ['protostar', 'satellite', 'remnant', 'cold-body']) {
|
|
311
|
+
assert.doesNotMatch(rail, new RegExp(object, 'i'), `no descent row may be labelled a ${object}`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await test('§6 an unknown relation is shown verbatim rather than dropped', async () => {
|
|
316
|
+
// A relation this page has not heard of is a real edge someone wrote. Dropping
|
|
317
|
+
// it would silently shorten the story; showing it unstyled does not.
|
|
318
|
+
const rows = rowsOf(await renderIdea({
|
|
319
|
+
descent_tasks: [{ task_id: '9007', depth: 1, relation: 'transmuted', origin_task_id: '9001', title: 'A hop from the future', status: 'shipped', version_id: 'BONGOS-V1' }],
|
|
320
|
+
}));
|
|
321
|
+
const hop = rows.find((r) => r.id === '9007');
|
|
322
|
+
assert.ok(hop, 'the row must still be drawn');
|
|
323
|
+
assert.match(hop.meta, /transmuted/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// §7 — it is a read of hostile data
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
await test('§7 a task title carrying markup reaches the page as TEXT, not as markup', async () => {
|
|
331
|
+
const html = await renderIdea({
|
|
332
|
+
descent_tasks: [{ task_id: '9008', depth: 1, relation: 'split', origin_task_id: '9001', title: '<img src=x onerror=alert(1)>', status: 'shipped', version_id: 'BONGOS-V1' }],
|
|
333
|
+
});
|
|
334
|
+
assert.doesNotMatch(html, /<img src=x/, 'the title must be escaped');
|
|
335
|
+
assert.match(html, /<img src=x/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await test('§7 a descent row with no title falls back to its id rather than rendering blank', async () => {
|
|
339
|
+
const rows = rowsOf(await renderIdea({
|
|
340
|
+
descent_tasks: [{ task_id: '9009', depth: 1, relation: 'split', origin_task_id: '9001', title: null, status: 'shipped' }],
|
|
341
|
+
}));
|
|
342
|
+
assert.match(rows.find((r) => r.id === '9009').title, /Task 9009/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// §8 — the read behind it
|
|
348
|
+
//
|
|
349
|
+
// The renderer above is only honest if the ROUTE hands it these fields, and the
|
|
350
|
+
// route reads a different row from the one it used to. Both halves of that are
|
|
351
|
+
// pinned here, DB-free, because both fail SILENTLY: a projection that stops
|
|
352
|
+
// carrying `lineage_tasks` renders a thought with a descent as one with none,
|
|
353
|
+
// and nothing errors.
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
const trail = require('../modules/ideas/trail.js');
|
|
357
|
+
const trailRemoval = require('../modules/ideas/trail-removal.js');
|
|
358
|
+
|
|
359
|
+
// A pool stand-in that records the SQL it was handed.
|
|
360
|
+
function recordingPool(rows = []) {
|
|
361
|
+
const log = [];
|
|
362
|
+
return { log, query: async (text, params) => { log.push({ text, params }); return { rows }; } };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await test('§8 the detail read and the trail page read use the SAME projection', async () => {
|
|
366
|
+
// THE ANTI-DRIFT CLAIM, and it is invisible in review. The two reads differ
|
|
367
|
+
// only in their WHERE; if someone hand-copies the SELECT for one of them and
|
|
368
|
+
// later adds a column to the other, the detail page silently stops carrying
|
|
369
|
+
// a field the attachers read by name. Comparing the text is what makes the
|
|
370
|
+
// "spelled once" comment in trail.js a fact rather than an intention.
|
|
371
|
+
process.env.NODE_ENV = 'test';
|
|
372
|
+
const one = recordingPool([{ id: '1' }]);
|
|
373
|
+
await trail.getIdeaTrailRow('1', { pool: one });
|
|
374
|
+
|
|
375
|
+
const many = recordingPool([]);
|
|
376
|
+
await trail.listIdeaTrailForBuilder('95', { limit: 5 }, { pool: many });
|
|
377
|
+
|
|
378
|
+
const selectOf = (sql) => sql.slice(0, sql.indexOf(' WHERE'));
|
|
379
|
+
assert.ok(one.log.length === 1 && many.log.length === 1, 'one query each');
|
|
380
|
+
assert.equal(
|
|
381
|
+
selectOf(one.log[0].text),
|
|
382
|
+
selectOf(many.log[0].text),
|
|
383
|
+
'the detail read must select exactly what the trail read selects',
|
|
384
|
+
);
|
|
385
|
+
// And the thing the attachers actually need is in it.
|
|
386
|
+
assert.match(one.log[0].text, /lineage_tasks/);
|
|
387
|
+
assert.match(one.log[0].text, /promoted_task_goal_id/);
|
|
388
|
+
assert.match(one.log[0].text, /WHERE i\.id = \$1/);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await test('§8 getIdeaTrailRow answers null for an id that is not there', async () => {
|
|
392
|
+
process.env.NODE_ENV = 'test';
|
|
393
|
+
const p = recordingPool([]);
|
|
394
|
+
assert.equal(await trail.getIdeaTrailRow('404', { pool: p }), null);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---- the removal attacher -------------------------------------------------
|
|
398
|
+
|
|
399
|
+
// A module-api stand-in is not available here, so the port is exercised through
|
|
400
|
+
// the real resolveOptional by temporarily registering one. When that is not
|
|
401
|
+
// possible the attacher's OWN contract is still the thing under test: what it
|
|
402
|
+
// writes onto the row for each of the three answers.
|
|
403
|
+
function withPort(port, fn) {
|
|
404
|
+
const api = require('../src/module-api');
|
|
405
|
+
const original = api.resolveOptional;
|
|
406
|
+
api.resolveOptional = (name) => (name === 'lifecycle.removals' ? port : original && original(name));
|
|
407
|
+
return fn().finally(() => { api.resolveOptional = original; });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await test('§8 attachRemovalToDescent keys BOTH the roots and the descent', async () => {
|
|
411
|
+
let asked = null;
|
|
412
|
+
const port = {
|
|
413
|
+
confirmedRemovalsForTasks: async (ids) => {
|
|
414
|
+
asked = [...ids].sort();
|
|
415
|
+
return new Map([['9005', { proposalTaskId: '9100', shipTaskId: '9101', paths: ['a.js'], decidedAt: 'x' }]]);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
await withPort(port, async () => {
|
|
419
|
+
const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [{ task_id: '9005' }, { task_id: '9006' }] };
|
|
420
|
+
await trailRemoval.attachRemovalToDescent([idea]);
|
|
421
|
+
// The ROOT is asked about too. attachDescentToTrail leaves roots out of
|
|
422
|
+
// descent_tasks, and a root is the commonest thing to have been torn out —
|
|
423
|
+
// asking only about the descent would draw a dead origin as alive.
|
|
424
|
+
assert.deepEqual(asked, ['9001', '9005', '9006']);
|
|
425
|
+
assert.deepEqual(Object.keys(idea.removed_tasks), ['9005']);
|
|
426
|
+
assert.equal(idea.removed_tasks['9005'].ship_task_id, '9101');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await test('§8 nothing removed is `{}`; an unreadable port is `null` — never the same value', async () => {
|
|
431
|
+
await withPort({ confirmedRemovalsForTasks: async () => new Map() }, async () => {
|
|
432
|
+
const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [] };
|
|
433
|
+
await trailRemoval.attachRemovalToDescent([idea]);
|
|
434
|
+
assert.deepEqual(idea.removed_tasks, {}, 'readable and empty');
|
|
435
|
+
});
|
|
436
|
+
await withPort(null, async () => {
|
|
437
|
+
const idea = { id: '1', lineage_tasks: [{ task_id: '9001' }], descent_tasks: [] };
|
|
438
|
+
await trailRemoval.attachRemovalToDescent([idea]);
|
|
439
|
+
assert.equal(idea.removed_tasks, null, 'unreadable is null, and the renderer says so');
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await test('§8 the whole page costs ONE port call, however many nodes it draws', async () => {
|
|
444
|
+
let calls = 0;
|
|
445
|
+
const port = { confirmedRemovalsForTasks: async () => { calls += 1; return new Map(); } };
|
|
446
|
+
await withPort(port, async () => {
|
|
447
|
+
const ideas = Array.from({ length: 12 }, (_, i) => ({
|
|
448
|
+
id: String(i + 1),
|
|
449
|
+
lineage_tasks: [{ task_id: String(8000 + i) }],
|
|
450
|
+
descent_tasks: Array.from({ length: 6 }, (__, j) => ({ task_id: String(9000 + i * 10 + j) })),
|
|
451
|
+
}));
|
|
452
|
+
await trailRemoval.attachRemovalToDescent(ideas);
|
|
453
|
+
assert.equal(calls, 1, 'one call for 12 ideas and 84 nodes, not one per idea');
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
458
|
+
process.exit(failed > 0 ? 1 : 0);
|