@bongos/core 1.19.864 → 1.19.866
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 +43 -28
- package/docs/copy-inventory.md +131 -104
- package/docs/copy-registry.json +410 -122
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/blockers.html +4 -1
- package/modules/hall-ui/public/blockers.js +283 -18
- package/modules/hall-ui/public/builders.js +2 -2
- package/modules/hall-ui/public/goal-inbox.js +6 -0
- package/modules/hall-ui/public/hall-render.js +50 -4
- package/modules/hall-ui/public/people.css +9 -0
- package/modules/hall-ui/public/roster.js +11 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task-detail.js +92 -3
- package/modules/lifecycle/routes/tasks.js +33 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/acclaim_feed_evidence_db.mjs +186 -0
- package/tests/blockers_hall.mjs +92 -0
- package/tests/blockers_page_ui.mjs +325 -0
- package/tests/hall_builders_page_pass.mjs +324 -0
- package/tests/helpers.mjs +13 -0
- package/tests/joinability_invite_only.mjs +11 -1
- package/tests/task_detail_ui.mjs +79 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// tests/blockers_page_ui.mjs — the /blockers queue page, EXECUTED (task
|
|
2
|
+
// 1003456, following tests/task_detail_ui.mjs's precedent).
|
|
3
|
+
//
|
|
4
|
+
// A ship-time grade on this task found two real bugs in blockers.js that a
|
|
5
|
+
// source-regex test (tests/blockers_hall.mjs) could not have caught, because
|
|
6
|
+
// both are about ORDER and STATE rather than presence:
|
|
7
|
+
// - a boot race: GET /me and GET /blockers fire together, and the row
|
|
8
|
+
// renderer reads the reviewer flag at RENDER time — if the queue rendered
|
|
9
|
+
// before the rank arrived, a Metic/Archon saw no Resolve/Link buttons
|
|
10
|
+
// until an unrelated re-render happened.
|
|
11
|
+
// - a CSS-family misuse (.ov-row--lead on a row with no lead column) that a
|
|
12
|
+
// regex cannot see is wrong without knowing what the class means.
|
|
13
|
+
// task_detail_ui.mjs's own header says it best: "browser code fails when a
|
|
14
|
+
// human clicks, and only a harness that clicks can see that." This is that
|
|
15
|
+
// harness for the sibling surface.
|
|
16
|
+
//
|
|
17
|
+
// Real hall-kit.js runs INSIDE the vm (it touches document/window at call
|
|
18
|
+
// time, so it must share the sandbox's stubs) — blockers-lib.js is pure and is
|
|
19
|
+
// require()'d directly, the same as tests/blockers_lib.mjs already trusts it.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/blockers_page_ui.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import vm from 'node:vm';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { makeRunner, makeEl, walk } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
33
|
+
const HALL_KIT_SRC = fs.readFileSync(path.join(HALL, 'hall-kit.js'), 'utf8');
|
|
34
|
+
const BLOCKERS_JS_SRC = fs.readFileSync(path.join(HALL, 'blockers.js'), 'utf8');
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const OTBBlockersLib = require(path.join(HALL, 'blockers-lib.js'));
|
|
38
|
+
|
|
39
|
+
const { test, summary } = makeRunner();
|
|
40
|
+
|
|
41
|
+
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
async function settle(extraMs = 0) {
|
|
43
|
+
if (extraMs) await tick(extraMs);
|
|
44
|
+
for (let i = 0; i < 30; i++) await tick(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_ROWS = [
|
|
48
|
+
{ id: 12, title: 'Need the owner’s AWS card on file', body_md: '', status: 'open', created_at: '2026-09-10T00:00:00.000Z', blocking_task_ids: [] },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function ok(data) { return { status: 200, ok: true, data }; }
|
|
52
|
+
function fail(status, code, message) { return { status, ok: false, data: { error: { code, message } } }; }
|
|
53
|
+
|
|
54
|
+
async function boot(opts = {}) {
|
|
55
|
+
const {
|
|
56
|
+
rank = 'archon',
|
|
57
|
+
rows = DEFAULT_ROWS,
|
|
58
|
+
meDelayMs = 0,
|
|
59
|
+
meFails = false,
|
|
60
|
+
blockersDelayMs = 0,
|
|
61
|
+
fileResponse = null, // null → default success; or a fail(...) envelope
|
|
62
|
+
linkResponse = null,
|
|
63
|
+
resolveResponse = null,
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
const requests = [];
|
|
67
|
+
const toasts = [];
|
|
68
|
+
const navigations = [];
|
|
69
|
+
const winListeners = {};
|
|
70
|
+
|
|
71
|
+
const api = {
|
|
72
|
+
async request(method, p, init) {
|
|
73
|
+
const body = (init && init.body) || {};
|
|
74
|
+
const url = p.replace('/api/bongos', '');
|
|
75
|
+
if (method === 'GET' && url === '/me') {
|
|
76
|
+
if (meDelayMs) await tick(meDelayMs);
|
|
77
|
+
requests.push({ method, path: p, body });
|
|
78
|
+
if (meFails) return fail(401, 'auth_needed', 'sign in');
|
|
79
|
+
return ok({ builder: { id: 1, rank } });
|
|
80
|
+
}
|
|
81
|
+
requests.push({ method, path: p, body });
|
|
82
|
+
if (method === 'POST' && url === '/blockers') {
|
|
83
|
+
return fileResponse || ok({ blocker: { id: 900, title: body.title, body_md: body.body_md, status: 'open', created_at: '2026-09-17T00:00:00.000Z', blocking_task_ids: [] } });
|
|
84
|
+
}
|
|
85
|
+
if (method === 'POST' && /^\/blockers\/\d+\/link$/.test(url)) {
|
|
86
|
+
return linkResponse || ok({ ok: true, blocked: true });
|
|
87
|
+
}
|
|
88
|
+
if (method === 'POST' && /^\/blockers\/\d+\/resolve$/.test(url)) {
|
|
89
|
+
return resolveResponse || ok({ blocker: { id: Number(url.split('/')[2]), status: 'resolved' } });
|
|
90
|
+
}
|
|
91
|
+
return ok({});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const locationObj = { hostname: 'builders.example.test', pathname: '/blockers', assign(u) { navigations.push(['assign', u]); } };
|
|
96
|
+
// ONE root holding the page's known containers as PERSISTENT children —
|
|
97
|
+
// getElementById WALKS it (the task-detail.js harness's own pattern), so an
|
|
98
|
+
// id introduced later by `container.innerHTML = '...'` (the file form, a
|
|
99
|
+
// row's inline resolve/link form) is found the same way a real querySelector
|
|
100
|
+
// would find it, rather than handing back a phantom detached element.
|
|
101
|
+
const root = makeEl('div');
|
|
102
|
+
function registerRoot(id) { const el = makeEl('div', { id }); root.appendChild(el); return el; }
|
|
103
|
+
registerRoot('blockers-body');
|
|
104
|
+
registerRoot('blockers-count');
|
|
105
|
+
registerRoot('blockers-sub');
|
|
106
|
+
registerRoot('blockers-file-area');
|
|
107
|
+
const documentObj = {
|
|
108
|
+
getElementById(id) { return walk(root, (el) => (el.id === id ? el : null)); },
|
|
109
|
+
};
|
|
110
|
+
const windowObj = {
|
|
111
|
+
OTB: { toast: (m) => toasts.push(m) },
|
|
112
|
+
OTBBlockersLib,
|
|
113
|
+
OTBTaskDetail: { href: (id) => '/task/' + id },
|
|
114
|
+
BongosClient: { createClient: () => api },
|
|
115
|
+
location: locationObj,
|
|
116
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
117
|
+
addEventListener(type, fn) { (winListeners[type] = winListeners[type] || []).push(fn); },
|
|
118
|
+
dispatchEvent(ev) { (winListeners[ev.type] || []).forEach((fn) => fn(ev)); return true; },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const sandbox = {
|
|
122
|
+
window: windowObj, document: documentObj, location: locationObj,
|
|
123
|
+
console, setTimeout, clearTimeout, Promise, Date, Math, JSON, Error,
|
|
124
|
+
Number, String, Object, Array, Boolean, Set, Map, RegExp, encodeURIComponent,
|
|
125
|
+
};
|
|
126
|
+
sandbox.globalThis = sandbox;
|
|
127
|
+
// fetch() is blockers.js's OWN call for GET /blockers — separate from the
|
|
128
|
+
// BongosClient api used for writes and /me, exactly as the real file does.
|
|
129
|
+
sandbox.fetch = async (url) => {
|
|
130
|
+
if (blockersDelayMs) await tick(blockersDelayMs);
|
|
131
|
+
requests.push({ method: 'GET', path: url });
|
|
132
|
+
return { ok: true, json: async () => ({ blockers: rows, page: { limit: 200, offset: 0, total: rows.length } }) };
|
|
133
|
+
};
|
|
134
|
+
vm.createContext(sandbox);
|
|
135
|
+
vm.runInContext(HALL_KIT_SRC, sandbox, { filename: 'hall-kit.js' });
|
|
136
|
+
vm.runInContext(BLOCKERS_JS_SRC, sandbox, { filename: 'blockers.js' });
|
|
137
|
+
await settle();
|
|
138
|
+
|
|
139
|
+
const body = () => documentObj.getElementById('blockers-body');
|
|
140
|
+
const fileArea = () => documentObj.getElementById('blockers-file-area');
|
|
141
|
+
const rowsOf = () => body().querySelectorAll('.ov-row');
|
|
142
|
+
const requestsTo = (method, re) => requests.filter((r) => r.method === method && re.test(r.path));
|
|
143
|
+
|
|
144
|
+
// The FILE form's open/cancel buttons and its own submit carry a DIRECT
|
|
145
|
+
// listener (wireFileForm addEventListener's each element itself). The
|
|
146
|
+
// per-row Resolve/Link open/cancel/submit are DELEGATED on #blockers-body
|
|
147
|
+
// (module-scope body.addEventListener) — makeEl's fire() does not bubble,
|
|
148
|
+
// so a delegated handler must be fired ON THE DELEGATE with the real
|
|
149
|
+
// target, exactly as a browser's own event dispatch would deliver it.
|
|
150
|
+
async function submitDirect(form) {
|
|
151
|
+
let prevented = false;
|
|
152
|
+
form.fire('submit', { preventDefault() { prevented = true; }, target: form });
|
|
153
|
+
await settle();
|
|
154
|
+
assert.equal(prevented, true, 'the submit handler did not execute');
|
|
155
|
+
}
|
|
156
|
+
async function submitDelegated(form) {
|
|
157
|
+
let prevented = false;
|
|
158
|
+
body().fire('submit', { preventDefault() { prevented = true; }, target: form });
|
|
159
|
+
await settle();
|
|
160
|
+
assert.equal(prevented, true, 'the delegated submit handler did not execute');
|
|
161
|
+
}
|
|
162
|
+
function clickDelegated(el) { body().fire('click', { target: el }); }
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
requests, requestsTo, toasts, navigations, settle,
|
|
166
|
+
body, fileArea, rowsOf,
|
|
167
|
+
fileOpenBtn: () => fileArea().querySelector('#blockers-file-open'),
|
|
168
|
+
fileForm: () => fileArea().querySelector('#blockers-file-form'),
|
|
169
|
+
submit: submitDirect,
|
|
170
|
+
submitRow: submitDelegated,
|
|
171
|
+
click: (el) => { el.fire('click', { target: el }); },
|
|
172
|
+
clickRow: clickDelegated,
|
|
173
|
+
dispatchWindowEvent: (type) => windowObj.dispatchEvent({ type }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ===========================================================================
|
|
178
|
+
// The boot race (the FIRST bug the grade found)
|
|
179
|
+
// ===========================================================================
|
|
180
|
+
|
|
181
|
+
await test('BUG REGRESSION: GET /blockers resolving BEFORE GET /me still ends with Resolve/Link offered to a reviewer', async () => {
|
|
182
|
+
const h = await boot({ rank: 'archon', blockersDelayMs: 0, meDelayMs: 15 });
|
|
183
|
+
const row = h.rowsOf()[0];
|
|
184
|
+
assert.ok(row, 'the row rendered');
|
|
185
|
+
assert.ok(row.querySelector('[data-resolve-open]'), 'once the viewer resolves, the row must re-render with the reviewer controls — this is exactly the boot race the grade found');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await test('the reverse order (viewer known first) already worked, and still does', async () => {
|
|
189
|
+
const h = await boot({ rank: 'archon', blockersDelayMs: 15, meDelayMs: 0 });
|
|
190
|
+
const row = h.rowsOf()[0];
|
|
191
|
+
assert.ok(row.querySelector('[data-resolve-open]'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('a non-reviewer sees the row with no Resolve/Link controls at all', async () => {
|
|
195
|
+
const h = await boot({ rank: 'xenos' });
|
|
196
|
+
const row = h.rowsOf()[0];
|
|
197
|
+
assert.equal(row.querySelector('[data-resolve-open]'), null);
|
|
198
|
+
assert.equal(row.querySelector('[data-link-open]'), null);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('a signed-out visitor (GET /me fails) sees the queue with no reviewer controls, not an error', async () => {
|
|
202
|
+
const h = await boot({ meFails: true });
|
|
203
|
+
const row = h.rowsOf()[0];
|
|
204
|
+
assert.equal(row.querySelector('[data-resolve-open]'), null);
|
|
205
|
+
assert.ok(h.fileOpenBtn(), 'the File button still renders — filing needs only a sign-in the write itself will demand');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// Filing — any signed-in builder, and the file+link one-action flow
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
|
|
212
|
+
await test('filing alone (no task #) posts once and toasts the filed id', async () => {
|
|
213
|
+
const h = await boot({ rank: 'xenos' });
|
|
214
|
+
h.click(h.fileOpenBtn());
|
|
215
|
+
const form = h.fileForm();
|
|
216
|
+
form.querySelector('#bf-title').value = 'Need a decision';
|
|
217
|
+
await h.submit(form);
|
|
218
|
+
const posts = h.requestsTo('POST', /\/blockers$/);
|
|
219
|
+
assert.equal(posts.length, 1);
|
|
220
|
+
assert.equal(posts[0].body.title, 'Need a decision');
|
|
221
|
+
assert.ok(h.toasts.some((t) => /Blocker #900 filed\.$/.test(t)));
|
|
222
|
+
assert.equal(h.requestsTo('POST', /\/link$/).length, 0, 'no task # given — no link attempted');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('filing WITH a task # does both in the one submit, and toasts success', async () => {
|
|
226
|
+
const h = await boot({ rank: 'xenos' });
|
|
227
|
+
h.click(h.fileOpenBtn());
|
|
228
|
+
const form = h.fileForm();
|
|
229
|
+
form.querySelector('#bf-title').value = 'Need a decision';
|
|
230
|
+
form.querySelector('#bf-task-id').value = '5001';
|
|
231
|
+
await h.submit(form);
|
|
232
|
+
const linkPosts = h.requestsTo('POST', /\/blockers\/900\/link$/);
|
|
233
|
+
assert.equal(linkPosts.length, 1);
|
|
234
|
+
assert.equal(linkPosts[0].body.task_id, 5001);
|
|
235
|
+
assert.ok(h.toasts.some((t) => /filed and linked to task #5001\./.test(t)));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await test('a non-reviewer filer who names a task the link 403s on still gets an HONEST message — filing is not a silent no-op', async () => {
|
|
239
|
+
const h = await boot({ rank: 'xenos', linkResponse: fail(403, 'permission_forbidden', 'nope') });
|
|
240
|
+
h.click(h.fileOpenBtn());
|
|
241
|
+
const form = h.fileForm();
|
|
242
|
+
form.querySelector('#bf-title').value = 'Need a decision';
|
|
243
|
+
form.querySelector('#bf-task-id').value = '5001';
|
|
244
|
+
await h.submit(form);
|
|
245
|
+
assert.ok(h.toasts.some((t) => t === 'Blocker #900 filed. You need Metic+ access to do that.'),
|
|
246
|
+
`expected the honest fallback, got: ${JSON.stringify(h.toasts)}`);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await test('a deduped (skipped) file with a task # typed says the link was NOT attempted', async () => {
|
|
250
|
+
const h = await boot({ fileResponse: ok({ skipped: true, reason: 'duplicate_source_ref' }) });
|
|
251
|
+
h.click(h.fileOpenBtn());
|
|
252
|
+
const form = h.fileForm();
|
|
253
|
+
form.querySelector('#bf-title').value = 'Need a decision';
|
|
254
|
+
form.querySelector('#bf-task-id').value = '5001';
|
|
255
|
+
await h.submit(form);
|
|
256
|
+
assert.equal(h.requestsTo('POST', /\/link$/).length, 0, 'no blocker id came back — nothing to link');
|
|
257
|
+
assert.ok(h.toasts.some((t) => /already filed\. It was NOT linked to task #5001/.test(t)),
|
|
258
|
+
`expected the honest skip+link message, got: ${JSON.stringify(h.toasts)}`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await test('an empty title is refused in the browser before a doomed request', async () => {
|
|
262
|
+
const h = await boot();
|
|
263
|
+
h.click(h.fileOpenBtn());
|
|
264
|
+
const form = h.fileForm();
|
|
265
|
+
await h.submit(form);
|
|
266
|
+
assert.equal(h.requestsTo('POST', /\/blockers$/).length, 0);
|
|
267
|
+
assert.equal(form.querySelector('#bf-err').textContent, 'A title is required.');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ===========================================================================
|
|
271
|
+
// Resolve / Link — reviewer-only, cosmetic mirror of blocker.review
|
|
272
|
+
// ===========================================================================
|
|
273
|
+
|
|
274
|
+
await test('a reviewer resolving a row posts to the real route, toasts, and reloads the queue', async () => {
|
|
275
|
+
const h = await boot({ rank: 'metic' });
|
|
276
|
+
h.clickRow(h.rowsOf()[0].querySelector('[data-resolve-open]'));
|
|
277
|
+
await h.settle();
|
|
278
|
+
// The click's own rerender() replaced the row's markup — re-query from the
|
|
279
|
+
// (stable) delegate rather than the now-detached `row` reference.
|
|
280
|
+
const form = h.body().querySelector('[data-resolve-form]');
|
|
281
|
+
assert.ok(form, 'the resolve form opened');
|
|
282
|
+
form.note.value = 'raised the budget';
|
|
283
|
+
await h.submitRow(form);
|
|
284
|
+
const posts = h.requestsTo('POST', /\/blockers\/12\/resolve$/);
|
|
285
|
+
assert.equal(posts.length, 1);
|
|
286
|
+
assert.equal(posts[0].body.resolution_note, 'raised the budget');
|
|
287
|
+
assert.ok(h.toasts.some((t) => t === 'Blocker #12 resolved.'));
|
|
288
|
+
// reload() re-fetches — a second GET /blockers beyond the initial boot fetch.
|
|
289
|
+
assert.ok(h.requestsTo('GET', /\/blockers\?limit=200$/).length >= 2, 'the queue reloads after a mutation');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await test('a reviewer linking a row posts task_id as a NUMBER', async () => {
|
|
293
|
+
const h = await boot({ rank: 'metic' });
|
|
294
|
+
h.clickRow(h.rowsOf()[0].querySelector('[data-link-open]'));
|
|
295
|
+
await h.settle();
|
|
296
|
+
const form = h.body().querySelector('[data-link-form]');
|
|
297
|
+
form.taskId.value = '777';
|
|
298
|
+
await h.submitRow(form);
|
|
299
|
+
const posts = h.requestsTo('POST', /\/blockers\/12\/link$/);
|
|
300
|
+
assert.equal(posts.length, 1);
|
|
301
|
+
assert.strictEqual(posts[0].body.task_id, 777, 'a string field value must be coerced to Number, not sent as text');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await test('a 403 on resolve renders HONEST copy and leaves the form open — never a silent no-op', async () => {
|
|
305
|
+
const h = await boot({ rank: 'metic', resolveResponse: fail(403, 'permission_forbidden', 'nope') });
|
|
306
|
+
h.clickRow(h.rowsOf()[0].querySelector('[data-resolve-open]'));
|
|
307
|
+
await h.settle();
|
|
308
|
+
const form = h.body().querySelector('[data-resolve-form]');
|
|
309
|
+
await h.submitRow(form);
|
|
310
|
+
assert.equal(form.querySelector('[data-err]').textContent, 'You need Metic+ access to do that.');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await test('resolving through the shared #/blocker/N overlay (otb:blocker-resolved) reloads this page\'s queue too', async () => {
|
|
314
|
+
const h = await boot({ rank: 'metic' });
|
|
315
|
+
const before = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
|
|
316
|
+
// Simulate task-detail.js's blockerPanel firing the event on THIS window —
|
|
317
|
+
// the SAME event the overlay dispatches when a builder resolves a blocker
|
|
318
|
+
// from the deep-link card opened on top of this page.
|
|
319
|
+
h.dispatchWindowEvent('otb:blocker-resolved');
|
|
320
|
+
await h.settle();
|
|
321
|
+
const after = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
|
|
322
|
+
assert.equal(after, before + 1, 'the queue re-fetched exactly once in response to the event');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
summary();
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// tests/hall_builders_page_pass.mjs — the three parts of task 1004004, each
|
|
2
|
+
// pinned by RUNNING the shipped renderer rather than reading it.
|
|
3
|
+
//
|
|
4
|
+
// The three were filed separately (1002851, 1002837, 1002835) and consolidated
|
|
5
|
+
// because their touches[] overlap serialised them anyway. What they share is one
|
|
6
|
+
// failure mode: a fact the hall already holds was rendered as a footnote, as
|
|
7
|
+
// nothing at all, or not rendered at all, so the reader had to go and look it up
|
|
8
|
+
// somewhere else.
|
|
9
|
+
//
|
|
10
|
+
// A (1002851) — the signed-out landing. Signing in was a footnote LINK, but
|
|
11
|
+
// only on the `apply` door; `open` and `invite_only` already render a real
|
|
12
|
+
// .landing__btn inside the primary door. So the button belongs on `apply`
|
|
13
|
+
// alone — adding one to all three would have made a third scattered path.
|
|
14
|
+
// These tests hold BOTH halves: the button appears on `apply`, and the two
|
|
15
|
+
// doors that already had one do NOT grow a second.
|
|
16
|
+
// B (1002837) — the roster's place cell. A builder's number is the id every
|
|
17
|
+
// task row and ship note cites, and the roster was the one place that knew
|
|
18
|
+
// who the builder was and didn't print it. Degrades to the place alone when
|
|
19
|
+
// the payload carries no id.
|
|
20
|
+
// C (1002835) — the peer-acclaim feed. A karma vote was cast on a title. The
|
|
21
|
+
// grade and the cost now ride along, and ABSENCE RENDERS AS ABSENCE: an
|
|
22
|
+
// ungraded ship says "not graded" and an unlogged cost says "no cost
|
|
23
|
+
// logged", because 0.0 and $0.00 are measurements that were never taken.
|
|
24
|
+
//
|
|
25
|
+
// Pure vm + file-read (no DB, no network) — runs in the DB-free unit gate.
|
|
26
|
+
import test from 'node:test';
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import vm from 'node:vm';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
35
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
|
|
36
|
+
|
|
37
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => (
|
|
38
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
// Count real sign-in CONTROLS (a .landing__btn), as opposed to prose links.
|
|
42
|
+
const countButtons = (html) => (html.match(/class="[^"]*landing__btn[^"]*"/g) || []).length;
|
|
43
|
+
|
|
44
|
+
// =========================================================================
|
|
45
|
+
// PART A — the returning builder's door (task 1002851)
|
|
46
|
+
// =========================================================================
|
|
47
|
+
|
|
48
|
+
// Render the signed-out landing for one joinability mode and hand back the HTML
|
|
49
|
+
// the page actually wrote. Runs the shipped hall-render.js in a DOM stub thin
|
|
50
|
+
// enough that anything renderLanding touches beyond the landing root would
|
|
51
|
+
// throw rather than pass silently.
|
|
52
|
+
function hallRender(joinability) {
|
|
53
|
+
let written = '';
|
|
54
|
+
const el = (extra = {}) => ({
|
|
55
|
+
hidden: false,
|
|
56
|
+
textContent: '',
|
|
57
|
+
contains() { return false; },
|
|
58
|
+
addEventListener() {},
|
|
59
|
+
set innerHTML(v) { written = v; },
|
|
60
|
+
get innerHTML() { return written; },
|
|
61
|
+
...extra,
|
|
62
|
+
});
|
|
63
|
+
const nodes = { '#landing-root': el(), '#welcome-line': el() };
|
|
64
|
+
|
|
65
|
+
const sandbox = {
|
|
66
|
+
window: {
|
|
67
|
+
OTB: {
|
|
68
|
+
escapeHtml,
|
|
69
|
+
$: (sel) => nodes[sel] || null,
|
|
70
|
+
fmtNum: (n) => String(n),
|
|
71
|
+
moduleOn: () => true,
|
|
72
|
+
},
|
|
73
|
+
OTBShell: { applyAccess() {} },
|
|
74
|
+
// hall-render builds its pagers at factory time; the landing uses none.
|
|
75
|
+
OTBKit: { makePager: () => ({ page: 1 }), pagerBarHtml: () => '' },
|
|
76
|
+
__BRANDING__: joinability ? { project: { joinability } } : {},
|
|
77
|
+
},
|
|
78
|
+
document: { querySelectorAll: () => [] },
|
|
79
|
+
console,
|
|
80
|
+
};
|
|
81
|
+
vm.createContext(sandbox);
|
|
82
|
+
vm.runInContext(read('hall-render.js'), sandbox, { filename: 'hall-render.js' });
|
|
83
|
+
|
|
84
|
+
const hall = sandbox.window.OTBHallRender({
|
|
85
|
+
API: '/api/bongos',
|
|
86
|
+
RANK_ORDER: { xenos: 1, thetes: 2, metic: 3, archon: 4 },
|
|
87
|
+
api: { request: () => Promise.resolve({ status: 200, ok: true, data: {} }) },
|
|
88
|
+
brandWorld: () => 'Testworld',
|
|
89
|
+
curLabel: () => 'Credits',
|
|
90
|
+
emptyRow: () => '',
|
|
91
|
+
fmtDate: () => '',
|
|
92
|
+
mins: () => '',
|
|
93
|
+
normalizeRank: (r) => r,
|
|
94
|
+
pct: () => 0,
|
|
95
|
+
postJSON: () => Promise.resolve({}),
|
|
96
|
+
rankLabel: (r) => r,
|
|
97
|
+
rankWeight: () => 0,
|
|
98
|
+
signClasses: () => '',
|
|
99
|
+
toast: () => {},
|
|
100
|
+
usd: (n) => `$${n}`,
|
|
101
|
+
});
|
|
102
|
+
return { hall, html: () => written };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Render the signed-out landing for one joinability mode and hand back the
|
|
106
|
+
// HTML the page actually wrote.
|
|
107
|
+
function renderLandingHtml(joinability) {
|
|
108
|
+
const { hall, html } = hallRender(joinability);
|
|
109
|
+
hall.renderLanding();
|
|
110
|
+
return html();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test('A: the apply door gives signing in a real button, not a footnote link', () => {
|
|
114
|
+
const html = renderLandingHtml('apply');
|
|
115
|
+
assert.match(html, /landing__returning/, 'the apply door renders a returning-builder block');
|
|
116
|
+
assert.match(
|
|
117
|
+
html,
|
|
118
|
+
/class="landing__btn landing__btn--quiet" href="\/api\/bongos\/auth\/web\/start"/,
|
|
119
|
+
'that block carries a real sign-in control in the quiet fill',
|
|
120
|
+
);
|
|
121
|
+
// The whole point of the change: the footnote link is GONE from this door, so
|
|
122
|
+
// there is exactly one sign-in path and it is the button.
|
|
123
|
+
assert.ok(
|
|
124
|
+
!/Already a member\? <a /.test(html),
|
|
125
|
+
'the apply door no longer repeats sign-in as a prose link under the form',
|
|
126
|
+
);
|
|
127
|
+
assert.match(html, /We store only your GitHub login/, 'the privacy sentence survives the note');
|
|
128
|
+
// Two controls total: request access, and sign in. Not three.
|
|
129
|
+
assert.equal(countButtons(html), 2, 'the apply door carries the request button and the sign-in button');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('A: the two doors that already had a sign-in button do not grow a second', () => {
|
|
133
|
+
for (const mode of ['open', 'invite_only']) {
|
|
134
|
+
const html = renderLandingHtml(mode);
|
|
135
|
+
assert.ok(
|
|
136
|
+
!/landing__returning/.test(html),
|
|
137
|
+
`the ${mode} door does not add a returning block — its primary door already signs you in`,
|
|
138
|
+
);
|
|
139
|
+
assert.equal(countButtons(html), 1, `the ${mode} door carries exactly one sign-in control`);
|
|
140
|
+
assert.match(html, /Already a member\? <a /, `the ${mode} door keeps its prose note for returning builders`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('A: an older branding pack with no joinability falls back to the apply door', () => {
|
|
145
|
+
// joinabilityMode() treats anything but open/invite_only as 'apply', so the
|
|
146
|
+
// button must follow that fallback — not sit behind a field an older pack
|
|
147
|
+
// never sent.
|
|
148
|
+
assert.match(renderLandingHtml(null), /landing__returning/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('A: the quiet fill is a defined variant, not an inline colour', () => {
|
|
152
|
+
const css = read('style.css');
|
|
153
|
+
const i = css.indexOf('\n.landing__btn--quiet {');
|
|
154
|
+
assert.ok(i > 0, 'style.css defines .landing__btn--quiet');
|
|
155
|
+
const block = css.slice(i, css.indexOf('}', i));
|
|
156
|
+
assert.deepEqual(block.match(/#[0-9a-f]{3,8}\b/gi) || [], [], 'the variant carries no literal colour');
|
|
157
|
+
// Same geometry as the request button means it inherits .landing__btn's
|
|
158
|
+
// min-height and padding rather than restating them.
|
|
159
|
+
assert.ok(!/min-height|padding/.test(block), 'the variant overrides fill only, never geometry');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// =========================================================================
|
|
163
|
+
// PART B — the builder's number on the roster (task 1002837)
|
|
164
|
+
// =========================================================================
|
|
165
|
+
|
|
166
|
+
// roster.js is an IIFE that fetches and mounts. Run it against a stubbed kit,
|
|
167
|
+
// capture the rowHtml it hands makeLedger, and call the real function.
|
|
168
|
+
async function rosterRowHtml(rows) {
|
|
169
|
+
let rowFn = null;
|
|
170
|
+
const elStub = { innerHTML: '', className: '', textContent: '', hidden: false };
|
|
171
|
+
const sandbox = {
|
|
172
|
+
window: {
|
|
173
|
+
OTB: { fmtInt: (n) => String(n == null ? 0 : n), rankLabel: (r) => r },
|
|
174
|
+
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
175
|
+
OTBKit: {
|
|
176
|
+
esc: escapeHtml,
|
|
177
|
+
makePager: () => ({ page: 1 }),
|
|
178
|
+
makeFilterBar: () => {},
|
|
179
|
+
makeLedger: (cfg) => { rowFn = cfg.rowHtml; return { render() {} }; },
|
|
180
|
+
paginate: (xs) => xs,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
document: { getElementById: () => ({ ...elStub }) },
|
|
184
|
+
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({ leaderboard: rows }) }),
|
|
185
|
+
console,
|
|
186
|
+
};
|
|
187
|
+
vm.createContext(sandbox);
|
|
188
|
+
vm.runInContext(read('roster.js'), sandbox, { filename: 'roster.js' });
|
|
189
|
+
// Let the fetch promise chain drain.
|
|
190
|
+
await new Promise((r) => setImmediate(r));
|
|
191
|
+
assert.ok(rowFn, 'roster.js mounted a ledger and handed it a row renderer');
|
|
192
|
+
return rowFn;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ROSTER_ROWS = [
|
|
196
|
+
{ id: '1000025', github_login: 'someone', display_name: 'Someone', rank: 'metic', total_credits: 900, karma: 5, tokens_used: 10 },
|
|
197
|
+
{ github_login: 'nameless', display_name: 'Nameless', rank: 'xenos', total_credits: 10, karma: 0, tokens_used: 1 },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
test('B: the roster prints the builder\'s number under their place', async () => {
|
|
201
|
+
const rowHtml = await rosterRowHtml(ROSTER_ROWS);
|
|
202
|
+
const html = rowHtml({ ...ROSTER_ROWS[0], __place: 1 });
|
|
203
|
+
assert.match(html, /<span class="ledger__id">#1000025<\/span>/, 'the id renders');
|
|
204
|
+
// Under the place, not beside the name — the place cell is what answers
|
|
205
|
+
// "which builder is this row".
|
|
206
|
+
const cell = html.slice(0, html.indexOf('</td>'));
|
|
207
|
+
assert.match(cell, /ledger__place/, 'the place cell is the one that carries it');
|
|
208
|
+
assert.match(cell, /1[\s\S]*ledger__id/, 'the place still comes first');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('B: a payload with no id degrades to the place alone', async () => {
|
|
212
|
+
const rowHtml = await rosterRowHtml(ROSTER_ROWS);
|
|
213
|
+
const html = rowHtml({ ...ROSTER_ROWS[1], __place: 2 });
|
|
214
|
+
assert.ok(!/ledger__id/.test(html), 'no empty "#" is printed for a row the server sent no id for');
|
|
215
|
+
assert.match(html, /<td class="ledger__num ledger__place">2<\/td>/, 'the place renders on its own');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('B: the id wears the same mono/faint voice as the login', () => {
|
|
219
|
+
const css = read('people.css');
|
|
220
|
+
const i = css.indexOf('.ledger__place .ledger__id {');
|
|
221
|
+
assert.ok(i > 0, 'people.css styles the roster id');
|
|
222
|
+
const block = css.slice(i, css.indexOf('}', i));
|
|
223
|
+
for (const token of ['var(--font-mono)', 'var(--ink-faint)']) {
|
|
224
|
+
assert.ok(block.includes(token), `the id spends ${token}, the voice .dir-login already uses`);
|
|
225
|
+
}
|
|
226
|
+
assert.match(block, /display: block/, 'it sits under the place rather than wrapping beside it');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// =========================================================================
|
|
230
|
+
// PART C — grade and cost on the peer-acclaim feed (task 1002835)
|
|
231
|
+
// =========================================================================
|
|
232
|
+
|
|
233
|
+
// acclaimEvidenceHtml is a view painter, so it lives in the hall-render carve
|
|
234
|
+
// (the same reason renderLanding does) and builders.js takes it from the
|
|
235
|
+
// factory. Read it off the same factory rather than re-deriving it.
|
|
236
|
+
const acclaimEvidence = () => hallRender().hall.acclaimEvidenceHtml;
|
|
237
|
+
test('C: a graded, costed ship carries both numbers', () => {
|
|
238
|
+
const html = acclaimEvidence()({ grade_score: '8.5', grade_passed: true, cost_usd: '12.3456' });
|
|
239
|
+
assert.match(html, /ledger__verdict--ok">8\.5 \/ 10</, 'a passing grade reads as passing');
|
|
240
|
+
assert.match(html, /acclaim__cost">\$12\.35</, 'the cost rounds to cents');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('C: a failing grade is not dressed as a passing one', () => {
|
|
244
|
+
const html = acclaimEvidence()({ grade_score: '3.0', grade_passed: false, cost_usd: '1' });
|
|
245
|
+
assert.match(html, /ledger__verdict--danger">3\.0 \/ 10</);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('C: absence renders as absence, never as zero', () => {
|
|
249
|
+
const ev = acclaimEvidence();
|
|
250
|
+
// The case that matters: a grader outage arrives as a NULL score (the route
|
|
251
|
+
// nulls it through gradeQualityWhere), and a voter must not read "0.0 / 10".
|
|
252
|
+
const ungraded = ev({ grade_score: null, grade_passed: null, cost_usd: '4' });
|
|
253
|
+
assert.match(ungraded, /ledger__verdict--none">not graded</);
|
|
254
|
+
assert.ok(!/0\.0 \/ 10/.test(ungraded), 'an ungraded ship is never scored 0.0');
|
|
255
|
+
|
|
256
|
+
// cost_usd PRESENT and null is the "logged nothing" case, distinct from the
|
|
257
|
+
// key being absent entirely (covered above) — both must not print $0.00.
|
|
258
|
+
const uncosted = ev({ grade_score: '9.0', grade_passed: true, cost_usd: null });
|
|
259
|
+
assert.match(uncosted, /ledger__verdict--none">no cost logged</);
|
|
260
|
+
assert.ok(!/\$0\.00/.test(uncosted), 'an unlogged cost is never printed as free');
|
|
261
|
+
|
|
262
|
+
// A genuine zero is still a measurement and must survive.
|
|
263
|
+
assert.match(ev({ grade_score: '0.0', grade_passed: false, cost_usd: '0' }), /0\.0 \/ 10/);
|
|
264
|
+
assert.match(ev({ grade_score: '0.0', grade_passed: false, cost_usd: '0' }), /\$0\.00/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('C: the evidence line hangs under the work, in the feed row', () => {
|
|
268
|
+
const src = read('builders.js');
|
|
269
|
+
assert.match(
|
|
270
|
+
src,
|
|
271
|
+
/<td>\$\{title\}\$\{acclaimEvidenceHtml\(t\)\}<\/td>/,
|
|
272
|
+
'the row renders it beneath the title rather than adding a column',
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('C: a builder who may not vote is shown no cost figure at all', () => {
|
|
277
|
+
// The route OMITS cost_usd below Metic rather than nulling it, because a null
|
|
278
|
+
// means "nothing was logged" — telling a reader a figure is missing is a
|
|
279
|
+
// different (and false) statement from showing them no figure.
|
|
280
|
+
const html = acclaimEvidence()({ grade_score: '8.0', grade_passed: true });
|
|
281
|
+
assert.match(html, /8\.0 \/ 10/, 'the grade is a quality verdict and still shows');
|
|
282
|
+
assert.ok(!/no cost logged/.test(html), 'a withheld cost is never reported as an unlogged one');
|
|
283
|
+
assert.ok(!/acclaim__cost/.test(html), 'and no figure is printed');
|
|
284
|
+
assert.ok(!/ · /.test(html), 'the separator goes with the part it separated');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('C: the route hands the cost only to the ranks that can vote', () => {
|
|
288
|
+
const route = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
289
|
+
const i = route.indexOf("router.get('/tasks/peer-votes/feed'");
|
|
290
|
+
const handler = route.slice(i, route.indexOf('// POST /tasks/peer-votes/tally', i));
|
|
291
|
+
assert.match(
|
|
292
|
+
handler,
|
|
293
|
+
/const tasks = canVote \? rows : rows\.map\(\(\{ cost_usd, \.\.\.rest \}\) => rest\);/,
|
|
294
|
+
'the cost is stripped for a non-voter, by omission rather than by nulling',
|
|
295
|
+
);
|
|
296
|
+
assert.match(handler, /res\.json\(\{ tasks, can_vote: canVote \}\)/, 'and the gated rows are what is sent');
|
|
297
|
+
assert.ok(!/grade_score, \.\.\.rest/.test(handler), 'the grade is not spend and is not gated with it');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('C: the route supplies both fields, and nulls a non-verdict grade', () => {
|
|
301
|
+
const route = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
302
|
+
const i = route.indexOf("router.get('/tasks/peer-votes/feed'");
|
|
303
|
+
assert.ok(i > 0, 'the feed route is still here');
|
|
304
|
+
const handler = route.slice(i, route.indexOf('// POST /tasks/peer-votes/tally', i));
|
|
305
|
+
for (const field of ['grade_score', 'grade_passed', 'cost_usd']) {
|
|
306
|
+
assert.ok(handler.includes(`AS ${field}`), `the feed selects ${field}`);
|
|
307
|
+
}
|
|
308
|
+
// The honesty half is server-side: a grader outage carries score 0 in
|
|
309
|
+
// task_grades, and only gradeQualityWhere tells it apart from a real 0.
|
|
310
|
+
assert.match(handler, /gradeQualityWhere\('g'\)/, 'an outage row is nulled, not passed through as 0');
|
|
311
|
+
// SUM over no rows is NULL, which is the "no cost logged" the client renders.
|
|
312
|
+
// A COALESCE here would turn every uncosted ship into a free one.
|
|
313
|
+
assert.ok(
|
|
314
|
+
!/COALESCE\(SUM\(amount_usd\)/.test(handler),
|
|
315
|
+
'the cost sum is left NULL when nothing was logged',
|
|
316
|
+
);
|
|
317
|
+
// Both joins are indexed lookups. Pinned here so a future reader does not have
|
|
318
|
+
// to re-derive it: task_grades.task_id is UNIQUE and cost_log.task_id carries
|
|
319
|
+
// idx_cost_log_task. If either ever stops being true, this reds.
|
|
320
|
+
const g016 = fs.readFileSync(path.join(ROOT, 'migrations/016_pms_task_grades.sql'), 'utf8');
|
|
321
|
+
assert.match(g016, /task_id\s+BIGINT NOT NULL UNIQUE/, 'task_grades.task_id is unique, so indexed');
|
|
322
|
+
const m003 = fs.readFileSync(path.join(ROOT, 'migrations/003_pms.sql'), 'utf8');
|
|
323
|
+
assert.match(m003, /CREATE INDEX IF NOT EXISTS idx_cost_log_task ON cost_log \(task_id\)/, 'cost_log.task_id is indexed');
|
|
324
|
+
});
|