@bongos/core 1.19.505 → 1.19.507
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 +32 -22
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +13 -16
- package/docs/copy-registry.json +34 -51
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +33 -6
- package/modules/hall-ui/public/hall-widgets.js +1 -1
- package/modules/hall-ui/public/sessions.html +10 -6
- package/modules/hall-ui/public/sessions.js +101 -40
- package/modules/lifecycle/routes/tasks.js +11 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +38 -0
- package/tests/hall_board_world.mjs +11 -1
- package/tests/home_bounded_reads.mjs +159 -0
- package/tests/sessions_kit_pager.mjs +312 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// tests/sessions_kit_pager.mjs — the session log pages on the KIT's contract, not
|
|
2
|
+
// its own (goal 1000072, the migration task 1003281 left behind).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. `sessions.js` was the hall's one sanctioned hand-rolled
|
|
5
|
+
// pager — audited as an exception at task 2179 because the kit could only slice an
|
|
6
|
+
// array the browser already held, and the session corpus cannot be held. Task 1003281
|
|
7
|
+
// added the server-offset mode to the kit and explicitly deferred the migration:
|
|
8
|
+
// "sessions.js stays as it is; migrating it is its own task." This is that task, and
|
|
9
|
+
// an exception removed without a test is an exception waiting to be re-added.
|
|
10
|
+
//
|
|
11
|
+
// It runs the REAL hall-kit.js and the REAL sessions.js in one vm against a stub DOM
|
|
12
|
+
// and a fake API (the profile_activity.mjs idiom), so what is asserted is the request
|
|
13
|
+
// the page actually issues and the bar a reader actually sees — not a stub of the kit
|
|
14
|
+
// agreeing with a stub of the page.
|
|
15
|
+
//
|
|
16
|
+
// The five behaviours the migration had to keep or gain:
|
|
17
|
+
// 1. it asks the SERVER for one page (the corpus is never pulled into the browser)
|
|
18
|
+
// 2. a page click moves the offset by the reader's per-page, not a fixed 50
|
|
19
|
+
// 3. per-page is a real control now, and it resets to page 1 and persists
|
|
20
|
+
// 4. a failed fetch ROLLS BACK the pager — the bar may never claim a page whose
|
|
21
|
+
// rows never arrived (the one piece of the hand-roll worth keeping)
|
|
22
|
+
// 5. nothing is pressable while a fetch is in flight
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/sessions_kit_pager.mjs
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { makeRunner } from './helpers.mjs';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
34
|
+
const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
|
|
35
|
+
const SESSIONS_JS = read('modules/hall-ui/public/sessions.js');
|
|
36
|
+
const SESSIONS_HTML = read('modules/hall-ui/public/sessions.html');
|
|
37
|
+
|
|
38
|
+
const { test, summary } = makeRunner();
|
|
39
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// the harness: a stub DOM just rich enough for the kit's ids + the page's cells
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function makeEl(id) {
|
|
46
|
+
const listeners = new Map();
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
innerHTML: '', textContent: '', value: '',
|
|
50
|
+
hidden: false, disabled: false,
|
|
51
|
+
options: [], style: {}, dataset: {},
|
|
52
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
53
|
+
addEventListener(ev, fn) {
|
|
54
|
+
if (!listeners.has(ev)) listeners.set(ev, []);
|
|
55
|
+
listeners.get(ev).push(fn);
|
|
56
|
+
},
|
|
57
|
+
fire(ev, arg) { for (const fn of listeners.get(ev) || []) fn(arg); },
|
|
58
|
+
listenerCount(ev) { return (listeners.get(ev) || []).length; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `rank: 'metic'` is the gate sessions.js applies before it fetches anything.
|
|
63
|
+
function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPagePref = null, dropKit = false } = {}) {
|
|
64
|
+
const els = new Map();
|
|
65
|
+
const getEl = (id) => {
|
|
66
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
67
|
+
return els.get(id);
|
|
68
|
+
};
|
|
69
|
+
const documentObj = {
|
|
70
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
71
|
+
getElementById: getEl,
|
|
72
|
+
querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
|
|
73
|
+
querySelectorAll() { return []; },
|
|
74
|
+
createElement() { return makeEl(); },
|
|
75
|
+
addEventListener() {},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const requests = [];
|
|
79
|
+
let calls = 0;
|
|
80
|
+
const rowsFor = (limit, offset) => Array.from(
|
|
81
|
+
{ length: Math.max(0, Math.min(limit, total - offset)) },
|
|
82
|
+
(_, i) => ({ session_id: `sid-${offset + i}`, builder_id: 1, github_login: 'jaxri', rank: 'metic', task_ids: [], assistant_turns: 3, total_tokens: 100, tool_errors: 0, duration_seconds: 60, uploaded_at: '2026-08-01T00:00:00Z' })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const client = {
|
|
86
|
+
request: async (method, url) => {
|
|
87
|
+
requests.push(url);
|
|
88
|
+
if (url.endsWith('/me')) {
|
|
89
|
+
return { status: 200, ok: true, data: { builder: { id: 1, rank: viewerRank } } };
|
|
90
|
+
}
|
|
91
|
+
if (url.includes('/sessions/search')) {
|
|
92
|
+
calls += 1;
|
|
93
|
+
if (failAfter != null && calls > failAfter) return { status: 500, ok: false, data: null };
|
|
94
|
+
const q = new URLSearchParams(url.split('?')[1] || '');
|
|
95
|
+
const limit = Number(q.get('limit'));
|
|
96
|
+
const offset = Number(q.get('offset'));
|
|
97
|
+
return {
|
|
98
|
+
status: 200,
|
|
99
|
+
ok: true,
|
|
100
|
+
data: { sessions: rowsFor(limit, offset), totals: { total, builders: 3 } },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { status: 404, ok: false, data: null };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const store = new Map();
|
|
108
|
+
// Seeded BEFORE the scripts run — makePager reads the preference at boot, so this is
|
|
109
|
+
// the only way to model a returning reader.
|
|
110
|
+
if (perPagePref != null) store.set('otb.sessions.perPage', String(perPagePref));
|
|
111
|
+
const windowObj = {
|
|
112
|
+
BongosClient: { createClient: () => client },
|
|
113
|
+
location: { assign() {} },
|
|
114
|
+
localStorage: {
|
|
115
|
+
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
116
|
+
setItem: (k, v) => store.set(k, String(v)),
|
|
117
|
+
},
|
|
118
|
+
scrollTo() {},
|
|
119
|
+
addEventListener() {},
|
|
120
|
+
OTB: {
|
|
121
|
+
escapeHtml: (s) => String(s == null ? '' : s),
|
|
122
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
123
|
+
num: (n) => Number(n) || 0,
|
|
124
|
+
fmtInt: (n) => String(Number(n) || 0),
|
|
125
|
+
fmtCompact: (n) => String(Number(n) || 0),
|
|
126
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
127
|
+
rankLabel: (r) => String(r ?? ''),
|
|
128
|
+
toast() {},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const sandbox = {
|
|
133
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
134
|
+
console: { error() {}, warn() {}, log() {} },
|
|
135
|
+
setTimeout, clearTimeout, URLSearchParams,
|
|
136
|
+
};
|
|
137
|
+
sandbox.globalThis = sandbox;
|
|
138
|
+
vm.createContext(sandbox);
|
|
139
|
+
// The real kit first — that is the point: the page is held to the shipped contract.
|
|
140
|
+
vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
|
|
141
|
+
if (dropKit) delete sandbox.window.OTBKit;
|
|
142
|
+
vm.runInContext(SESSIONS_JS, sandbox, { filename: 'sessions.js' });
|
|
143
|
+
|
|
144
|
+
const searches = () => requests.filter((u) => u.includes('/sessions/search'));
|
|
145
|
+
const q = (n) => {
|
|
146
|
+
const list = searches();
|
|
147
|
+
const url = list[n < 0 ? list.length + n : n];
|
|
148
|
+
return Object.fromEntries(new URLSearchParams(url.split('?')[1]));
|
|
149
|
+
};
|
|
150
|
+
return { els, getEl, searches, q, store, kit: sandbox.window.OTBKit };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// 1. the page markup no longer carries a pager of its own
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
await test('sessions.html carries a mount, not a hand-rolled pager', () => {
|
|
158
|
+
assert.ok(!/id="ledger-pager"/.test(SESSIONS_HTML), 'the bespoke <nav class="pager"> is gone');
|
|
159
|
+
assert.ok(!/id="pager-(prev|next|pos)"/.test(SESSIONS_HTML), 'its bespoke button ids are gone');
|
|
160
|
+
assert.match(SESSIONS_HTML, /id="sessions-pager-mount"/);
|
|
161
|
+
assert.match(SESSIONS_HTML, /hall-kit\.js/, 'the kit must load on this page now');
|
|
162
|
+
// The kit must be loaded BEFORE the page script that reads window.OTBKit at boot.
|
|
163
|
+
assert.ok(SESSIONS_HTML.indexOf('hall-kit.js') < SESSIONS_HTML.indexOf('sessions.js?'),
|
|
164
|
+
'hall-kit.js must come before sessions.js');
|
|
165
|
+
// The pager mount stays OUTSIDE the horizontal scrollport, or it slides away with
|
|
166
|
+
// the columns on a narrow screen.
|
|
167
|
+
const wrapEnd = SESSIONS_HTML.indexOf('</div>', SESSIONS_HTML.indexOf('ledger-table-wrap'));
|
|
168
|
+
assert.ok(SESSIONS_HTML.indexOf('sessions-pager-mount') > wrapEnd,
|
|
169
|
+
'the pager mount must not be inside .ledger-table-wrap');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('sessions.js hand-rolls no pager state any more', () => {
|
|
173
|
+
assert.ok(!/const PAGE_SIZE\b/.test(SESSIONS_JS), 'the fixed page size is gone');
|
|
174
|
+
assert.ok(!/state\.offset/.test(SESSIONS_JS.replace(/^\s*\/\/.*$/gm, '')),
|
|
175
|
+
'the hand-rolled offset cursor is gone (comments may still explain it)');
|
|
176
|
+
for (const fn of ['makePager', 'pagerBarHtml', 'wirePager', 'pageOffset', 'renderServerPage']) {
|
|
177
|
+
assert.ok(SESSIONS_JS.includes(`kit.${fn}`), `sessions.js must use the kit's ${fn}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// 2. what the page actually does, executed
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
await test('the first load asks the SERVER for one page — the corpus is never held', async () => {
|
|
186
|
+
const h = harness({ total: 320 });
|
|
187
|
+
await tick(); await tick();
|
|
188
|
+
assert.equal(h.searches().length, 1);
|
|
189
|
+
const p = h.q(0);
|
|
190
|
+
assert.equal(p.offset, '0');
|
|
191
|
+
assert.equal(p.limit, '50', 'the page keeps its historical 50 as the default per-page');
|
|
192
|
+
assert.equal(p.order, 'recent');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test('Next moves the offset by the reader\'s per-page, and the bar says where they are', async () => {
|
|
196
|
+
const h = harness({ total: 320 });
|
|
197
|
+
await tick(); await tick();
|
|
198
|
+
h.getEl('sessions-next').fire('click');
|
|
199
|
+
await tick(); await tick();
|
|
200
|
+
assert.equal(h.searches().length, 2);
|
|
201
|
+
assert.equal(h.q(-1).offset, '50');
|
|
202
|
+
assert.equal(h.getEl('sessions-page').textContent, 'Page 2 of 7');
|
|
203
|
+
assert.equal(h.getEl('sessions-range').textContent, 'Showing 51–100 of 320');
|
|
204
|
+
assert.equal(h.getEl('sessions-pager').hidden, false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('per-page is a real control now: it re-asks, resets to page 1, and persists', async () => {
|
|
208
|
+
const h = harness({ total: 320 });
|
|
209
|
+
await tick(); await tick();
|
|
210
|
+
h.getEl('sessions-next').fire('click');
|
|
211
|
+
await tick(); await tick();
|
|
212
|
+
assert.equal(h.q(-1).offset, '50', 'sanity: on page 2');
|
|
213
|
+
|
|
214
|
+
const sel = h.getEl('sessions-per-page');
|
|
215
|
+
sel.fire('change', { target: { value: '100' } });
|
|
216
|
+
await tick(); await tick();
|
|
217
|
+
const p = h.q(-1);
|
|
218
|
+
assert.equal(p.limit, '100', 'the new page size reaches the server');
|
|
219
|
+
assert.equal(p.offset, '0', 'a per-page change returns to page 1');
|
|
220
|
+
assert.equal(h.store.get('otb.sessions.perPage'), '100', 'the choice is remembered');
|
|
221
|
+
assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 4');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('a remembered per-page is used on the very first request of the next visit', async () => {
|
|
225
|
+
// The point of persisting it: a returning reader does not have to set it again, and
|
|
226
|
+
// does not first get a page at the default size and then a second one at theirs.
|
|
227
|
+
const h = harness({ total: 320, perPagePref: 25 });
|
|
228
|
+
await tick(); await tick();
|
|
229
|
+
assert.equal(h.searches().length, 1, 'one request, not a default page then a corrected one');
|
|
230
|
+
assert.equal(h.q(0).limit, '25');
|
|
231
|
+
assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 13');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('a junk stored preference falls back to the default rather than to nothing', async () => {
|
|
235
|
+
// localStorage is reader-writable and survives across releases; a value the option
|
|
236
|
+
// list no longer offers must not become the page size.
|
|
237
|
+
const h = harness({ total: 320, perPagePref: 'lots' });
|
|
238
|
+
await tick(); await tick();
|
|
239
|
+
assert.equal(h.q(0).limit, '50');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('a failed page ROLLS BACK — the bar never claims a page whose rows never came', async () => {
|
|
243
|
+
// failAfter: 1 → the first load succeeds, every page click after it 500s.
|
|
244
|
+
const h = harness({ total: 320, failAfter: 1 });
|
|
245
|
+
await tick(); await tick();
|
|
246
|
+
assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 7');
|
|
247
|
+
|
|
248
|
+
h.getEl('sessions-next').fire('click');
|
|
249
|
+
await tick(); await tick();
|
|
250
|
+
assert.equal(h.searches().length, 2, 'it did try');
|
|
251
|
+
assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 7',
|
|
252
|
+
'the failed page 2 must not be left showing in the bar');
|
|
253
|
+
assert.equal(h.getEl('sessions-range').textContent, 'Showing 1–50 of 320',
|
|
254
|
+
'and the range must still describe the rows that are actually in the table');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('a failed per-page change rolls the SIZE back too', async () => {
|
|
258
|
+
const h = harness({ total: 320, failAfter: 1 });
|
|
259
|
+
await tick(); await tick();
|
|
260
|
+
h.getEl('sessions-per-page').fire('change', { target: { value: '10' } });
|
|
261
|
+
await tick(); await tick();
|
|
262
|
+
h.getEl('sessions-next').fire('click');
|
|
263
|
+
await tick(); await tick();
|
|
264
|
+
// Back on the 50-row page 1, the only page whose rows ever arrived, so Next asks
|
|
265
|
+
// for offset 50 and not offset 10.
|
|
266
|
+
assert.equal(h.q(-1).limit, '50', 'the rolled-back size is what the next request uses');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('nothing is pressable while a fetch is in flight', async () => {
|
|
270
|
+
const h = harness({ total: 320 });
|
|
271
|
+
await tick(); await tick();
|
|
272
|
+
assert.equal(h.getEl('sessions-per-page').disabled, false, 'idle: the control is live');
|
|
273
|
+
assert.equal(h.getEl('sessions-next').disabled, false);
|
|
274
|
+
h.getEl('sessions-next').fire('click');
|
|
275
|
+
// deliberately not awaited — this is mid-flight
|
|
276
|
+
assert.equal(h.getEl('sessions-next').disabled, true, 'in flight: Next is dead');
|
|
277
|
+
assert.equal(h.getEl('sessions-prev').disabled, true, 'in flight: Prev is dead');
|
|
278
|
+
assert.equal(h.getEl('sessions-per-page').disabled, true, 'in flight: per-page is dead');
|
|
279
|
+
await tick(); await tick();
|
|
280
|
+
assert.equal(h.getEl('sessions-per-page').disabled, false, 'landed: live again');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await test('Prev stays dead on page 1 — the kit decides that, not the page', async () => {
|
|
284
|
+
const h = harness({ total: 320 });
|
|
285
|
+
await tick(); await tick();
|
|
286
|
+
assert.equal(h.getEl('sessions-prev').disabled, true, 'page 1 has nothing before it');
|
|
287
|
+
assert.equal(h.getEl('sessions-next').disabled, false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await test('a corpus that fits one page shows no bar at all', async () => {
|
|
291
|
+
const h = harness({ total: 8 });
|
|
292
|
+
await tick(); await tick();
|
|
293
|
+
assert.equal(h.getEl('sessions-pager').hidden, true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await test('a missing kit degrades to a named failure, not a blank page', async () => {
|
|
297
|
+
// The kit is a separate <script>; a cache miss must not throw at parse time and take
|
|
298
|
+
// the whole page — including the rank gate — down with it.
|
|
299
|
+
const h = harness({ total: 320, dropKit: true });
|
|
300
|
+
await tick(); await tick();
|
|
301
|
+
assert.equal(h.searches().length, 0, 'no corpus request without a pager to place it');
|
|
302
|
+
assert.match(h.getEl('ledger-sub').textContent, /could not be loaded/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await test('the rank gate still runs before any corpus request', async () => {
|
|
306
|
+
const h = harness({ total: 320, viewerRank: 'xenos' });
|
|
307
|
+
await tick(); await tick();
|
|
308
|
+
assert.equal(h.searches().length, 0, 'a xenos must not even ask');
|
|
309
|
+
assert.equal(h.getEl('gate-scroll').hidden, false, 'they get the sealed notice');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
summary();
|