@bongos/core 1.19.882 → 1.19.883
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 +52 -52
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +6 -0
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +34 -25
- package/docs/file-map.md +1 -2
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/bongos-cli-release.md +1 -1
- package/docs/recipes/managed-settings-remote-control.md +1 -1
- package/migrations/core_248_module_enablement_overrides.sql +50 -0
- package/modules/dev-box/app/README.md +4 -3
- package/modules/hall-ui/public/collab-lib.js +33 -1
- package/modules/hall-ui/public/collab.css +33 -4
- package/modules/hall-ui/public/collab.html +9 -2
- package/modules/hall-ui/public/collab.js +68 -15
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +38 -1
- package/src/bongos/module-overrides.js +117 -0
- package/src/bongos/routes/modules.js +56 -23
- package/src/module-api.js +1 -1
- package/src/modules.js +120 -14
- package/src/platform-server.js +47 -5
- package/tests/box_sweep_docs.mjs +7 -3
- package/tests/collab_page.mjs +136 -1
- package/tests/modules.mjs +110 -0
- package/tests/modules_route_wiring.mjs +103 -0
- package/docs/recipes/builder-box-lifecycle.md +0 -181
- package/docs/recipes/devbox-app-release.md +0 -103
- package/docs/recipes/devbox-app-windows-test.md +0 -128
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
package/src/modules.js
CHANGED
|
@@ -186,6 +186,12 @@ function resolveModules({
|
|
|
186
186
|
// is the one that reads the real posture, and the one that fails closed when it
|
|
187
187
|
// cannot.
|
|
188
188
|
spendGuard = NO_SPEND_RISK, warn = warnSpendRefusal,
|
|
189
|
+
// THE DB OVERRIDE LAYER (task 1002857) — a { <moduleKey>: boolean } snapshot of
|
|
190
|
+
// module_enablement_overrides for this scope, injected rather than read, so this
|
|
191
|
+
// resolver stays pure and synchronous. Defaulted to {} so every existing caller
|
|
192
|
+
// resolves byte-identically to before; an unreachable database passes {} too,
|
|
193
|
+
// which is what makes the DB a durable ADDITION rather than a boot dependency.
|
|
194
|
+
override = {},
|
|
189
195
|
} = {}) {
|
|
190
196
|
// The known-key set + defaults come from the passed registry: callers inject
|
|
191
197
|
// the built-in MODULE_REGISTRY (the default, what the pure unit tests use) or
|
|
@@ -217,7 +223,38 @@ function resolveModules({
|
|
|
217
223
|
return out;
|
|
218
224
|
};
|
|
219
225
|
|
|
220
|
-
|
|
226
|
+
// PRECEDENCE: neutral pack -> instance pack -> DB override -> env -> registry default.
|
|
227
|
+
//
|
|
228
|
+
// ABOVE THE INSTANCE PACK, because outranking the committed file is the entire
|
|
229
|
+
// point: the file is what `git reset --hard` restores on every deploy, so an
|
|
230
|
+
// override that sat below it would be erased by the very event it exists to
|
|
231
|
+
// survive.
|
|
232
|
+
//
|
|
233
|
+
// BELOW ENV, because env is the incident knob. An operator must always be able
|
|
234
|
+
// to force a module off on one box without a working database — and if the DB
|
|
235
|
+
// outranked env, the failure mode would be "the module I am trying to kill is
|
|
236
|
+
// being switched back on by a row I cannot reach to delete".
|
|
237
|
+
const fromOverride = (o) => {
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [k, v] of Object.entries(isObj(o) ? o : {})) {
|
|
240
|
+
// A row for a module this build does not carry is INERT, not an error. The
|
|
241
|
+
// DB outlives any one build: a module can be uninstalled while its override
|
|
242
|
+
// row remains, and that must not stop the process booting. This is the one
|
|
243
|
+
// place the unknown-key rule is deliberately softer than fromConfig's, which
|
|
244
|
+
// throws on enabling an unknown module — a config file is authored by hand
|
|
245
|
+
// for THIS build, a table is not.
|
|
246
|
+
if (known.includes(k)) out[k] = v;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
};
|
|
250
|
+
const layers = [
|
|
251
|
+
['neutral', fromConfig(neutral, 'neutral')],
|
|
252
|
+
['instance', fromConfig(instance, 'instance')],
|
|
253
|
+
['override', fromOverride(override)],
|
|
254
|
+
['env', {}], // filled by the env pass below; named here so the order is stated once
|
|
255
|
+
];
|
|
256
|
+
const envLayer = layers[3][1];
|
|
257
|
+
const merged = Object.assign({}, ...layers.map(([, m]) => m));
|
|
221
258
|
|
|
222
259
|
// Env overrides: <PREFIX>_MODULE_<KEY> where KEY is upper-snake of the module
|
|
223
260
|
// name (dev-box -> DEV_BOX). Highest precedence.
|
|
@@ -232,6 +269,7 @@ function resolveModules({
|
|
|
232
269
|
const b = coerceBool(raw);
|
|
233
270
|
if (b === undefined) throw new Error(`modules: ${envName}="${raw}" is not a boolean (use 1/0, on/off, true/false)`);
|
|
234
271
|
merged[key] = b;
|
|
272
|
+
envLayer[key] = b;
|
|
235
273
|
}
|
|
236
274
|
}
|
|
237
275
|
|
|
@@ -240,14 +278,27 @@ function resolveModules({
|
|
|
240
278
|
const v = key in merged ? coerceBool(merged[key]) : undefined;
|
|
241
279
|
resolved[key] = v === undefined ? registry[key].default : v;
|
|
242
280
|
}
|
|
281
|
+
// WHICH LAYER WON, computed in the SAME pass as the value so the two cannot
|
|
282
|
+
// disagree (task 1002857). An operator looking at a module that is on needs to
|
|
283
|
+
// know whether that came from the committed pack, a durable override or an env
|
|
284
|
+
// knob on this box — without it, "why is this on?" costs a shell and a grep.
|
|
285
|
+
// Non-enumerable so every existing consumer (Object.keys, JSON.stringify, the
|
|
286
|
+
// clientModules projection) sees exactly the boolean map it saw before.
|
|
287
|
+
const sources = {};
|
|
288
|
+
for (const key of known) {
|
|
289
|
+
const hit = [...layers].reverse().find(([, m]) => key in m);
|
|
290
|
+
sources[key] = hit ? hit[0] : 'default';
|
|
291
|
+
}
|
|
243
292
|
// THE SPEND GUARD, applied LAST — after config, after env, after the default.
|
|
244
293
|
// It is deliberately not another precedence level: a refusal that env could
|
|
245
294
|
// outrank would be a suggestion, and the whole point is that the one knob an
|
|
246
295
|
// untrusted-but-cheap deploy would reach for cannot turn it back on.
|
|
247
296
|
for (const key of spendRefusals(resolved, registry, spendGuard)) {
|
|
248
297
|
resolved[key] = false;
|
|
298
|
+
sources[key] = 'spend-refusal'; // the guard outranks every layer, so it owns the answer
|
|
249
299
|
warn(key, envPrefix);
|
|
250
300
|
}
|
|
301
|
+
Object.defineProperty(resolved, 'sources', { value: Object.freeze(sources), enumerable: false });
|
|
251
302
|
return Object.freeze(resolved);
|
|
252
303
|
}
|
|
253
304
|
|
|
@@ -276,19 +327,53 @@ function readJson(p) {
|
|
|
276
327
|
// Like branding's selector, these are FIXED env names (read before the resolved
|
|
277
328
|
// envPrefix is known), not <PREFIX>_ ones. A relative path resolves against the
|
|
278
329
|
// repo root.
|
|
330
|
+
// WHICH instance pack this process runs, by the precedence documented above.
|
|
331
|
+
// Extracted from loadModules (task 1002857) so moduleScopeKey() and the actual
|
|
332
|
+
// read cannot disagree about which pack is in play — a scope key derived from a
|
|
333
|
+
// second, parallel copy of this ladder would drift the first time one changed.
|
|
334
|
+
function resolveInstancePackPath({ instancePath, env = process.env } = {}) {
|
|
335
|
+
if (instancePath !== undefined) return instancePath;
|
|
336
|
+
// Canonical spelling pinned to FALLBACK_ENV_PREFIX for the same reason as
|
|
337
|
+
// branding's selector — read before the pack that names the prefix. The
|
|
338
|
+
// legacy prefixes still resolve (one warning each), so an already-deployed
|
|
339
|
+
// BONGOS_MODULES_FILE / BONGOS_BRANDING_FILE keeps selecting the same pack.
|
|
340
|
+
const fromEnv = resolveEnv('MODULES_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
|
|
341
|
+
if (fromEnv) return path.resolve(resolveInstanceRoot(), fromEnv);
|
|
342
|
+
if (resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX })) return NEUTRAL_PATH;
|
|
343
|
+
return INSTANCE_PATH;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// THE SCOPE KEY for module_enablement_overrides (task 1002857).
|
|
347
|
+
//
|
|
348
|
+
// One deployed checkout can run two instances against ONE database, differentiated
|
|
349
|
+
// only by BONGOS_MODULES_FILE / BONGOS_BRANDING_FILE — the default instance on
|
|
350
|
+
// config/modules.json and a vanilla sibling on config/modules.neutral.json. The
|
|
351
|
+
// pack path is therefore not an incidental detail: it is the ONLY axis those two
|
|
352
|
+
// processes differ on, which makes it the honest scope key. A single global row
|
|
353
|
+
// per module would let one sibling's toggle flip the other's module.
|
|
354
|
+
//
|
|
355
|
+
// Normalised — relative to the instance root, posix separators — so the key is
|
|
356
|
+
// stable across a moved checkout, an absolute-vs-relative env value, and Windows
|
|
357
|
+
// vs Linux. A path OUTSIDE the instance root (possible via an absolute
|
|
358
|
+
// BONGOS_MODULES_FILE) keeps its absolute form rather than a '../../..' walk, which
|
|
359
|
+
// would encode the checkout's position on disk into a database row.
|
|
360
|
+
function moduleScopeKey({ instancePath, env = process.env } = {}) {
|
|
361
|
+
const p = resolveInstancePackPath({ instancePath, env });
|
|
362
|
+
const rel = path.relative(resolveInstanceRoot(), p);
|
|
363
|
+
const key = rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : p;
|
|
364
|
+
return key.split(path.sep).join('/');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The DB override snapshot, read SYNCHRONOUSLY by loadModules and refreshed
|
|
368
|
+
// asynchronously by src/bongos/module-overrides.js. It starts empty and stays
|
|
369
|
+
// empty if the database is never reached, which is what keeps this a durable
|
|
370
|
+
// addition rather than a boot-time dependency: no row, no opinion, resolve as
|
|
371
|
+
// before.
|
|
372
|
+
let _overrides = {};
|
|
373
|
+
|
|
279
374
|
function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.env } = {}) {
|
|
280
375
|
const neutral = readJson(neutralPath) || {};
|
|
281
|
-
|
|
282
|
-
if (ip === undefined) {
|
|
283
|
-
// Canonical spelling pinned to FALLBACK_ENV_PREFIX for the same reason as
|
|
284
|
-
// branding's selector — read before the pack that names the prefix. The
|
|
285
|
-
// legacy prefixes still resolve (one warning each), so an already-deployed
|
|
286
|
-
// GDS_MODULES_FILE / GDS_BRANDING_FILE keeps selecting the same pack.
|
|
287
|
-
const override = resolveEnv('MODULES_FILE', { env, prefix: FALLBACK_ENV_PREFIX });
|
|
288
|
-
if (override) ip = path.resolve(resolveInstanceRoot(), override);
|
|
289
|
-
else if (resolveEnv('BRANDING_FILE', { env, prefix: FALLBACK_ENV_PREFIX })) ip = NEUTRAL_PATH;
|
|
290
|
-
else ip = INSTANCE_PATH;
|
|
291
|
-
}
|
|
376
|
+
const ip = resolveInstancePackPath({ instancePath, env });
|
|
292
377
|
const instance = readJson(ip) || {};
|
|
293
378
|
// Reuse the branding env prefix so an instance has ONE prefix, not two.
|
|
294
379
|
let envPrefix = FALLBACK_ENV_PREFIX;
|
|
@@ -323,7 +408,9 @@ function loadModules({ neutralPath = NEUTRAL_PATH, instancePath, env = process.e
|
|
|
323
408
|
} catch { /* unreadable posture — the fail-closed default above stands */ }
|
|
324
409
|
// Resolve against the EFFECTIVE registry (built-in + discovered) so a config
|
|
325
410
|
// flag for a discovered module is honored. No-op until a module exists.
|
|
326
|
-
return resolveModules({
|
|
411
|
+
return resolveModules({
|
|
412
|
+
neutral, instance, env, envPrefix, registry: effectiveRegistry(), spendGuard, override: _overrides,
|
|
413
|
+
});
|
|
327
414
|
}
|
|
328
415
|
|
|
329
416
|
// Resolved singleton for app code; lazy so require-time never crashes tooling.
|
|
@@ -333,7 +420,22 @@ const modules = () => (_cached ||= loadModules());
|
|
|
333
420
|
// read (a config-file write from the hall's Modules-tab toggle, ADR 0107 §9;
|
|
334
421
|
// also used by tests). Cheap: the next modules() call just re-reads disk.
|
|
335
422
|
const reload = () => { _cached = null; return modules(); };
|
|
336
|
-
|
|
423
|
+
// Replace the override snapshot and invalidate, in one step — a caller that set
|
|
424
|
+
// the snapshot without reloading would leave the live process on a stale answer,
|
|
425
|
+
// which is the exact class of bug this feature exists to remove. Returns the newly
|
|
426
|
+
// resolved set so a route can report what it actually produced rather than what it
|
|
427
|
+
// asked for.
|
|
428
|
+
const setModuleOverrides = (map) => {
|
|
429
|
+
const clean = {};
|
|
430
|
+
for (const [k, v] of Object.entries(map && typeof map === 'object' ? map : {})) {
|
|
431
|
+
const b = coerceBool(v);
|
|
432
|
+
if (b !== undefined) clean[k] = b;
|
|
433
|
+
}
|
|
434
|
+
_overrides = Object.freeze(clean);
|
|
435
|
+
return reload();
|
|
436
|
+
};
|
|
437
|
+
const moduleOverrides = () => _overrides;
|
|
438
|
+
const _resetForTests = () => { _cached = null; _overrides = {}; };
|
|
337
439
|
|
|
338
440
|
function isModuleEnabled(name, set = modules()) {
|
|
339
441
|
const known = knownModules();
|
|
@@ -388,6 +490,10 @@ module.exports = {
|
|
|
388
490
|
spendRefusals,
|
|
389
491
|
warnSpendRefusal,
|
|
390
492
|
resolveModules,
|
|
493
|
+
resolveInstancePackPath,
|
|
494
|
+
moduleScopeKey,
|
|
495
|
+
setModuleOverrides,
|
|
496
|
+
moduleOverrides,
|
|
391
497
|
loadModules,
|
|
392
498
|
modules,
|
|
393
499
|
reload,
|
package/src/platform-server.js
CHANGED
|
@@ -49,6 +49,13 @@ const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGloba
|
|
|
49
49
|
const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
|
|
50
50
|
const moduleLoader = require('./module-loader/loader');
|
|
51
51
|
const { isModuleEnabled } = require('./modules');
|
|
52
|
+
// Hoisted out of an inline require inside the boot IIFE below (task 1004052 strand walk).
|
|
53
|
+
// knip does not follow an inline require(), so every export of module-overrides.js read as
|
|
54
|
+
// dead code and tripped the ratchet that held this branch out of main. Requiring it here
|
|
55
|
+
// does not weaken the property the comment at the call site protects: refreshOverrides()
|
|
56
|
+
// still swallows its own database error, and node-postgres constructs a Pool without
|
|
57
|
+
// connecting, so a box with no reachable database still boots on the packs and env alone.
|
|
58
|
+
const { refreshOverrides } = require('./bongos/module-overrides');
|
|
52
59
|
const { logger, requestIdMiddleware } = require('./bongos/logger');
|
|
53
60
|
|
|
54
61
|
const PORT = Number(process.env.PORT) || 3000;
|
|
@@ -311,11 +318,46 @@ function startBackgroundServices() {
|
|
|
311
318
|
// Start only when run as the entry (`node src/platform-server.js`). Importing the
|
|
312
319
|
// module (the smoke test) must NOT bind a port.
|
|
313
320
|
if (require.main === module) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
321
|
+
// DURABLE MODULE ENABLEMENT, read BEFORE the app is built (task 1002857).
|
|
322
|
+
//
|
|
323
|
+
// It has to be before: buildPlatformApp() mounts routes from the resolved module
|
|
324
|
+
// set, so an override that landed after construction would not change what is
|
|
325
|
+
// mounted until the next restart — which is precisely the "my toggle did not
|
|
326
|
+
// stick" complaint this feature exists to answer.
|
|
327
|
+
//
|
|
328
|
+
// And it is NOT a boot dependency. refreshOverrides() swallows its own database
|
|
329
|
+
// error and returns null, so an unreachable database boots this process exactly
|
|
330
|
+
// as it booted before the table existed — on the config packs and env alone. The
|
|
331
|
+
// await buys a better answer when the database is there; it never withholds the
|
|
332
|
+
// server when it is not.
|
|
333
|
+
(async () => {
|
|
334
|
+
await refreshOverrides();
|
|
335
|
+
const app = buildPlatformApp();
|
|
336
|
+
const httpServer = http.createServer(app);
|
|
337
|
+
httpServer.listen(PORT, HOST, () => {
|
|
338
|
+
logger.info({ host: HOST, port: PORT }, 'Cloud Bongos platform (GDS-only, no game; ADR 0062 R51) listening');
|
|
339
|
+
startBackgroundServices();
|
|
340
|
+
});
|
|
341
|
+
})().catch((err) => {
|
|
342
|
+
// THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
|
|
343
|
+
// tests/platform_boot.mjs's 1003820 case). buildPlatformApp() THROWS when an
|
|
344
|
+
// enabled module consumes a port nothing provided, and before this block was
|
|
345
|
+
// async that throw was an uncaught exception: the process died, non-zero, which
|
|
346
|
+
// is the correct answer to "half the instance cannot be wired". Inside a promise
|
|
347
|
+
// it became a rejection instead, and the unhandled-rejection backstop below
|
|
348
|
+
// explicitly keeps the process UP — so the server came up half-wired and told
|
|
349
|
+
// nobody. Re-assert the old contract explicitly: a boot failure is fatal.
|
|
350
|
+
//
|
|
351
|
+
// RETHROWN, not logged-and-exited. Before the await this WAS an uncaught
|
|
352
|
+
// exception, and Node's own handling of one is exactly the behaviour wanted:
|
|
353
|
+
// the raw stack on stderr, then a non-zero exit. Re-logging it instead would
|
|
354
|
+
// mean choosing between logger.fatal — which JSON-escapes the module-loader's
|
|
355
|
+
// carefully written "module X consumes port Y, here is how to fix it" into a
|
|
356
|
+
// wall of backslashes at the moment an operator has to read it — and a
|
|
357
|
+
// console call that says less than Node already says. A throw from nextTick
|
|
358
|
+
// is an uncaughtException, NOT an unhandledRejection, so the backstop below
|
|
359
|
+
// (which deliberately keeps the process up) never sees it.
|
|
360
|
+
process.nextTick(() => { throw err; });
|
|
319
361
|
});
|
|
320
362
|
|
|
321
363
|
// Graceful shutdown — stop each module poller (e.g. close the Discord gateway
|
package/tests/box_sweep_docs.mjs
CHANGED
|
@@ -35,7 +35,11 @@ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
|
|
35
35
|
const BOX_JS = 'scripts/gds/box.js';
|
|
36
36
|
const IDLE_SKILL = '.claude/scheduled-tasks/box-idle-suspend/SKILL.md';
|
|
37
37
|
const DORMANT_SKILL = '.claude/scheduled-tasks/box-dormant-reclaim/SKILL.md';
|
|
38
|
-
|
|
38
|
+
// The fourth surface this guard used to read, docs/recipes/builder-box-lifecycle.md,
|
|
39
|
+
// was deleted with the other three published dev-box recipes (task 1004013,
|
|
40
|
+
// ADR 0305: a recipe lives with what it documents). A file that does not exist
|
|
41
|
+
// cannot invert against box.js, so it drops out of the lists below rather than
|
|
42
|
+
// weakening them — the two SKILL.md surfaces are still read in full.
|
|
39
43
|
|
|
40
44
|
// Comments are stripped before scanning prose: each surface deliberately keeps a
|
|
41
45
|
// sentence SAYING which model it used to describe, and a naive substring check
|
|
@@ -76,7 +80,7 @@ test('no box-sweep doc claims the IDLE sweep destroys, while the code parks', ()
|
|
|
76
80
|
/single job is to DESTROY/i,
|
|
77
81
|
];
|
|
78
82
|
|
|
79
|
-
for (const file of [IDLE_SKILL, DORMANT_SKILL
|
|
83
|
+
for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
|
|
80
84
|
const prose = stripHtmlComments(read(file));
|
|
81
85
|
for (const lie of lies) {
|
|
82
86
|
assert.doesNotMatch(prose, lie,
|
|
@@ -107,7 +111,7 @@ test('every surface names sweep-dormant as the only automated snapshot delete',
|
|
|
107
111
|
// The safety property box.js implements in prose: full reclaim warns first, so
|
|
108
112
|
// no automated path takes uncommitted work silently. A reader who misses this
|
|
109
113
|
// is the reader who reaches for deprovision.
|
|
110
|
-
for (const file of [IDLE_SKILL, DORMANT_SKILL
|
|
114
|
+
for (const file of [IDLE_SKILL, DORMANT_SKILL]) {
|
|
111
115
|
assert.match(stripHtmlComments(read(file)), /only automated path that destroys a snapshot|only automated snapshot delete/i,
|
|
112
116
|
`${file} never tells the reader that sweep-dormant is the only automated path that `
|
|
113
117
|
+ 'destroys a snapshot (and that it warns first) — the safety property this subsystem rests on.');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -23,6 +23,16 @@ function lib() {
|
|
|
23
23
|
new Function('window', src)(win);
|
|
24
24
|
return win.OTBCollabLib;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
// The role vocabulary is pure too, so RUN it for the same reason — a grep over
|
|
28
|
+
// roleWord()'s source would agree with a roleWord() that was wrong.
|
|
29
|
+
function domUtils() {
|
|
30
|
+
const src = read('modules', 'hall-ui', 'public', 'dom-utils.js');
|
|
31
|
+
const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
|
|
32
|
+
const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
|
|
33
|
+
new Function('window', 'document', src)(win, doc);
|
|
34
|
+
return win.OTB;
|
|
35
|
+
}
|
|
26
36
|
const NOW = Date.parse('2026-08-24T12:00:00Z');
|
|
27
37
|
const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
|
|
28
38
|
|
|
@@ -71,8 +81,75 @@ test('ageing changes how a row LOOKS, not only where it sits', () => {
|
|
|
71
81
|
test('the row markup carries the band class and the label', () => {
|
|
72
82
|
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
73
83
|
assert.match(js, /cb-age cb-age--\$\{band\.id\}/, 'the pill\'s class comes from the band');
|
|
74
|
-
assert.match(js, /escapeHtml\(
|
|
84
|
+
assert.match(js, /escapeHtml\(L\.ageLabel\(req\.created_at, nowMs\)\)/, 'the label is rendered, escaped');
|
|
75
85
|
assert.match(js, /L\.ageBand\(req\.created_at, nowMs\)/, 'through the lib, so the boundaries are the tested ones');
|
|
86
|
+
// THE BAND AND THE LABEL ARE TWO SOURCES ON PURPOSE (task 1004041). Until then
|
|
87
|
+
// the pill rendered `band.label`, so the band decided both how loud the row
|
|
88
|
+
// looked AND what it said — which is why fourteen rows past a week carried
|
|
89
|
+
// fourteen copies of "waiting over a week". The class still comes from the
|
|
90
|
+
// band (four treatments, readable down a column) and the words now come from
|
|
91
|
+
// ageLabel (the real age, different on every row). Asserting only one of the
|
|
92
|
+
// two would let them be collapsed back onto each other.
|
|
93
|
+
assert.ok(!/escapeHtml\(band\.label\)/.test(js), 'the words are the real age, not the band name again');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('two rows in the same band do not read identically', () => {
|
|
97
|
+
// The artist's finding, as an executable claim: a signal that never varies is
|
|
98
|
+
// not a signal. Every one of these is band 'stale', and no two say the same
|
|
99
|
+
// thing.
|
|
100
|
+
const L = lib();
|
|
101
|
+
const said = [8, 9, 13, 21, 40, 400].map((d) => {
|
|
102
|
+
assert.equal(L.ageBand(daysAgo(d), NOW).id, 'stale', `${d} days is stale`);
|
|
103
|
+
return L.ageLabel(daysAgo(d), NOW);
|
|
104
|
+
});
|
|
105
|
+
assert.equal(new Set(said).size, said.length, `six stale rows, six different readings: ${said.join(' / ')}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the age label says how long, coarsely, and never accuses an unknown age', () => {
|
|
109
|
+
const L = lib();
|
|
110
|
+
assert.equal(L.ageLabel(daysAgo(0.4), NOW), 'just asked');
|
|
111
|
+
assert.equal(L.ageLabel(daysAgo(1), NOW), 'waiting a day');
|
|
112
|
+
assert.equal(L.ageLabel(daysAgo(4), NOW), 'waiting 4 days');
|
|
113
|
+
assert.equal(L.ageLabel(daysAgo(13), NOW), 'waiting 13 days');
|
|
114
|
+
// Past a fortnight it goes to weeks: 'waiting 38 days' is a precision nobody
|
|
115
|
+
// acts on, the same argument the bands were written on.
|
|
116
|
+
assert.equal(L.ageLabel(daysAgo(21), NOW), 'waiting 3 weeks');
|
|
117
|
+
for (const bad of [null, undefined, '', 'nonsense']) {
|
|
118
|
+
assert.equal(L.ageLabel(bad, NOW), 'just asked', `${JSON.stringify(bad)} must not look neglected`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the two calm bands are not chips, and the date is not said twice', () => {
|
|
123
|
+
// The chip appears where there is something to say. All four bands keep their
|
|
124
|
+
// own ink (the test above), but fresh and waiting drop the pill's ring, so a
|
|
125
|
+
// board of healthy asks does not open as a wall of chips.
|
|
126
|
+
const css = read('modules', 'hall-ui', 'public', 'collab.css');
|
|
127
|
+
assert.match(
|
|
128
|
+
css,
|
|
129
|
+
/\.cb-age--fresh,\s*\n\.cb-age--waiting\s*\{[^}]*border-color: transparent/,
|
|
130
|
+
'the calm bands read as quiet text, not as chips',
|
|
131
|
+
);
|
|
132
|
+
// And the separate date line is gone: the pill carries the age in words and
|
|
133
|
+
// the exact timestamp on its title, so a date beside it was one fact twice.
|
|
134
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
135
|
+
assert.ok(!/ov-row__when/.test(js), 'no second rendering of the same fact');
|
|
136
|
+
assert.match(js, /title="Asked \$\{escapeHtml\(String\(req\.created_at/, 'the exact date is still reachable');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the alarm does not sit beside the accent control', () => {
|
|
140
|
+
// DESIGN.md spends the accent only on what is pressable or live. On "Your
|
|
141
|
+
// asks" the age pill (danger ink) used to share the right column with the
|
|
142
|
+
// solid-accent "Someone helped" button, so a warning and a control sat two
|
|
143
|
+
// centimetres apart in one colour family. The age is a FACT about the ask now;
|
|
144
|
+
// the right column on that row holds only what you can press.
|
|
145
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
146
|
+
const mine = js.slice(js.indexOf('function mineRow('), js.indexOf('function allRow('));
|
|
147
|
+
assert.ok(mine.length > 100, 'found mineRow');
|
|
148
|
+
const right = mine.slice(mine.indexOf('ov-row__right'));
|
|
149
|
+
assert.ok(!right.includes('ageChip'), 'no age pill in the column that holds the accent button');
|
|
150
|
+
assert.ok(right.includes('btn-accent'), 'the accent button is still there');
|
|
151
|
+
const facts = mine.slice(mine.indexOf('ov-row__facts'), mine.indexOf('ov-row__right'));
|
|
152
|
+
assert.ok(facts.includes('ageChip'), 'the age reads as a fact about the ask');
|
|
76
153
|
});
|
|
77
154
|
|
|
78
155
|
test('oldest first WITHIN the page, in addition to the band', () => {
|
|
@@ -264,6 +341,64 @@ test('the page is the three blocks, led by faces, with the ledes gone', () => {
|
|
|
264
341
|
assert.match(html, /class="cb-foot"/, 'and it is the foot, not the opener');
|
|
265
342
|
});
|
|
266
343
|
|
|
344
|
+
test('a person meets the page\'s NUMBERS before its pills', () => {
|
|
345
|
+
// Task 1003942 promised "a glass figure strip at the top (waiting on you /
|
|
346
|
+
// your open asks / crafts you answer for)" and shipped without it; the
|
|
347
|
+
// grader's narc worker caught the omission and the artist's verdict named
|
|
348
|
+
// what it cost — with nothing calm at the top, the page opens on age pills.
|
|
349
|
+
const html = read('modules', 'hall-ui', 'public', 'collab.html');
|
|
350
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
351
|
+
assert.match(html, /<div class="stat-row ov-summary cb-figs" id="cb-figs"/, 'the strip is the shell\'s glass band');
|
|
352
|
+
// Position is the whole claim: before the blocks, not beside them.
|
|
353
|
+
assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('cb-band'), 'the strip is ABOVE the blocks');
|
|
354
|
+
assert.ok(html.indexOf('id="cb-figs"') < html.indexOf('id="cb-all"'), 'and above the board');
|
|
355
|
+
assert.match(js, /function paintFigures\(/, 'the page fills it');
|
|
356
|
+
assert.match(js, /paintFigures\(rowsOf\(forMe\), rowsOf\(mine\), crafts\)/, 'from the reads it already makes — no fourth fetch');
|
|
357
|
+
for (const label of ['Waiting on you', 'Your open asks', 'Roles you answer for']) {
|
|
358
|
+
assert.ok(js.includes(label), `the strip carries "${label}"`);
|
|
359
|
+
}
|
|
360
|
+
// It is painted BEFORE the blocks, so the numbers are on screen first even on
|
|
361
|
+
// a slow paint.
|
|
362
|
+
assert.ok(js.indexOf('paintFigures(') < js.indexOf("paintBlock('for-me'"), 'and painted first');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('ONE WORD, ONE CASING: a sentence takes roleWord, a control takes roleLabel', () => {
|
|
366
|
+
// The artist's third finding, and the design error under it. Collab said
|
|
367
|
+
// "Asks to Builders" in its sub line and "You asked artists" six lines down,
|
|
368
|
+
// because task 1003936 routed one call site through roleLabel() — correct for
|
|
369
|
+
// a picker, wrong in prose — and reasoned about the other separately.
|
|
370
|
+
const dom = read('modules', 'hall-ui', 'public', 'dom-utils.js');
|
|
371
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
372
|
+
assert.match(dom, /function roleWord\(d\)/, 'prose has its own helper');
|
|
373
|
+
assert.match(dom, /roleAxisLabel, roleLabel, roleWord,/, 'and it is exported');
|
|
374
|
+
// The rule is written down next to the helper that made it necessary, which is
|
|
375
|
+
// the third DONE WHEN clause of task 1004041.
|
|
376
|
+
const nearby = dom.slice(dom.indexOf('function roleLabel('), dom.indexOf('function roleWord('));
|
|
377
|
+
assert.match(nearby, /A CONTROL TAKES roleLabel\(\); A SENTENCE TAKES roleWord\(\)/, 'the rule sits beside roleLabel');
|
|
378
|
+
// The page's sentence no longer capitalises a common noun, and no longer
|
|
379
|
+
// opens on a clause the next clause repeats.
|
|
380
|
+
assert.match(js, /roleWord\(c\)\}s/, 'the sub line names roles as common nouns');
|
|
381
|
+
assert.ok(!/Builders asking builders/.test(js), 'the tautology is gone');
|
|
382
|
+
// A CALL, not a mention: the comment above the sub line names roleLabel() to
|
|
383
|
+
// explain why it is not used, and a bare /roleLabel\(/ would read that as the
|
|
384
|
+
// defect it warns about.
|
|
385
|
+
assert.ok(!/window\.OTB\.roleLabel\(/.test(js), 'no control-voice label inside a sentence on this page');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('roleWord and roleLabel disagree exactly where they should', () => {
|
|
389
|
+
// Executed, not grepped: the acronym exception has to survive both ways, or
|
|
390
|
+
// "You asked uis" ships the next time a ui task files a help request.
|
|
391
|
+
const { roleLabel, roleWord } = domUtils();
|
|
392
|
+
assert.equal(roleLabel('engineer'), 'Engineer');
|
|
393
|
+
assert.equal(roleWord('engineer'), 'engineer');
|
|
394
|
+
assert.equal(roleLabel('ui'), 'UI');
|
|
395
|
+
assert.equal(roleWord('ui'), 'UI', 'an acronym is not a common noun in any sentence');
|
|
396
|
+
assert.equal(roleLabel('unclassified'), 'No role yet');
|
|
397
|
+
assert.equal(roleWord('unclassified'), 'no role yet');
|
|
398
|
+
assert.equal(roleWord(''), '');
|
|
399
|
+
assert.equal(roleWord(null), '');
|
|
400
|
+
});
|
|
401
|
+
|
|
267
402
|
test('the by-name ask is the one LIT thing — the accent goes on the personal call', () => {
|
|
268
403
|
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
269
404
|
const css = read('modules', 'hall-ui', 'public', 'collab.css');
|
package/tests/modules.mjs
CHANGED
|
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { test } from 'node:test';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import modulesMod from '../src/modules.js';
|
|
6
|
+
// Named, not modulesMod.moduleOverrides (task 1004052 strand walk): knip does not credit
|
|
7
|
+
// property access on a default import as using the named export, so the snapshot getter
|
|
8
|
+
// read as dead code and tripped the ratchet that held this branch out of main.
|
|
9
|
+
import { moduleOverrides } from '../src/modules.js';
|
|
6
10
|
|
|
7
11
|
const {
|
|
8
12
|
resolveModules, isModuleEnabled, enabledModules, enabledDisciplines, clientModules,
|
|
@@ -221,3 +225,109 @@ test('a branded sibling (GDS_BRANDING_FILE set, no GDS_MODULES_FILE) defaults to
|
|
|
221
225
|
// assertion above never reaches it (task 1003487).
|
|
222
226
|
assert.deepEqual(enabledDisciplines(m), ['engineer', 'artist', 'ideator', 'ui']);
|
|
223
227
|
});
|
|
228
|
+
|
|
229
|
+
// ── the DB override layer (task 1002857, migration core_248) ────────────────────
|
|
230
|
+
// The toggle used to write config/modules.json, which the pull-deploy's
|
|
231
|
+
// `git reset --hard origin/main` then reverted. These pin the precedence the
|
|
232
|
+
// durable replacement has to hold, because getting the ORDER wrong is the only way
|
|
233
|
+
// this feature can be worse than the bug it replaces.
|
|
234
|
+
|
|
235
|
+
test('1002857: an override outranks the committed instance pack', () => {
|
|
236
|
+
// The whole point: the pack is what a deploy restores, so an override that sat
|
|
237
|
+
// below it would be erased by the very event it exists to survive.
|
|
238
|
+
const m = resolveModules({ instance: OTB, override: { 'dev-box': false }, registry: SAMPLE_REG });
|
|
239
|
+
assert.equal(m['dev-box'], false, 'the override wins over the pack');
|
|
240
|
+
assert.equal(m.discord, true, 'a module with no override still reads the pack');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('1002857: an override turns a module ON as well as off', () => {
|
|
244
|
+
const m = resolveModules({ instance: VANILLA, override: { discord: true }, registry: SAMPLE_REG });
|
|
245
|
+
assert.equal(m.discord, true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('1002857: env still outranks an override — the incident knob needs no database', () => {
|
|
249
|
+
const m = resolveModules({
|
|
250
|
+
instance: VANILLA,
|
|
251
|
+
override: { 'dev-box': true },
|
|
252
|
+
env: { OTB_MODULE_DEV_BOX: '0' },
|
|
253
|
+
envPrefix: 'OTB',
|
|
254
|
+
registry: SAMPLE_REG,
|
|
255
|
+
});
|
|
256
|
+
assert.equal(m['dev-box'], false, 'env must be able to kill a module the DB turned on');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('1002857: an override for a module this build does not carry is inert, not fatal', () => {
|
|
260
|
+
// The database outlives any one build: a module can be uninstalled while its row
|
|
261
|
+
// remains. fromConfig throws on enabling an unknown key (a hand-authored file is
|
|
262
|
+
// written for THIS build); a table is not, so it must not stop the process.
|
|
263
|
+
const m = resolveModules({ override: { 'a-module-that-left': true }, registry: SAMPLE_REG });
|
|
264
|
+
assert.ok(!('a-module-that-left' in m), 'unknown key dropped');
|
|
265
|
+
// NOT 'enables nothing': some discovered modules (ui-design and friends) are
|
|
266
|
+
// default-ON for every instance, so the enabled set is legitimately non-empty.
|
|
267
|
+
// The claim under test is narrower and the one that matters — the unknown key
|
|
268
|
+
// did not conjure itself into the enabled list.
|
|
269
|
+
assert.ok(!enabledModules(m).includes('a-module-that-left'), 'and it enables nothing');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('1002857: no override is "no opinion" — an unreachable database resolves as before', () => {
|
|
273
|
+
const withEmpty = resolveModules({ instance: OTB, override: {}, registry: SAMPLE_REG });
|
|
274
|
+
const without = resolveModules({ instance: OTB, registry: SAMPLE_REG });
|
|
275
|
+
assert.deepEqual({ ...withEmpty }, { ...without }, 'empty snapshot changes nothing');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('1002857: each module reports the layer that produced its state', () => {
|
|
279
|
+
const m = resolveModules({
|
|
280
|
+
neutral: { modules: { discord: false } },
|
|
281
|
+
instance: OTB,
|
|
282
|
+
override: { 'dev-box': false },
|
|
283
|
+
env: { OTB_MODULE_GAME: '0' },
|
|
284
|
+
envPrefix: 'OTB',
|
|
285
|
+
registry: SAMPLE_REG,
|
|
286
|
+
});
|
|
287
|
+
assert.equal(m.sources['dev-box'], 'override');
|
|
288
|
+
assert.equal(m.sources.game, 'env');
|
|
289
|
+
assert.equal(m.sources.discord, 'instance', 'the instance pack outranks the neutral one');
|
|
290
|
+
assert.equal(m.sources['art-pipeline'], 'instance');
|
|
291
|
+
// sources must never join the boolean map: clientModules and every JSON
|
|
292
|
+
// projection of it would start shipping an object where a boolean belongs.
|
|
293
|
+
assert.ok(!Object.keys(m).includes('sources'), 'sources is non-enumerable');
|
|
294
|
+
assert.ok(!('sources' in JSON.parse(JSON.stringify(m))), 'and never serialises');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('1002857: the scope key separates two instances sharing one database', () => {
|
|
298
|
+
// One checkout can run the default instance and a vanilla sibling, differentiated
|
|
299
|
+
// only by env. A single global row per module would let one flip the other's.
|
|
300
|
+
const dflt = modulesMod.moduleScopeKey({ env: {} });
|
|
301
|
+
const sibling = modulesMod.moduleScopeKey({ env: { GDS_BRANDING_FILE: 'config/branding.cloudbongos.json' } });
|
|
302
|
+
assert.notEqual(dflt, sibling, 'the two instances must not share a scope');
|
|
303
|
+
assert.equal(dflt, 'config/modules.json');
|
|
304
|
+
assert.equal(sibling, 'config/modules.neutral.json');
|
|
305
|
+
// Posix separators and no absolute path, so the key is stable across a moved
|
|
306
|
+
// checkout and across Windows vs Linux.
|
|
307
|
+
assert.ok(!dflt.includes(String.fromCharCode(92)), "posix separators, not a Windows path");
|
|
308
|
+
assert.deepEqual(
|
|
309
|
+
modulesMod.moduleScopeKey({ env: { GDS_MODULES_FILE: 'config/other.json' } }),
|
|
310
|
+
'config/other.json',
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('1002857: setModuleOverrides installs the snapshot AND invalidates the cache', () => {
|
|
315
|
+
// Setting without reloading would leave the live process on a stale answer —
|
|
316
|
+
// the exact class of bug this feature exists to remove.
|
|
317
|
+
try {
|
|
318
|
+
const before = modulesMod.modules();
|
|
319
|
+
assert.equal(before['dev-box'], false, 'precondition: off on a vanilla resolve');
|
|
320
|
+
const after = modulesMod.setModuleOverrides({ 'dev-box': true });
|
|
321
|
+
assert.equal(after['dev-box'], true, 'the returned set reflects the override');
|
|
322
|
+
assert.equal(modulesMod.modules()['dev-box'], true, 'and so does the live singleton');
|
|
323
|
+
assert.equal(moduleOverrides()['dev-box'], true);
|
|
324
|
+
} finally {
|
|
325
|
+
modulesMod._resetForTests();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('1002857: _resetForTests clears the override snapshot too', () => {
|
|
330
|
+
modulesMod.setModuleOverrides({ 'dev-box': true });
|
|
331
|
+
modulesMod._resetForTests();
|
|
332
|
+
assert.deepEqual(moduleOverrides(), {}, 'a leaked snapshot would cross-contaminate tests');
|
|
333
|
+
});
|