@bongos/core 1.19.1008 → 1.19.1009

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,338 @@
1
+ // tests/ideator_one_line_path_proof.mjs — THE PROOF for spec decision 18
2
+ // (task 1004101 / BV2.R22 of goal 1000095, spec R06a).
3
+ //
4
+ // THE DECISION THIS GUARDS. Spec decision 18 folded the Full Idea form onto the
5
+ // same surface as the one-line composer and called itself "the decision with the
6
+ // most risk in it", because a spark's whole value is that it costs nothing to
7
+ // have. If the fast path picked up an extra step, a scroll or a decision, this
8
+ // scope would have built something beautiful that people stop using — and they
9
+ // would go back to firing one-liners into Discord.
10
+ //
11
+ // WHY THIS IS NOT MORE UNIT TESTS. tests/idea_spark_hall.mjs already asserts
12
+ // that the folded page behaves correctly. What it cannot see is whether the
13
+ // folded page is WORSE THAN THE ONE IT REPLACED, because it only ever boots the
14
+ // current file. A regression in the fast path is not a broken assertion; it is
15
+ // the same page, still working, costing more than it used to. The only way to
16
+ // see that is to measure both.
17
+ //
18
+ // SO THE BASELINE IS THE REAL PRE-FOLD PAGE, not a description of it.
19
+ // tests/fixtures/ideator-prefold/ holds byte copies of
20
+ // modules/hall-ui/public/ideas.{html,js} at commit f375a1f9 — the commit
21
+ // immediately before the fold landed (task 1004087) — carried as .baseline files
22
+ // so the duplication ratchet does not read a frozen copy as copy/paste to collapse
23
+ // (see that folder's README). Both pages are booted through the SAME stub, instrumented the same way, in the same process. A
24
+ // hand-written "before" number would agree with the "after" because the author
25
+ // made it agree; these two can only agree if the code does.
26
+ //
27
+ // WHAT THIS FILE CAN AND CANNOT MEASURE — read this before trusting it.
28
+ // It measures the things that are DETERMINISTIC: how many gestures filing
29
+ // takes, how much the page does before the fast path is usable, where the fast
30
+ // path sits in the document, and what the fast path depends on. It does NOT
31
+ // measure wall-clock, because a wall-clock assertion in CI is a flake and a
32
+ // median over a stub DOM is not a number about the product. The real elapsed
33
+ // time was measured in a browser and is recorded, with its method and its
34
+ // error bars, in docs/governance/bongos-v2-ideator-one-line-path-proof.md.
35
+ // A reader who wants the seconds should go there; what is enforced on every
36
+ // push is below.
37
+ //
38
+ // Pure vm + file read. No DB, no network, no browser.
39
+ //
40
+ // Run: node tests/ideator_one_line_path_proof.mjs
41
+
42
+ import { strict as assert } from 'node:assert';
43
+ import { createRequire } from 'node:module';
44
+ import fs from 'node:fs';
45
+ import path from 'node:path';
46
+ import vm from 'node:vm';
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ const require = createRequire(import.meta.url);
50
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
51
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
52
+ const PREFOLD = path.join(ROOT, 'tests/fixtures/ideator-prefold');
53
+
54
+ // The commit the baseline was taken at, so a reader can re-cut it and diff.
55
+ const BASELINE_SHA = 'f375a1f9';
56
+
57
+ const PAGES = {
58
+ before: {
59
+ label: `the page as it was (${BASELINE_SHA}, before the fold)`,
60
+ js: fs.readFileSync(path.join(PREFOLD, 'ideas.js.baseline'), 'utf8'),
61
+ html: fs.readFileSync(path.join(PREFOLD, 'ideas.html.baseline'), 'utf8'),
62
+ },
63
+ after: {
64
+ label: 'the page as it is now (folded)',
65
+ js: fs.readFileSync(path.join(HALL, 'ideas.js'), 'utf8'),
66
+ html: fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8'),
67
+ },
68
+ };
69
+
70
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
71
+ const TEMPLATE = {
72
+ key: BASE_TEMPLATE.key,
73
+ label: BASE_TEMPLATE.label,
74
+ fields: BASE_TEMPLATE.fields.map((f) => ({
75
+ key: f.key, label: f.label, required: !!f.required, substance_min: f.substanceMin || 0,
76
+ })),
77
+ };
78
+
79
+ const SPARKS = [
80
+ { id: '77', title: 'Let a builder mute one channel', status: 'open', grade: 'spark', captured_at: '2026-08-01T00:00:00Z', filed_by_login: 'thinker' },
81
+ { id: '78', title: 'A shared changelog', status: 'open', grade: 'spark', captured_at: '2026-07-30T00:00:00Z', filed_by_login: null },
82
+ ];
83
+ const VERSIONS = [{ id: 'BV1', status: 'building' }];
84
+ const GOALS = [{ id: '77', title: 'Goal Seventy Seven', version_id: 'BV1' }];
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // the instrumented boot — identical for both pages
88
+ // ---------------------------------------------------------------------------
89
+ //
90
+ // THE GESTURE COUNTER IS THE LOAD-BEARING PART. A "gesture" here is one
91
+ // discrete thing a person does with the keyboard or the pointer: focusing a
92
+ // control, changing its value, or pressing something. Typing a title is ONE
93
+ // gesture (a person types a sentence, they do not perform N keystroke
94
+ // decisions), which is the reading that makes the comparison fair — counting
95
+ // keystrokes would let a shorter placeholder look like a faster path.
96
+ //
97
+ // It is counted by INSTRUMENTING THE STUB rather than by reading the source:
98
+ // every value written to a control and every handler fired is recorded, so the
99
+ // count is what the driver below actually had to do to reach a filed spark,
100
+ // on whichever page it was driven against.
101
+
102
+ function boot(which, { templateOk = true } = {}) {
103
+ const page = PAGES[which];
104
+ const els = new Map();
105
+ const gestures = [];
106
+ const calls = [];
107
+
108
+ const makeEl = (id) => ({
109
+ id,
110
+ dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
111
+ readOnly: false, tabIndex: 0, _html: '', _handlers: {},
112
+ _attrs: {}, _classes: new Set(),
113
+ _value: '',
114
+ get value() { return this._value; },
115
+ set value(v) { this._value = String(v == null ? '' : v); },
116
+ get classList() { const s = this._classes; return { add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c) }; },
117
+ setAttribute(k, v) { this._attrs[String(k)] = String(v); },
118
+ getAttribute(k) { const v = this._attrs[String(k)]; return v === undefined ? null : v; },
119
+ addEventListener(t, fn) { this._handlers[t] = fn; },
120
+ querySelector() { return makeEl('anon'); }, querySelectorAll() { return []; }, appendChild() {},
121
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
122
+ });
123
+
124
+ const documentObj = {
125
+ title: '', body: { dataset: { page: 'ideas' } },
126
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
127
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); },
128
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
129
+ querySelectorAll() { return []; },
130
+ createElement() { return makeEl('created'); },
131
+ addEventListener() {},
132
+ };
133
+
134
+ const client = {
135
+ inbox: {
136
+ getInboxTemplate: async () => { calls.push('template'); return templateOk ? { status: 200, ok: true, data: { template: TEMPLATE } } : { status: 500, ok: false, data: null }; },
137
+ getInboxSparks: async () => { calls.push('sparks'); return { status: 200, ok: true, data: { sparks: SPARKS, count: SPARKS.length } }; },
138
+ getInboxId: async () => { calls.push('idea'); return { status: 404, ok: false, data: null }; },
139
+ postInboxScore: async () => { calls.push('score'); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
140
+ postInbox: async (a) => { calls.push('capture'); return { status: 201, ok: true, data: { idea: { id: '910', routing: { routed: false, reason: 'no_goal' } } } }; },
141
+ postInboxIdDevelop: async () => { calls.push('develop'); return { status: 200, ok: true, data: { idea: {} } }; },
142
+ postInboxPreviewLanding: async () => { calls.push('preview'); return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } }; },
143
+ },
144
+ versions: { getVersions: async () => { calls.push('versions'); return { status: 200, ok: true, data: { versions: VERSIONS } }; } },
145
+ goals: {
146
+ getGoals: async () => { calls.push('goals'); return { status: 200, ok: true, data: { goals: GOALS } }; },
147
+ getGoalsSuggestions: async () => { calls.push('suggestions'); return { status: 200, ok: true, data: { suggestions: [] } }; },
148
+ },
149
+ me: { getMe: async () => { calls.push('me'); return { status: 200, ok: true, data: { task_kinds: ['bug', 'feature'] } }; } },
150
+ };
151
+
152
+ const windowObj = {
153
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
154
+ BongosClient: { createClient: () => client },
155
+ OTB: {
156
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
157
+ $: (sel) => documentObj.querySelector(sel),
158
+ toast: () => {},
159
+ fillKindFilter: () => {},
160
+ kindLabel: (k) => k,
161
+ },
162
+ localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
163
+ location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search: '', hash: '', assign() {} },
164
+ addEventListener() {},
165
+ };
166
+
167
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date };
168
+ sandbox.globalThis = sandbox;
169
+ vm.createContext(sandbox);
170
+ vm.runInContext(page.js, sandbox, { filename: `ideas.js[${which}]` });
171
+
172
+ const el = (id) => documentObj.getElementById(id);
173
+ return {
174
+ which, label: page.label, html: page.html, calls, gestures, el,
175
+ // Every driver action below goes through these, so the gesture count is a
176
+ // record of what was actually done rather than a number someone typed.
177
+ type(id, text) { gestures.push(`type:${id}`); el(id).value = text; const h = el(id)._handlers.input; if (h) h(); },
178
+ choose(id, v) { gestures.push(`choose:${id}`); el(id).value = v; const h = el(id)._handlers.change; if (h) h(); },
179
+ press(id) { gestures.push(`press:${id}`); const h = el(id)._handlers.click; if (h) return h(); return undefined; },
180
+ async fileIt() {
181
+ gestures.push('press:fq-submit');
182
+ return el('fq-form')._handlers.submit({ preventDefault() {} });
183
+ },
184
+ submittable() { return typeof el('fq-form')._handlers.submit === 'function'; },
185
+ };
186
+ }
187
+
188
+ const settle = () => new Promise((r) => setTimeout(r, 0));
189
+ const boots = async (which, opts) => { const r = boot(which, opts); await settle(); await settle(); await settle(); return r; };
190
+
191
+ let passed = 0;
192
+ let failed = 0;
193
+ const findings = [];
194
+ async function claim(name, fn) {
195
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
196
+ catch (err) { failed++; findings.push(`${name}: ${err.message}`); console.error(` FAIL ${name}\n ${err.message}`); }
197
+ }
198
+
199
+ console.log('\nTHE ONE-LINE PATH, BEFORE AND AFTER THE FOLD');
200
+ console.log(` before = ${PAGES.before.label}`);
201
+ console.log(` after = ${PAGES.after.label}\n`);
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // CLAIM 1 — the same gestures
205
+ // ---------------------------------------------------------------------------
206
+
207
+ await claim('filing one line takes the SAME gestures on both pages, and they are the same gestures', async () => {
208
+ const drive = async (which) => {
209
+ const r = await boots(which);
210
+ r.type('fq-title', 'the hall should remember what I filed');
211
+ await r.fileIt();
212
+ await settle();
213
+ return r;
214
+ };
215
+ const before = await drive('before');
216
+ const after = await drive('after');
217
+
218
+ assert.deepEqual(after.gestures, before.gestures,
219
+ `the gesture sequence changed:\n before ${JSON.stringify(before.gestures)}\n after ${JSON.stringify(after.gestures)}`);
220
+ // Stated absolutely as well as relatively: two pages that both got WORSE in
221
+ // the same way would pass the comparison above and nothing else here.
222
+ assert.deepEqual(after.gestures, ['type:fq-title', 'press:fq-submit'],
223
+ 'the fast path is a title and a press — anything else is a step this decision promised not to add');
224
+ assert.ok(before.calls.includes('capture') && after.calls.includes('capture'),
225
+ 'and both actually filed — a path that files nothing is not a fast one');
226
+ });
227
+
228
+ await claim('neither page makes the filer touch the kind or the goal to file', async () => {
229
+ for (const which of ['before', 'after']) {
230
+ const r = await boots(which);
231
+ r.type('fq-title', 'one line');
232
+ await r.fileIt();
233
+ await settle();
234
+ const capture = r.calls.filter((c) => c === 'capture');
235
+ assert.equal(capture.length, 1, `${which}: filing with nothing but a title reaches the capture route`);
236
+ }
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // CLAIM 2 — the fast path is usable no later than it was
241
+ // ---------------------------------------------------------------------------
242
+
243
+ await claim('the form is submittable before ANY read answers — on both pages', async () => {
244
+ // Deliberately NOT awaited: this is the state of the page in the instant
245
+ // after its script runs, before a single promise has settled.
246
+ for (const which of ['before', 'after']) {
247
+ const r = boot(which);
248
+ assert.equal(r.submittable(), true,
249
+ `${which}: the submit handler is attached synchronously, so a fast typist is never racing the network`);
250
+ }
251
+ // Honest reading: this was ALREADY true before the fold. It is recorded as a
252
+ // property preserved, not as an improvement won — see the governance record.
253
+ });
254
+
255
+ await claim('the folded page does LESS on arrival: no template read, no scoring, no meter', async () => {
256
+ const before = await boots('before');
257
+ const after = await boots('after');
258
+
259
+ assert.ok(before.calls.includes('template'),
260
+ 'the old page fetched the five questions on arrival whether or not anyone wanted them');
261
+ assert.ok(!after.calls.includes('template'),
262
+ 'the folded page does not');
263
+ assert.ok(!after.calls.includes('score'), 'and scores nothing');
264
+ assert.ok(after.calls.length < before.calls.length,
265
+ `the folded page issues fewer reads on arrival (before ${before.calls.length}: ${before.calls.join(',')} · after ${after.calls.length}: ${after.calls.join(',')})`);
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // CLAIM 3 — the fast path is not behind anything
270
+ // ---------------------------------------------------------------------------
271
+
272
+ await claim('the one-line composer moved ABOVE the two things that used to precede it', async () => {
273
+ const pos = (html, id) => html.indexOf(`id="${id}"`);
274
+ const b = PAGES.before.html;
275
+ const a = PAGES.after.html;
276
+
277
+ assert.ok(pos(b, 'fi-queue-scroll') < pos(b, 'fq-compose-scroll'),
278
+ 'before: the spark queue came first — the baseline says so itself, so this is measured, not remembered');
279
+ assert.ok(pos(a, 'fq-compose-scroll') < pos(a, 'fi-queue-scroll'),
280
+ 'after: the one-line composer is first');
281
+ assert.ok(pos(a, 'fq-compose-scroll') < pos(a, 'fi-compose-scroll'),
282
+ 'and still ahead of the five questions');
283
+ // The rail is the other half of "how much room the fast path gets".
284
+ assert.match(b, /class="app-content app-content--railed"/,
285
+ 'before: the column reserved the meter\'s room at every wide width, occupied or not');
286
+ assert.doesNotMatch(a, /class="[^"]*app-content--railed/,
287
+ 'after: it does not, so the one-line form is drawn at the page\'s full width');
288
+ });
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // CLAIM 4 — the fast path does not depend on the slow one
292
+ // ---------------------------------------------------------------------------
293
+
294
+ await claim('a totally broken Full Idea half does not slow, block or shout over the fast path', async () => {
295
+ const before = await boots('before', { templateOk: false });
296
+ const after = await boots('after', { templateOk: false });
297
+
298
+ for (const [which, r] of [['before', before], ['after', after]]) {
299
+ r.type('fq-title', 'filing while the other half is down');
300
+ await r.fileIt();
301
+ await settle();
302
+ assert.ok(r.calls.includes('capture'), `${which}: the filing still goes through`);
303
+ }
304
+ // The difference is what the PAGE says about it. The old page wrote the
305
+ // template's failure into the page-head subtitle — a failure notice sitting
306
+ // over a form that was working perfectly well.
307
+ assert.match(before.el('fi-sub').textContent, /Could not load the idea template/,
308
+ 'before: the page announced a broken half the filer had not asked for');
309
+ assert.doesNotMatch(after.el('fi-sub').textContent, /Could not load/,
310
+ 'after: a folded half that failed to load says nothing, because nobody opened it');
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // CLAIM 5 — the guard on this proof
315
+ // ---------------------------------------------------------------------------
316
+
317
+ await claim('the baseline is the REAL previous page, not a paraphrase of it', async () => {
318
+ // If someone ever "tidies" the fixture into agreement with the current page,
319
+ // every comparison above becomes vacuous and still passes. These are the
320
+ // properties that made the before the before.
321
+ const b = PAGES.before.js;
322
+ assert.ok(b.includes('const r = await api.inbox.getInboxTemplate({});'),
323
+ 'the baseline still fetches the template unconditionally in main()');
324
+ assert.ok(!b.includes('setDisclosure'), 'and knows nothing about a disclosure');
325
+ assert.ok(!PAGES.before.html.includes('fi-open'), 'and has no toggle in its markup');
326
+ assert.notEqual(PAGES.before.js, PAGES.after.js, 'the two pages are not the same file');
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+
331
+ console.log('');
332
+ console.log(`ideator_one_line_path_proof: ${passed} passed, ${failed} failed`);
333
+ if (failed > 0) {
334
+ console.error('\nTHE FAST PATH GOT MORE EXPENSIVE. Spec decision 18 is the decision this scope');
335
+ console.error('flagged as riskiest, and these are its guard rails:');
336
+ for (const f of findings) console.error(` - ${f}`);
337
+ process.exit(1);
338
+ }