@bongos/core 1.19.346 → 1.19.348
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 +57 -22
- package/docs/copy-inventory.md +49 -37
- package/docs/copy-registry.json +170 -42
- package/modules/hall-ui/public/collab-lib.js +77 -0
- package/modules/hall-ui/public/collab.css +36 -0
- package/modules/hall-ui/public/collab.html +66 -0
- package/modules/hall-ui/public/collab.js +139 -0
- package/modules/hall-ui/public/idea.js +9 -1
- package/modules/hall-ui/public/ideas.js +17 -5
- package/modules/hall-ui/public/shell.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/client-baseurl-guard.js +116 -0
- package/scripts/gds/deploy-divergence.js +34 -3
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/update-channel.js +121 -5
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +5 -1
- package/tests/client_baseurl_guard.mjs +122 -0
- package/tests/collab_page.mjs +172 -0
- package/tests/hall_nav.mjs +7 -1
- package/tests/idea_hall_composer.mjs +11 -0
- package/tests/update_channel.mjs +125 -0
|
@@ -32,6 +32,23 @@ const DEFAULT_CHANNEL = 'patch';
|
|
|
32
32
|
// core template — a fresh instance subscribes nothing until the operator lists a target here.
|
|
33
33
|
const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
|
|
34
34
|
|
|
35
|
+
// Why a roster came back the way it did. The routine reports this EVERY run: an upgrader whose only
|
|
36
|
+
// job is keeping instances current must never be indistinguishable from one that has stopped. Before
|
|
37
|
+
// task 1003158 an absent manifest, a malformed manifest and a deliberately empty roster all produced
|
|
38
|
+
// the same silent `instances: []`, which is exactly how cloudbongos.com sat 36 releases behind for
|
|
39
|
+
// days with nothing complaining (the same failure SHAPE as blocker 1000097 / task 1002521).
|
|
40
|
+
const ROSTER_OK = 'ok'; // at least one usable entry
|
|
41
|
+
const ROSTER_ABSENT = 'manifest-absent'; // no file at all
|
|
42
|
+
const ROSTER_MALFORMED = 'manifest-malformed';// file present, JSON unparseable
|
|
43
|
+
const ROSTER_EMPTY = 'roster-empty'; // file parsed, nothing subscribable in it
|
|
44
|
+
const ROSTER_REASONS = [ROSTER_OK, ROSTER_ABSENT, ROSTER_MALFORMED, ROSTER_EMPTY];
|
|
45
|
+
|
|
46
|
+
// How many releases a subscribed instance may fall behind before "the upgrader stopped" is the only
|
|
47
|
+
// remaining explanation. A successful sweep takes the HIGHEST permitted version in one jump, so a
|
|
48
|
+
// healthy roster returns to 0 behind after every run — a backlog that keeps growing means no run is
|
|
49
|
+
// landing. 5 leaves room for a busy publish-on-merge day between two daily sweeps without crying wolf.
|
|
50
|
+
const DEFAULT_STALENESS_THRESHOLD = 5;
|
|
51
|
+
|
|
35
52
|
// ---- semver (stable x.y.z, optional prerelease tag) -------------------------
|
|
36
53
|
// A deliberately small parser — the repo avoids an npm-semver dependency in the CLI path (see
|
|
37
54
|
// upgrade.js versionFromDep). We only need compare + the major/minor/patch fields.
|
|
@@ -143,16 +160,23 @@ function parseNpmViewVersions(stdout) {
|
|
|
143
160
|
// this control plane keeps up to date. Never throws: a missing/malformed file ⇒ an empty roster
|
|
144
161
|
// (subscribe nothing), because an unreadable manifest must not upgrade anything. Each entry is
|
|
145
162
|
// normalized to { slug, dir, service, healthUrl, channel } with the channel validated + defaulted.
|
|
163
|
+
//
|
|
164
|
+
// Still fails CLOSED, no longer fails QUIET (task 1003158): every return carries a `reason` from
|
|
165
|
+
// ROSTER_REASONS and a `dropped` count of entries that were unusable, so a caller can say WHY it is
|
|
166
|
+
// about to do nothing. Pair it with describeRoster() for the line to print.
|
|
146
167
|
function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SUBSCRIPTIONS_REL } = {}) {
|
|
147
168
|
const abs = path.isAbsolute(file) ? file : path.join(instanceDir, file);
|
|
148
169
|
let raw;
|
|
149
|
-
try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false }; }
|
|
170
|
+
try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false, dropped: 0, reason: ROSTER_ABSENT }; }
|
|
150
171
|
let json;
|
|
151
|
-
try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, error: `malformed JSON: ${e.message}
|
|
172
|
+
try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, dropped: 0, error: `malformed JSON: ${e.message}`, reason: ROSTER_MALFORMED }; }
|
|
152
173
|
const defaultChannel = normalizeChannel(json.defaultChannel);
|
|
153
174
|
const rows = Array.isArray(json.instances) ? json.instances : [];
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
// An entry without a dir can't be upgraded — drop it, but COUNT the drops. Silently discarding an
|
|
176
|
+
// operator's malformed row is how an instance believes it is enrolled while nothing ever touches it.
|
|
177
|
+
const usable = rows.filter((r) => r && typeof r === 'object' && r.dir);
|
|
178
|
+
const dropped = rows.length - usable.length;
|
|
179
|
+
const instances = usable
|
|
156
180
|
.map((r) => ({
|
|
157
181
|
slug: String(r.slug || r.dir),
|
|
158
182
|
dir: String(r.dir),
|
|
@@ -178,7 +202,90 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
|
|
|
178
202
|
? Object.fromEntries(Object.entries(r.env).filter(([, v]) => typeof v === 'string'))
|
|
179
203
|
: {},
|
|
180
204
|
}));
|
|
181
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
defaultChannel, instances, source: abs, present: true, dropped,
|
|
207
|
+
reason: instances.length ? ROSTER_OK : ROSTER_EMPTY,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- reporting: make "nothing happened" audible -----------------------------
|
|
212
|
+
|
|
213
|
+
// Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
|
|
214
|
+
// can act on ('ok' | 'warn' | 'alarm'). The judgement that matters is `controlPlane`: a fresh
|
|
215
|
+
// instance subscribing nothing is the shipped default (ADR 0136 — the manifest ships empty), but a
|
|
216
|
+
// CONTROL PLANE that subscribes nothing has silently stopped doing its only job, and that is an alarm.
|
|
217
|
+
// A malformed manifest is an alarm everywhere — an operator wrote something and it does not parse.
|
|
218
|
+
function describeRoster(roster, { controlPlane = false } = {}) {
|
|
219
|
+
const r = roster || {};
|
|
220
|
+
const reason = ROSTER_REASONS.includes(r.reason) ? r.reason : ROSTER_EMPTY;
|
|
221
|
+
const where = r.source || SUBSCRIPTIONS_REL;
|
|
222
|
+
const dropped = Number(r.dropped) || 0;
|
|
223
|
+
const n = Array.isArray(r.instances) ? r.instances.length : 0;
|
|
224
|
+
|
|
225
|
+
if (reason === ROSTER_MALFORMED) {
|
|
226
|
+
return { level: 'alarm', reason, count: n, dropped,
|
|
227
|
+
message: `update-subscriptions: MALFORMED manifest at ${where} (${r.error || 'unparseable'}) — subscribing NOTHING. No instance will be upgraded until this parses.` };
|
|
228
|
+
}
|
|
229
|
+
if (reason === ROSTER_ABSENT) {
|
|
230
|
+
return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
|
|
231
|
+
message: `update-subscriptions: no manifest at ${where} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ' That is the shipped default; add an instance to enroll it.'}` };
|
|
232
|
+
}
|
|
233
|
+
if (reason === ROSTER_EMPTY) {
|
|
234
|
+
return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
|
|
235
|
+
message: `update-subscriptions: manifest at ${where} lists no upgradable instance${dropped ? ` (${dropped} entr${dropped === 1 ? 'y' : 'ies'} dropped for a missing \`dir\`)` : ''} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ''}` };
|
|
236
|
+
}
|
|
237
|
+
// Usable entries — still surface partial loss, which is the quiet half of an enrolled-but-untouched
|
|
238
|
+
// instance: the sweep looks healthy while the operator's broken row is never upgraded.
|
|
239
|
+
if (dropped > 0) {
|
|
240
|
+
return { level: 'warn', reason, count: n, dropped,
|
|
241
|
+
message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed, but ${dropped} entr${dropped === 1 ? 'y was' : 'ies were'} DROPPED for a missing \`dir\` in ${where} — those instances are NOT being upgraded.` };
|
|
242
|
+
}
|
|
243
|
+
return { level: 'ok', reason, count: n, dropped,
|
|
244
|
+
message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed from ${where}.` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- watching the watcher ---------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// How far behind is this instance in releases its OWN channel is entitled to take? Not raw version
|
|
250
|
+
// distance: majors, prereleases and (on the patch channel) minors are releases the subscription is
|
|
251
|
+
// correctly declining, and counting them would alarm on instances that are behaving exactly as
|
|
252
|
+
// configured. `behind` is therefore "stable releases this channel WOULD have taken and has not".
|
|
253
|
+
//
|
|
254
|
+
// This is the check that was missing (task 1003158). deploy-divergence.js detects the SYMPTOM — live
|
|
255
|
+
// is behind main — at ship time, when a human is already looking. Nothing watched whether the
|
|
256
|
+
// upgrader itself was still alive, so its death was only ever discovered by someone noticing stale UI
|
|
257
|
+
// days later. A healthy sweep drives `behind` to 0 every run, so a persistent backlog IS the alarm.
|
|
258
|
+
//
|
|
259
|
+
// A `pinned` instance is never stale: channelAllows declines everything, so behind === 0 by
|
|
260
|
+
// construction. That is correct — a pinned instance is not neglected, it is pinned.
|
|
261
|
+
function subscriptionStaleness({ installed, channel, available, threshold = DEFAULT_STALENESS_THRESHOLD } = {}) {
|
|
262
|
+
const ch = normalizeChannel(channel);
|
|
263
|
+
const list = Array.isArray(available) ? available : [];
|
|
264
|
+
// `installed` is parsed and the channel re-normalized ONCE, not per candidate:
|
|
265
|
+
// channelAllows re-does both on every element, and this filter is the only place
|
|
266
|
+
// that calls it in a loop. Cheap either way at registry scale — hoisted because
|
|
267
|
+
// the invariant genuinely is invariant, not because the arithmetic hurts.
|
|
268
|
+
const from = parseSemver(installed);
|
|
269
|
+
const behind = (ch === 'pinned' || !from) ? 0
|
|
270
|
+
: list.filter((v) => channelAllows(installed, v, ch)).length;
|
|
271
|
+
const t = Number(threshold);
|
|
272
|
+
const limit = Number.isFinite(t) && t >= 0 ? t : DEFAULT_STALENESS_THRESHOLD;
|
|
273
|
+
return {
|
|
274
|
+
channel: ch,
|
|
275
|
+
installed: installed == null ? null : String(installed),
|
|
276
|
+
target: resolveChannelTarget({ installed, channel: ch, available: list }),
|
|
277
|
+
behind,
|
|
278
|
+
threshold: limit,
|
|
279
|
+
stale: behind > limit,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The alarm line for one subscribed instance, or null when it is healthy (nothing to say).
|
|
284
|
+
function describeStaleness(st, { slug = 'instance' } = {}) {
|
|
285
|
+
if (!st || !st.stale) return null;
|
|
286
|
+
return `update-subscriptions: ${slug} is ${st.behind} ${st.channel} release${st.behind === 1 ? '' : 's'} behind `
|
|
287
|
+
+ `(installed ${st.installed || 'unknown'}, entitled to ${st.target}) — over the ${st.threshold} threshold. `
|
|
288
|
+
+ 'A healthy sweep clears this to zero every run, so the upgrader has most likely stopped.';
|
|
182
289
|
}
|
|
183
290
|
|
|
184
291
|
module.exports = {
|
|
@@ -195,4 +302,13 @@ module.exports = {
|
|
|
195
302
|
listAvailableVersions,
|
|
196
303
|
parseNpmViewVersions,
|
|
197
304
|
loadSubscriptions,
|
|
305
|
+
ROSTER_OK,
|
|
306
|
+
ROSTER_ABSENT,
|
|
307
|
+
ROSTER_MALFORMED,
|
|
308
|
+
ROSTER_EMPTY,
|
|
309
|
+
ROSTER_REASONS,
|
|
310
|
+
DEFAULT_STALENESS_THRESHOLD,
|
|
311
|
+
describeRoster,
|
|
312
|
+
subscriptionStaleness,
|
|
313
|
+
describeStaleness,
|
|
198
314
|
};
|
|
@@ -944,8 +944,11 @@ function mountInternalSurfaces(app) {
|
|
|
944
944
|
// URL→gate map by parsing this file statically, and a comment in that gap drops
|
|
945
945
|
// the regex out of its map — which reads as "every page on this tier is
|
|
946
946
|
// ungated". It fails loudly by design; keep prose above the const.
|
|
947
|
+
// task 1003172 (C02): `collab` joins the same tier — every read behind it is
|
|
948
|
+
// any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
|
|
949
|
+
// derives crafts from the viewer's own profile), so signed-in is the right floor.
|
|
947
950
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
948
|
-
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
951
|
+
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
949
952
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
950
953
|
// page that embeds them.
|
|
951
954
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -1122,8 +1122,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1122
1122
|
// landed since 1.19.344 with no explicit bump. run 32800819617. (task 1002620)
|
|
1123
1123
|
// 1.19.346 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1124
1124
|
// landed since 1.19.345 with no explicit bump. run 32801147433. (task 1002620)
|
|
1125
|
+
// 1.19.347 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1126
|
+
// landed since 1.19.346 with no explicit bump. run 32802265337. (task 1002620)
|
|
1127
|
+
// 1.19.348 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1128
|
+
// landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
|
|
1125
1129
|
// ---------------------------------------------------------------------------
|
|
1126
|
-
const CORE_VERSION = '1.19.
|
|
1130
|
+
const CORE_VERSION = '1.19.348'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1127
1131
|
|
|
1128
1132
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1129
1133
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tests/client_baseurl_guard.mjs — the guard that stops a page 404ing every call
|
|
2
|
+
// (task 1003263).
|
|
3
|
+
//
|
|
4
|
+
// Three pages shipped the defect this guard exists to catch: ideas.js and idea.js
|
|
5
|
+
// were dead in production from the day they shipped, and collab.js was caught one
|
|
6
|
+
// commit before it would have joined them. So the guard itself is worth testing on
|
|
7
|
+
// synthetic sources rather than only on the live tree — a scan that passes because
|
|
8
|
+
// the tree happens to be clean today proves nothing about what it would REFUSE.
|
|
9
|
+
//
|
|
10
|
+
// Every case below is an in-memory file: no fixtures on disk, no dependence on the
|
|
11
|
+
// current state of modules/hall-ui/public/.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/client_baseurl_guard.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const { checkClientBaseUrl, scanPages } = require('../scripts/gds/client-baseurl-guard.js');
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
let failed = 0;
|
|
23
|
+
function test(name, fn) {
|
|
24
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
25
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const from = (map) => ({
|
|
29
|
+
files: Object.keys(map),
|
|
30
|
+
read: (rel) => (rel in map ? map[rel] : null),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// --- THE DEFECT ------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
test('the exact shipped defect is refused: baseUrl:\'\' plus a namespaced call', () => {
|
|
36
|
+
const { violations } = scanPages(...Object.values(from({
|
|
37
|
+
'modules/hall-ui/public/bad.js':
|
|
38
|
+
"const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
|
|
39
|
+
+ 'const r = await api.inbox.getInboxTemplate({});\n',
|
|
40
|
+
})));
|
|
41
|
+
assert.equal(violations.length, 1);
|
|
42
|
+
assert.match(violations[0], /bad\.js/);
|
|
43
|
+
assert.match(violations[0], /site root/, 'the message says WHY it is wrong, not just that it is');
|
|
44
|
+
assert.match(violations[0], /Drop the baseUrl override/, 'and what to do about it');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('double-quoted and backtick empty bases are the same defect', () => {
|
|
48
|
+
for (const empty of ['""', '``']) {
|
|
49
|
+
const { violations } = scanPages(...Object.values(from({
|
|
50
|
+
'public/p.js': `const api = createClient({ baseUrl: ${empty} });\napi.me.getMe({});\n`,
|
|
51
|
+
})));
|
|
52
|
+
assert.equal(violations.length, 1, `baseUrl: ${empty} must be caught`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- WHAT IT MUST NOT FLAG -------------------------------------------------
|
|
57
|
+
// A guard that fires on correct code gets suppressed, and then it protects nothing.
|
|
58
|
+
|
|
59
|
+
test("the escape-hatch style is CORRECT with baseUrl:'' — never flagged", () => {
|
|
60
|
+
// work.js, copy-desk.js and most of the hall do exactly this: empty base, but
|
|
61
|
+
// every path written out in full. Flagging it would be a false positive on the
|
|
62
|
+
// majority of the hall.
|
|
63
|
+
const { violations } = scanPages(...Object.values(from({
|
|
64
|
+
'modules/hall-ui/public/work.js':
|
|
65
|
+
"const API = '/api/bongos';\n"
|
|
66
|
+
+ "const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin' });\n"
|
|
67
|
+
+ "const res = await api.request('GET', `${API}/tasks`);\n",
|
|
68
|
+
})));
|
|
69
|
+
assert.deepEqual(violations, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the fix itself passes — omitting baseUrl with namespaced calls is correct', () => {
|
|
73
|
+
const { violations } = scanPages(...Object.values(from({
|
|
74
|
+
'modules/hall-ui/public/ideas.js':
|
|
75
|
+
"const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });\n"
|
|
76
|
+
+ 'await api.inbox.getInboxTemplate({});\n',
|
|
77
|
+
})));
|
|
78
|
+
assert.deepEqual(violations, [], 'the shipped fix must not trip the guard that motivated it');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a non-empty baseUrl is a deliberate choice, not the defect', () => {
|
|
82
|
+
const { violations } = scanPages(...Object.values(from({
|
|
83
|
+
'public/ext.js':
|
|
84
|
+
"const api = createClient({ baseUrl: 'https://other.example/api/bongos/v1' });\n"
|
|
85
|
+
+ 'api.me.getMe({});\n',
|
|
86
|
+
})));
|
|
87
|
+
assert.deepEqual(violations, []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a page that builds no client at all is ignored', () => {
|
|
91
|
+
const { violations, usingClient } = scanPages(...Object.values(from({
|
|
92
|
+
'public/static.js': "document.querySelector('#x').textContent = 'hi';\n",
|
|
93
|
+
})));
|
|
94
|
+
assert.deepEqual(violations, []);
|
|
95
|
+
assert.equal(usingClient, 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- THE SCAN MUST NOT SILENTLY SUCCEED ------------------------------------
|
|
99
|
+
|
|
100
|
+
test('an empty enumeration is a hard FAIL, not a clean pass', () => {
|
|
101
|
+
// The failure mode that makes a guard worthless: git ls-files returns nothing,
|
|
102
|
+
// zero files are scanned, and the check reports green forever.
|
|
103
|
+
const r = checkClientBaseUrl({ files: [], read: () => null });
|
|
104
|
+
assert.equal(r.hardFail, true);
|
|
105
|
+
assert.match(r.violations[0], /scan defect/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an unreadable file is skipped without being counted as scanned', () => {
|
|
109
|
+
const { scanned } = scanPages(['gone.js'], () => null);
|
|
110
|
+
assert.equal(scanned, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the check reports hardFail so CI actually stops', () => {
|
|
114
|
+
const r = checkClientBaseUrl(from({
|
|
115
|
+
'public/bad.js': "createClient({ baseUrl: '' });\napi.inbox.postInbox({});\n",
|
|
116
|
+
}));
|
|
117
|
+
assert.equal(r.ok, false);
|
|
118
|
+
assert.equal(r.hardFail, true, 'a page dead in prod is not a warning');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
console.log(`\nclient_baseurl_guard: ${passed} passed, ${failed} failed`);
|
|
122
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// tests/collab_page.mjs — the Collab page (task 1003172 / C02, goal 1000075,
|
|
2
|
+
// criterion collab-page-matches-my-craft).
|
|
3
|
+
//
|
|
4
|
+
// The criterion has three clauses and the third is the one that gets skipped:
|
|
5
|
+
// requests match the viewer's CRAFT, are ANSWERABLE from the page, and a request
|
|
6
|
+
// nobody takes is VISIBLY AGING rather than silently dead. Sorting the oldest to
|
|
7
|
+
// the top does not satisfy the third — the oldest then looks exactly like the
|
|
8
|
+
// newest, only lower down.
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
17
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
18
|
+
|
|
19
|
+
// collab-lib.js is pure, so RUN it rather than grep it.
|
|
20
|
+
function lib() {
|
|
21
|
+
const src = read('modules', 'hall-ui', 'public', 'collab-lib.js');
|
|
22
|
+
const win = {};
|
|
23
|
+
new Function('window', src)(win);
|
|
24
|
+
return win.OTBCollabLib;
|
|
25
|
+
}
|
|
26
|
+
const NOW = Date.parse('2026-08-24T12:00:00Z');
|
|
27
|
+
const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
|
|
28
|
+
|
|
29
|
+
// ---- THE AGEING CLAUSE ----------------------------------------------------
|
|
30
|
+
test('age is a BAND with a label, not just a sort position', () => {
|
|
31
|
+
const L = lib();
|
|
32
|
+
assert.equal(L.ageBand(daysAgo(0), NOW).id, 'fresh');
|
|
33
|
+
assert.equal(L.ageBand(daysAgo(1), NOW).id, 'waiting');
|
|
34
|
+
assert.equal(L.ageBand(daysAgo(3), NOW).id, 'aging');
|
|
35
|
+
assert.equal(L.ageBand(daysAgo(7), NOW).id, 'stale');
|
|
36
|
+
assert.equal(L.ageBand(daysAgo(90), NOW).id, 'stale');
|
|
37
|
+
for (const b of L.BANDS) assert.ok(b.label && b.label.length > 4, `${b.id} says it in words`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the boundaries are exact, so the bands mean something', () => {
|
|
41
|
+
const L = lib();
|
|
42
|
+
assert.equal(L.ageBand(daysAgo(0.99), NOW).id, 'fresh', 'just under a day is still fresh');
|
|
43
|
+
assert.equal(L.ageBand(daysAgo(2.99), NOW).id, 'waiting');
|
|
44
|
+
assert.equal(L.ageBand(daysAgo(6.99), NOW).id, 'aging');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an unknown age reads as fresh, never as an accusation', () => {
|
|
48
|
+
const L = lib();
|
|
49
|
+
for (const bad of [null, undefined, '', 'nonsense']) {
|
|
50
|
+
assert.equal(L.ageBand(bad, NOW).id, 'fresh', `${JSON.stringify(bad)} must not look neglected`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('ageing changes how a row LOOKS, not only where it sits', () => {
|
|
55
|
+
// The whole point of the clause. Each band must have its own visual treatment
|
|
56
|
+
// in CSS, or "visibly aging" is just a sort order with extra steps.
|
|
57
|
+
const css = read('modules', 'hall-ui', 'public', 'collab.css');
|
|
58
|
+
const L = lib();
|
|
59
|
+
for (const b of L.BANDS) {
|
|
60
|
+
assert.match(css, new RegExp(`\\.cb-item--${b.id}\\b`), `${b.id} has its own styling`);
|
|
61
|
+
}
|
|
62
|
+
assert.match(css, /\.cb-item--stale\s*\{[^}]*border-left-color/, 'the band reads down a list at a glance');
|
|
63
|
+
// Colour alone is not a message and not everyone sees it.
|
|
64
|
+
assert.match(css, /\.cb-item--stale \.cb-age/, 'and the words are emphasised too');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the row markup carries the band class and the label', () => {
|
|
68
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
69
|
+
assert.match(js, /cb-item--\$\{L\.ageBand\(/, 'the class comes from the band');
|
|
70
|
+
assert.match(js, /cb-age cb-age--\$\{band\.id\}/, 'and so does the chip');
|
|
71
|
+
assert.match(js, /escapeHtml\(band\.label\)/, 'the label is rendered, escaped');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('oldest first WITHIN the page, in addition to the band', () => {
|
|
75
|
+
const L = lib();
|
|
76
|
+
const rows = [{ created_at: daysAgo(1) }, { created_at: daysAgo(9) }, { created_at: daysAgo(4) }];
|
|
77
|
+
const sorted = rows.slice().sort(L.byAgeOldestFirst);
|
|
78
|
+
assert.deepEqual(sorted.map((r) => L.ageBand(r.created_at, NOW).id), ['stale', 'aging', 'waiting']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- THE MATCHING CLAUSE --------------------------------------------------
|
|
82
|
+
test('the page NEVER sends a craft — matching is the server\'s job', () => {
|
|
83
|
+
// C01's grader found a ?craft= param that let any builder read another craft's
|
|
84
|
+
// requests. The fix put craft derivation on the server, and this page must not
|
|
85
|
+
// reintroduce the parameter from the client side.
|
|
86
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
87
|
+
// Assert on the REQUEST, not on any occurrence of the word: the first version
|
|
88
|
+
// matched `Matching your craft: ${crafts.join(', ')}` — the page's own subtitle —
|
|
89
|
+
// which is display text, not a parameter. Fifth time this session that a source
|
|
90
|
+
// assertion has been wider than its invariant (learning 1000196).
|
|
91
|
+
assert.match(js, /getHelpRequestsForMe\(\{\}\)/, 'the read is unparameterised');
|
|
92
|
+
assert.ok(!/(query|body):\s*\{[^}]*craft/.test(js), 'craft never travels in a query or body');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a craft request and a by-name request read differently', () => {
|
|
96
|
+
const L = lib();
|
|
97
|
+
assert.match(L.addressedTo({ needs_craft: 'artist' }, 1), /artists/);
|
|
98
|
+
assert.match(L.addressedTo({ needs_builder_id: 7 }, 7), /by name/);
|
|
99
|
+
assert.match(L.addressedTo({ needs_builder_id: 7 }, 9), /one person/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('context links to the task or goal, and to neither when absent', () => {
|
|
103
|
+
const L = lib();
|
|
104
|
+
assert.equal(L.contextOf({ task_id: 5 }).kind, 'task');
|
|
105
|
+
assert.equal(L.contextOf({ goal_id: 5 }).kind, 'goal');
|
|
106
|
+
assert.equal(L.contextOf({}), null, 'no context means no element, not an empty one');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- THE ANSWERABLE CLAUSE ------------------------------------------------
|
|
110
|
+
test('the asker can settle in place, with both outcomes', () => {
|
|
111
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
112
|
+
assert.match(js, /data-settle="answered"/);
|
|
113
|
+
assert.match(js, /data-settle="withdrawn"/);
|
|
114
|
+
assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('there is deliberately NO reply thread — that would reimplement the Agora', () => {
|
|
118
|
+
// The goal forbids reimplementing the Agora to get around C06's dependency.
|
|
119
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
120
|
+
for (const f of ['reply', 'comment', 'thread', 'message']) {
|
|
121
|
+
assert.ok(!new RegExp(`data-${f}|post${f}`, 'i').test(js), `no ${f} surface`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---- placement ------------------------------------------------------------
|
|
126
|
+
test('it is a PAGE, not a Home card — the actionable half is the needs roster', () => {
|
|
127
|
+
// hall-ui/CLAUDE.md's tree: an action awaiting the builder goes in the
|
|
128
|
+
// needs-attention inbox and NEVER a new card, because one landing place for
|
|
129
|
+
// everything actionable is the whole point. C01 already put it there.
|
|
130
|
+
assert.ok(fs.existsSync(path.join(HALL, 'collab.html')));
|
|
131
|
+
const builders = read('modules', 'hall-ui', 'public', 'builders.js');
|
|
132
|
+
assert.ok(!/collab/i.test(builders), 'no Home card was added');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('the page is behind the signed-in gate', () => {
|
|
136
|
+
const serve = read('src', 'bongos', 'routes', '..', 'serve-internal.js');
|
|
137
|
+
const m = /const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/[^\n]+\/);/.exec(serve);
|
|
138
|
+
assert.ok(m, 'the gate const is still statically parseable');
|
|
139
|
+
assert.ok(new RegExp(m[1].slice(1, -1)).test('/builders/collab'), 'collab is gated');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('every CSS token the page uses exists in style.css', () => {
|
|
143
|
+
const css = read('modules', 'hall-ui', 'public', 'collab.css');
|
|
144
|
+
const style = read('modules', 'hall-ui', 'public', 'style.css');
|
|
145
|
+
const used = new Set([...css.matchAll(/var\((--[a-z-]+)\)/g)].map((x) => x[1]));
|
|
146
|
+
assert.ok(used.size >= 5);
|
|
147
|
+
for (const t of used) {
|
|
148
|
+
assert.ok(new RegExp(`^\\s*${t}:`, 'm').test(style), `${t} must be defined (idea 1000821)`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---- THE CLIENT ACTUALLY REACHES THE API ----------------------------------
|
|
153
|
+
// The page called every endpoint through the GENERATED client (api.helpRequests.*),
|
|
154
|
+
// whose methods emit API-RELATIVE paths ('/help-requests/for-me'). Combined with
|
|
155
|
+
// baseUrl:'' — which the client documents as "these paths are already absolute" —
|
|
156
|
+
// every request went to the SITE ROOT and 404'd, so the page would have shipped
|
|
157
|
+
// dead on arrival. ideas.js and idea.js already shipped with exactly this defect.
|
|
158
|
+
//
|
|
159
|
+
// Grepped rather than executed on purpose: the defect is in URL CONSTRUCTION, and
|
|
160
|
+
// every existing test stubs the client, which is precisely why three pages shipped
|
|
161
|
+
// green while 404ing. A repo-wide version of this guard belongs in fitness.js once
|
|
162
|
+
// the two idea pages are fixed — scoped here to the page this task owns.
|
|
163
|
+
test('the client is not configured to send API calls to the site root', () => {
|
|
164
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
165
|
+
const namespaced = [...js.matchAll(/\bapi\.([a-zA-Z]\w*)\.([a-zA-Z]\w*)\(/g)];
|
|
166
|
+
assert.ok(namespaced.length > 0, 'this page uses the generated client');
|
|
167
|
+
assert.ok(
|
|
168
|
+
!/createClient\(\{[^}]*baseUrl:\s*(''|"")/.test(js),
|
|
169
|
+
"baseUrl:'' sends generated-client paths to the site root — drop it so they "
|
|
170
|
+
+ 'resolve against /api/bongos/v1, or switch every call to api.request() with a full path',
|
|
171
|
+
);
|
|
172
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -149,7 +149,13 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
149
149
|
// Gated cosmetically to match its data endpoint — `project.curate`, floor METIC,
|
|
150
150
|
// so the nav entry reads 'government' since ADR 0174 (it said 'archon', then
|
|
151
151
|
// was stricter than the wall). requireProjectCuratePage is the wall server-side.
|
|
152
|
-
|
|
152
|
+
// task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
|
|
153
|
+
// group is picked by whose data it is, and a help request is another builder
|
|
154
|
+
// reaching you — not your own numbers (that is You), and not reference (Docs).
|
|
155
|
+
// First within the group because it is the one page here you ACT on; the
|
|
156
|
+
// leaderboard and the directory are browsing surfaces. Updating this oracle is
|
|
157
|
+
// the reviewed decision the pin exists to force.
|
|
158
|
+
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'leaderboard', 'people'], 'Community = everyone else');
|
|
153
159
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
154
160
|
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
155
161
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
@@ -112,6 +112,17 @@ await test('the page gets both the FORM and the METER from the server', () => {
|
|
|
112
112
|
assert.match(IDEAS_JS, /getInboxTemplate/, 'the form is rendered from GET /inbox/template');
|
|
113
113
|
assert.match(IDEAS_JS, /postInboxScore/, 'the meter is filled from POST /inbox/score');
|
|
114
114
|
assert.match(IDEAS_JS, /postInbox\b/, 'and it files through POST /inbox');
|
|
115
|
+
|
|
116
|
+
// ...AND THOSE CALLS MUST REACH THE API. The three assertions above are regexes
|
|
117
|
+
// over the SOURCE TEXT, so they passed for months while the page 404'd on every
|
|
118
|
+
// one of them: baseUrl:'' told the client the API-relative templates were already
|
|
119
|
+
// absolute, and each request went to the site root. Naming a method proves the
|
|
120
|
+
// call is written, never that it is addressed — task 1003263.
|
|
121
|
+
assert.doesNotMatch(
|
|
122
|
+
IDEAS_JS,
|
|
123
|
+
/createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|"")/,
|
|
124
|
+
"baseUrl:'' sends the generated client's API-relative paths to the site root",
|
|
125
|
+
);
|
|
115
126
|
});
|
|
116
127
|
|
|
117
128
|
await test('the page does not print a pass bar — that rule lives in economy', () => {
|