@bongos/core 1.20.77 → 1.20.78
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 +79 -54
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +70 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +22 -14
- package/docs/copy-registry.json +125 -41
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +5 -1
- package/docs/page-readings.json +150 -137
- package/docs/recipes/upgrading-the-core.md +11 -2
- package/modules/copy-desk/artist-stats.js +15 -1
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +41 -0
- package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
- package/modules/hall-ui/public/deploy.states.json +1 -1
- package/modules/hall-ui/public/profile-crafts.js +231 -0
- package/modules/hall-ui/public/profile.css +60 -0
- package/modules/hall-ui/public/profile.html +31 -12
- package/modules/hall-ui/public/profile.js +180 -75
- package/modules/hall-ui/public/profile.states.json +34 -0
- package/modules/hall-ui/public/sky-draw.js +66 -39
- package/modules/hall-ui/public/sky-interaction.js +1 -1
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/hall-ui/public/thinking.css +52 -24
- package/modules/hall-ui/records/profile-roles.md +18 -0
- package/modules/npm-release/public/work.js +72 -0
- package/modules/npm-release/work.js +67 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/gds/update-channel.js +68 -1
- package/scripts/gds/update-sweep.js +100 -4
- package/scripts/migrate.sh +6 -2
- package/src/module-api.js +1 -1
- package/tests/hall_sky.mjs +77 -0
- package/tests/helpers.mjs +32 -7
- package/tests/migrate_applied_check.mjs +94 -0
- package/tests/npm_release_page.mjs +67 -0
- package/tests/npm_release_work.mjs +82 -0
- package/tests/profile_activity.mjs +23 -15
- package/tests/profile_role_headline.mjs +4 -4
- package/tests/profile_role_tabs.mjs +163 -0
- package/tests/update_channel.mjs +80 -1
- package/tests/update_subscription_engine.mjs +99 -0
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
// instance's OWN core says this upgrade rewrites (laneGeneratedPaths). Anything outside that set is
|
|
52
52
|
// still a human's, and still stops us.
|
|
53
53
|
//
|
|
54
|
+
// ── WHICH VERSIONS AN INSTANCE TAKES AT ALL (task 1004297, ADR 0360) ───────────────────────────────
|
|
55
|
+
// Each roster entry has a FOLLOW beside its channel. `released` (the default) takes only versions at
|
|
56
|
+
// or below the registry's `latest` label — what the owner has released to other projects; the
|
|
57
|
+
// platform's own hall is set to `candidates` and takes every version as soon as it is published, so
|
|
58
|
+
// Bongos runs each one before anyone else is offered it. An instance that follows releases and whose
|
|
59
|
+
// released label cannot be read is SKIPPED, never upgraded on a guess.
|
|
60
|
+
//
|
|
54
61
|
// Usage:
|
|
55
62
|
// node scripts/gds/update-sweep.js # dry-run (plan only)
|
|
56
63
|
// node scripts/gds/update-sweep.js --apply # actually upgrade
|
|
@@ -472,6 +479,83 @@ function preflightFlagsFor(pinMode) {
|
|
|
472
479
|
|
|
473
480
|
// ── the sweep ────────────────────────────────────────────────────────────────────────────────────
|
|
474
481
|
|
|
482
|
+
// ── A VERSION THAT FAILED HERE IS NOT RETRIED EVERY SWEEP (task 1004297, ADR 0360 D4) ─────────────
|
|
483
|
+
// At a daily cadence a version that failed its health check was retried once a day. At the 15-minute
|
|
484
|
+
// cadence the platform now runs, the same version would be installed, fail, restart the service and
|
|
485
|
+
// roll back every quarter of an hour until something newer was published — an outage generator. So a
|
|
486
|
+
// version whose upgrade failed here is HELD for a day for that instance: the sweep skips it and says
|
|
487
|
+
// so, and any NEWER version is taken at once (it is the fix). The hold expires so a transient failure
|
|
488
|
+
// (a registry blip, a full disk since cleared) is retried rather than refused forever.
|
|
489
|
+
//
|
|
490
|
+
// The record lives in the sweep's own config home, never in an instance's tree: a file written there
|
|
491
|
+
// would be "a human's work" to pinState and wedge the lane it exists to protect.
|
|
492
|
+
const REFUSAL_HOLD_MS = 24 * 60 * 60 * 1000;
|
|
493
|
+
const REFUSALS_FILE = 'core-update-refused.json';
|
|
494
|
+
|
|
495
|
+
function refusalsPath() {
|
|
496
|
+
try { return path.join(require('../../src/instance-config.js').configHome(), REFUSALS_FILE); } catch { return null; }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readRefusals(file, readFile = fs.readFileSync) {
|
|
500
|
+
if (!file) return {};
|
|
501
|
+
try {
|
|
502
|
+
const j = JSON.parse(readFile(file, 'utf8'));
|
|
503
|
+
return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
|
|
504
|
+
} catch { return {}; }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The versions held for `slug` at `now`: a Map version → { at, reason }, only those still inside
|
|
508
|
+
// the hold. PURE.
|
|
509
|
+
function heldVersions(refusals, slug, now) {
|
|
510
|
+
const out = new Map();
|
|
511
|
+
const rows = refusals && Array.isArray(refusals[slug]) ? refusals[slug] : [];
|
|
512
|
+
for (const r of rows) {
|
|
513
|
+
const at = r && Date.parse(r.at);
|
|
514
|
+
if (r && typeof r.version === 'string' && Number.isFinite(at) && now - at < REFUSAL_HOLD_MS) out.set(r.version, { at: r.at, reason: r.reason || null });
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Record that `version` failed on `slug`. Keeps only rows still inside the hold, so the file cannot
|
|
520
|
+
// grow without bound. Best-effort: a record that cannot be written costs one retry, never the sweep.
|
|
521
|
+
function recordRefusal({ file, slug, version, reason, now = Date.now(), readFile = fs.readFileSync, writeFile = fs.writeFileSync, mkdir = fs.mkdirSync } = {}) {
|
|
522
|
+
if (!file || !slug || !version) return false;
|
|
523
|
+
try {
|
|
524
|
+
const all = readRefusals(file, readFile);
|
|
525
|
+
const keep = [...heldVersions(all, slug, now)].filter(([v]) => v !== version).map(([v, x]) => ({ version: v, at: x.at, reason: x.reason }));
|
|
526
|
+
all[slug] = [...keep, { version, at: new Date(now).toISOString(), reason: reason || null }];
|
|
527
|
+
mkdir(path.dirname(file), { recursive: true });
|
|
528
|
+
writeFile(file, JSON.stringify(all, null, 2) + '\n');
|
|
529
|
+
return true;
|
|
530
|
+
} catch { return false; }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// What this instance should move to, and under which follow (task 1004297, ADR 0360). The channel
|
|
534
|
+
// bounds how far a jump may go; the follow bounds which published versions exist to jump to at all.
|
|
535
|
+
//
|
|
536
|
+
// `inst.follow` is absent only when the core this sweep resolved predates the axis. That core also
|
|
537
|
+
// predates any split between candidates and releases, so every published version IS a release and
|
|
538
|
+
// the old reading — all of them — is the right one. With the axis present, `released` asks the
|
|
539
|
+
// registry which version carries the `latest` label and SKIPS the instance when it cannot say: an
|
|
540
|
+
// instance that follows releases is never upgraded on a guess.
|
|
541
|
+
//
|
|
542
|
+
// Returns { follow, target } (target null = up to date) or { follow, skip: '<why>' }.
|
|
543
|
+
function followTarget({ inst, installed, channel, versions, env, held = new Map() }) {
|
|
544
|
+
const follow = inst.follow || 'candidates';
|
|
545
|
+
let admitted = versions;
|
|
546
|
+
if (follow === 'released' && typeof channel.readReleaseTags === 'function') {
|
|
547
|
+
const tags = channel.readReleaseTags({ cwd: inst.dir, env });
|
|
548
|
+
if (!tags.ok) return { follow, skip: `could not read which version is released (${tags.error})` };
|
|
549
|
+
admitted = channel.versionsForFollow({ follow, available: admitted, released: tags.released });
|
|
550
|
+
}
|
|
551
|
+
const pick = (list) => channel.resolveChannelTarget({ installed, channel: inst.channel, available: list }) || null;
|
|
552
|
+
const target = pick(admitted.filter((v) => !held.has(v)));
|
|
553
|
+
// A hold is named only when it is what changed the answer: the version this instance would have
|
|
554
|
+
// taken without it.
|
|
555
|
+
const unheld = pick(admitted);
|
|
556
|
+
return { follow, target, held: unheld && unheld !== target && held.has(unheld) ? { version: unheld, ...held.get(unheld) } : null };
|
|
557
|
+
}
|
|
558
|
+
|
|
475
559
|
// `instanceRoot` is the default roster root when no --instance is given: the subscribe.js shim passes
|
|
476
560
|
// its own instance root; run directly, it is the working directory.
|
|
477
561
|
function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
|
|
@@ -503,6 +587,8 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
|
|
|
503
587
|
if (only && roster.length === 0) console.error(` ! --only ${only} matched no subscribed instance`);
|
|
504
588
|
|
|
505
589
|
let upgraded = 0, skipped = 0, failed = 0, upToDate = 0, healed = 0;
|
|
590
|
+
const refusalsFile = refusalsPath();
|
|
591
|
+
const refusals = readRefusals(refusalsFile);
|
|
506
592
|
|
|
507
593
|
for (const inst of roster) {
|
|
508
594
|
const label = inst.slug;
|
|
@@ -574,8 +660,12 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
|
|
|
574
660
|
const listed = channel.listAvailableVersions({ cwd: inst.dir, env: { ...process.env, ...inst.env } });
|
|
575
661
|
if (!listed.ok) { console.error(` ! ${label}: could not list registry versions (${listed.error}) — skipped`); skipped++; continue; }
|
|
576
662
|
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
663
|
+
const aim = followTarget({ inst, installed, channel, versions: listed.versions, env: { ...process.env, ...inst.env }, held: heldVersions(refusals, label, Date.now()) });
|
|
664
|
+
if (aim.skip) { console.error(` ! ${label}: ${aim.skip} — skipped`); skipped++; continue; }
|
|
665
|
+
if (aim.held) console.log(` • ${label}: ${aim.held.version} failed here at ${aim.held.at} — held for a day, or until a newer version is published`);
|
|
666
|
+
const target = aim.target;
|
|
667
|
+
const pace = `channel: ${inst.channel}, follows: ${aim.follow}`;
|
|
668
|
+
if (!target) { console.log(` ✓ ${label}: up to date on ${installed} (${pace})`); upToDate++; continue; }
|
|
579
669
|
|
|
580
670
|
const { script: upgradeScript, source: scriptSource } = resolveUpgradeScript(inst.dir, { fallback: sweepingCoreUpgradeScript });
|
|
581
671
|
if (!upgradeScript) { console.error(` ! ${label}: no upgrade.js resolvable for this instance — skipped`); skipped++; continue; }
|
|
@@ -590,7 +680,7 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
|
|
|
590
680
|
// promise a policy the real sweep will not use (task 1004122). It read `canCommitPin` alone
|
|
591
681
|
// before, and so said "--commit-pin" for a co-tenant that was never going to get it.
|
|
592
682
|
const dryPinFlags = [...pinFlagsFor({ pinMode, canCommitPin, canPinNoPush }), ...preflightFlagsFor(pinMode)];
|
|
593
|
-
console.log(` → ${label}: would upgrade ${installed} → ${target} (
|
|
683
|
+
console.log(` → ${label}: would upgrade ${installed} → ${target} (${pace}, registry, via the ${scriptSource} core${dryPinFlags.length ? `, ${dryPinFlags.join(' ')}` : ''})`);
|
|
594
684
|
console.log(` engine: ${dryEngine}`);
|
|
595
685
|
continue;
|
|
596
686
|
}
|
|
@@ -656,6 +746,12 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
|
|
|
656
746
|
|
|
657
747
|
if (res.status !== 0) {
|
|
658
748
|
console.error(` ✖ ${label}: upgrade to ${target} exited ${res.status == null ? '(signal)' : res.status} — see output above (auto-rollback runs on install/migrate/health failure)`);
|
|
749
|
+
// Hold the version unless it actually went in (a failure AFTER a good install, such as an
|
|
750
|
+
// unpushed pin, is not the version's fault and must not stop it being retried).
|
|
751
|
+
if (readInstalledCoreVersion(inst.dir) !== target) {
|
|
752
|
+
const held = recordRefusal({ file: refusalsFile, slug: label, version: target, reason: `exit ${res.status == null ? 'signal' : res.status}` });
|
|
753
|
+
console.error(` ${held ? `${target} is held for this instance for a day; a newer version is still taken at once.` : 'could not record the hold — the next sweep will try this version again.'}`);
|
|
754
|
+
}
|
|
659
755
|
// A rollback restores the OLD pin, which can itself leave the tree dirty. Tidy it so one bad
|
|
660
756
|
// patch does not wedge every future sweep.
|
|
661
757
|
const after = pinState(inst.dir, spawnSync, generatedAfter);
|
|
@@ -714,4 +810,4 @@ if (require.main === module) {
|
|
|
714
810
|
catch (e) { console.error(`core-update-subscription: fatal — ${e && e.message ? e.message : e}`); process.exit(1); }
|
|
715
811
|
}
|
|
716
812
|
|
|
717
|
-
module.exports = { main, coreModulePathFrom, CORE_PKG_NAMES, pinState, laneGeneratedPaths, commitPin, preflightFlagsFor, resolveUpgradeScript, supportsCommitPin, supportsPinNoPush, pinFlagsFor, goLiveTargetFor, resolveGoLive, goLiveEngineFor, needsCoTenantMode, pinModeOf, healDecision, rawTopologyFor, PIN_FILES, PIN_MODES, GO_LIVE_REQUIRED };
|
|
813
|
+
module.exports = { main, followTarget, heldVersions, recordRefusal, readRefusals, REFUSAL_HOLD_MS, coreModulePathFrom, CORE_PKG_NAMES, pinState, laneGeneratedPaths, commitPin, preflightFlagsFor, resolveUpgradeScript, supportsCommitPin, supportsPinNoPush, pinFlagsFor, goLiveTargetFor, resolveGoLive, goLiveEngineFor, needsCoTenantMode, pinModeOf, healDecision, rawTopologyFor, PIN_FILES, PIN_MODES, GO_LIVE_REQUIRED };
|
package/scripts/migrate.sh
CHANGED
|
@@ -218,7 +218,11 @@ apply_migration() {
|
|
|
218
218
|
MIGRATIONS_APPLIED=$((MIGRATIONS_APPLIED + 1))
|
|
219
219
|
return
|
|
220
220
|
fi
|
|
221
|
-
|
|
221
|
+
# NO PIPE (task 1004488). Under pipefail, `echo "$APPLIED" | grep -q` fails whenever grep -q
|
|
222
|
+
# matches and exits before echo has written the whole list (echo dies of SIGPIPE), so an applied
|
|
223
|
+
# migration read as NOT applied and was re-run: a random handful per deploy on the droplet.
|
|
224
|
+
# A here-string has no writer to kill. -x whole line, -F literal: a stem is not a pattern.
|
|
225
|
+
if grep -qxF -- "$version" <<< "$APPLIED"; then
|
|
222
226
|
echo "skip $version (already applied)"
|
|
223
227
|
MIGRATIONS_ALREADY=$((MIGRATIONS_ALREADY + 1))
|
|
224
228
|
return
|
|
@@ -343,7 +347,7 @@ for root in "$CORE_ROOT" "$INSTANCE_ROOT"; do
|
|
|
343
347
|
mig_dir="${mod_dir}migrations"
|
|
344
348
|
[ -d "$mig_dir" ] || continue
|
|
345
349
|
# Check if this module is enabled
|
|
346
|
-
if !
|
|
350
|
+
if ! grep -qF -- " $mod_key " <<< " $NODE_ENABLED_MODULES "; then
|
|
347
351
|
echo "module $mod_key disabled — skipping ${mig_dir}/*.sql"
|
|
348
352
|
continue
|
|
349
353
|
fi
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.78'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/hall_sky.mjs
CHANGED
|
@@ -513,4 +513,81 @@ await test('a ring carries its UAT state in the goal page words, and says it on
|
|
|
513
513
|
assert.equal((src.match(/esc\(c\.uatLabel\)/g) || []).length, 2, 'the hover card and the panel both print the label, escaped');
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
// ═══ light mode (task 1004500) ════════════════════════════════════════════════
|
|
517
|
+
|
|
518
|
+
// The sky follows the hall's theme. Dark is the template's set on the stage;
|
|
519
|
+
// light overrides it, written twice (html[data-theme] once shell.js runs, the
|
|
520
|
+
// media query for first paint) — the pair style.css keeps for its dark tier,
|
|
521
|
+
// and the same drift risk, so the same lockstep.
|
|
522
|
+
const SKY_CSS = FS.readFileSync('modules/hall-ui/public/thinking.css', 'utf8').replace(/\r\n/g, '\n');
|
|
523
|
+
const declLines = (body) => body.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
524
|
+
const declNames = (body) => new Set([...body.matchAll(/(--[\w-]+|color-scheme)\s*:/g)].map((m) => m[1]));
|
|
525
|
+
const skyBlocks = () => {
|
|
526
|
+
const dark = /^body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\}/m.exec(SKY_CSS);
|
|
527
|
+
const light = /^html\[data-theme="light"\] body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\}/m.exec(SKY_CSS);
|
|
528
|
+
const twin = /@media \(prefers-color-scheme: light\) \{\n\s*html:not\(\[data-theme\]\) body\[data-page\] \.sky-stage \{\n([\s\S]*?)\n\s*\}\n\}/.exec(SKY_CSS);
|
|
529
|
+
return { dark: dark && dark[1], light: light && light[1], twin: twin && twin[1] };
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
await test('LIGHT IS WRITTEN TWICE AND THE TWO ARE ONE — the data-theme block and its first-paint twin declare the same lines', () => {
|
|
533
|
+
const { dark, light, twin } = skyBlocks();
|
|
534
|
+
assert.ok(dark, 'the dark (template) token block is found');
|
|
535
|
+
assert.ok(light, 'the html[data-theme="light"] block is found');
|
|
536
|
+
assert.ok(twin, 'the prefers-color-scheme: light twin is found');
|
|
537
|
+
same(declLines(twin), declLines(light), 'the twin is the data-theme block, line for line');
|
|
538
|
+
assert.ok(declLines(light).length >= 6, 'and the block is not vacuously empty');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await test('light re-declares every colour the dark set declares, and every channel is three bytes', () => {
|
|
542
|
+
const { dark, light } = skyBlocks();
|
|
543
|
+
const type = new Set(['--label', '--mono', '--face']); // the faces are not a theme
|
|
544
|
+
const want = [...declNames(dark)].filter((n) => !type.has(n));
|
|
545
|
+
assert.ok(want.length >= 15, `the dark set names its colours (${want.length})`);
|
|
546
|
+
const have = declNames(light);
|
|
547
|
+
same(want.filter((n) => !have.has(n)), [], 'no dark colour is left for light to inherit');
|
|
548
|
+
for (const [name, body] of [['dark', dark], ['light', light]]) {
|
|
549
|
+
const chans = [...body.matchAll(/(--sky-[\w-]+-rgb)\s*:\s*([^;]+);/g)];
|
|
550
|
+
assert.ok(chans.length >= 5, `${name} declares the canvas's channels`);
|
|
551
|
+
for (const [, n, v] of chans) {
|
|
552
|
+
const parts = v.split(',').map((x) => Number(x.trim()));
|
|
553
|
+
assert.ok(parts.length === 3 && parts.every((x) => Number.isInteger(x) && x >= 0 && x <= 255), `${name} ${n} is r,g,b (got "${v}")`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// What the stage's computed style answers in each theme: the two blocks above,
|
|
559
|
+
// read the way the browser would.
|
|
560
|
+
const stageStyles = (body) => Object.fromEntries([...body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
|
|
561
|
+
|
|
562
|
+
await test('THE CANVAS FOLLOWS THE TOGGLE — a theme flip re-reads the stage and repaints, with no reload', async () => {
|
|
563
|
+
const { dark, light } = skyBlocks();
|
|
564
|
+
const styles = stageStyles(dark);
|
|
565
|
+
const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions(), styles });
|
|
566
|
+
const SKY = p.SKY(); const acc = SKY.ACC;
|
|
567
|
+
assert.ok(p.themeObservers() >= 1, 'the page watches html[data-theme]');
|
|
568
|
+
p.frame();
|
|
569
|
+
same(SKY.INK, [255, 255, 255], 'dark: the ink is the template white');
|
|
570
|
+
same(SKY.VOID, [0, 0, 0], 'dark: the void is black');
|
|
571
|
+
same(SKY.tone([255, 238, 222]), [255, 238, 222], 'dark: a thought keeps its temperature');
|
|
572
|
+
|
|
573
|
+
Object.assign(styles, stageStyles(light));
|
|
574
|
+
p.S().dirty = false;
|
|
575
|
+
p.setTheme('light');
|
|
576
|
+
assert.equal(!!p.S().dirty, true, 'the flip marks the sky for a redraw');
|
|
577
|
+
p.frame();
|
|
578
|
+
same(SKY.INK, stageStyles(light)['--sky-ink-rgb'].split(',').map(Number), 'light: the ink is the stage\'s');
|
|
579
|
+
same(SKY.VOID, stageStyles(light)['--sky-void-rgb'].split(',').map(Number), 'light: the void is paper');
|
|
580
|
+
same(SKY.ACC, stageStyles(light)['--sky-accent-rgb'].split(',').map(Number), 'light: the accent darkens');
|
|
581
|
+
assert.equal(SKY.ACC === acc, true, 'refilled in place, so SKY.ACC is still the object every file holds');
|
|
582
|
+
const k = Number(stageStyles(light)['--sky-temp']);
|
|
583
|
+
same(SKY.tone([255, 238, 222]), [255, 238, 222].map((v) => Math.round(v * k)), 'light: a near-white thought darkens to read on paper');
|
|
584
|
+
assert.equal(SKY.ink(0.5), `rgba(${SKY.INK.join(',')},0.5)`, 'ink() paints in the new ink');
|
|
585
|
+
|
|
586
|
+
Object.assign(styles, stageStyles(dark));
|
|
587
|
+
p.setTheme('dark');
|
|
588
|
+
p.frame();
|
|
589
|
+
same(SKY.INK, [255, 255, 255], 'and back: the white returns');
|
|
590
|
+
assert.equal(SKY.ink(0.5), 'rgba(255,255,255,0.5)', 'ink() forgot the light strings');
|
|
591
|
+
});
|
|
592
|
+
|
|
516
593
|
summary();
|
package/tests/helpers.mjs
CHANGED
|
@@ -518,9 +518,17 @@ export function shallowCloneReason(root = ROOT) {
|
|
|
518
518
|
// each caller — is the /inbox/by-builder/ payload to answer with.
|
|
519
519
|
const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
520
520
|
const PROFILE_ROLES_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
521
|
+
// The Activity tabs draw from these two (task 1004435), loaded first as the page does.
|
|
522
|
+
const IDEA_OBJECTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea-objects.js'), 'utf8');
|
|
523
|
+
const PROFILE_CRAFTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-crafts.js'), 'utf8');
|
|
521
524
|
// Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
|
|
522
525
|
// ledger's rowHtml, and return what it rendered plus every request it made.
|
|
523
|
-
|
|
526
|
+
//
|
|
527
|
+
// `roles` is the profile's roles.shown, main first (task 1004435): one Activity
|
|
528
|
+
// tab each. The default leads with the ideator, so the trail is the first tab
|
|
529
|
+
// and a suite that reads it needs no switch; switchCraft('engineer') opens the
|
|
530
|
+
// works and goals, switchCraft('artist') the approved pages.
|
|
531
|
+
export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator', 'engineer'], artistPages = [] } = {}) {
|
|
524
532
|
const els = new Map();
|
|
525
533
|
const makeEl = () => ({
|
|
526
534
|
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
@@ -551,12 +559,15 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
551
559
|
: { status: 401, ok: false, data: null };
|
|
552
560
|
}
|
|
553
561
|
if (/\/builders\/\d+\/profile$/.test(url)) {
|
|
554
|
-
return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
|
|
562
|
+
return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [], roles: { main: roles[0], shown: roles } } };
|
|
555
563
|
}
|
|
556
564
|
if (url.includes('/activity?')) {
|
|
557
565
|
const isGoals = url.includes('section=goals');
|
|
558
566
|
return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
|
|
559
567
|
}
|
|
568
|
+
if (url.includes('/copy-desk/artists/')) {
|
|
569
|
+
return { status: 200, ok: true, data: { rows: artistPages, total: artistPages.length } };
|
|
570
|
+
}
|
|
560
571
|
if (url.includes('/inbox/by-builder/')) {
|
|
561
572
|
return { status: 200, ok: true, data: { ideas: trail, total: trail.length } };
|
|
562
573
|
}
|
|
@@ -589,7 +600,7 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
589
600
|
let current = tabs[0].id;
|
|
590
601
|
tabsOnChange = onChange;
|
|
591
602
|
onChange(current);
|
|
592
|
-
return { active: () => current, activate(id) { current = id; onChange(id); } };
|
|
603
|
+
return { active: () => current, activate(id) { current = id; onChange(id); }, setCount() {} };
|
|
593
604
|
},
|
|
594
605
|
shipVisualThumbHtml: () => '',
|
|
595
606
|
};
|
|
@@ -616,17 +627,20 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
616
627
|
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
|
|
617
628
|
localStorage: { getItem: () => null, setItem() {} },
|
|
618
629
|
addEventListener() {},
|
|
630
|
+
matchMedia: () => ({ matches: false }),
|
|
619
631
|
};
|
|
620
632
|
|
|
621
633
|
const sandbox = {
|
|
622
634
|
window: windowObj, document: documentObj, location: windowObj.location,
|
|
623
|
-
console, setTimeout, clearTimeout, URLSearchParams,
|
|
635
|
+
console, setTimeout, clearTimeout, URLSearchParams, matchMedia: windowObj.matchMedia,
|
|
624
636
|
};
|
|
625
637
|
sandbox.globalThis = sandbox;
|
|
626
638
|
vm.createContext(sandbox);
|
|
627
|
-
// profile.js draws the header from profile-roles.js (task 1004434)
|
|
628
|
-
// page loads first.
|
|
639
|
+
// profile.js draws the header from profile-roles.js (task 1004434) and the
|
|
640
|
+
// Activity tabs from profile-crafts.js (task 1004435), which the page loads first.
|
|
629
641
|
vm.runInContext(PROFILE_ROLES_JS, sandbox, { filename: 'profile-roles.js' });
|
|
642
|
+
vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
|
|
643
|
+
vm.runInContext(PROFILE_CRAFTS_JS, sandbox, { filename: 'profile-crafts.js' });
|
|
630
644
|
vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
|
|
631
645
|
|
|
632
646
|
const htmlFor = (mountId) => {
|
|
@@ -789,6 +803,7 @@ export function bootSkyPage({
|
|
|
789
803
|
mine = null, sky = null, versions = null, me = null, template = undefined,
|
|
790
804
|
viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
|
|
791
805
|
innerWidth = 1280, innerHeight = 800, reduce = true, page = 'thinking',
|
|
806
|
+
styles = null,
|
|
792
807
|
} = {}) {
|
|
793
808
|
// ── the DOM ──────────────────────────────────────────────────────────────
|
|
794
809
|
const windowListeners = new Map();
|
|
@@ -927,14 +942,22 @@ export function bootSkyPage({
|
|
|
927
942
|
clearTimeout: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
|
|
928
943
|
};
|
|
929
944
|
class Event { constructor(type) { this.type = type; } preventDefault() {} }
|
|
945
|
+
// The theme seam (task 1004500): `styles` is the stage's computed custom
|
|
946
|
+
// properties (mutable, so a test can change them before a flip), and every
|
|
947
|
+
// MutationObserver the page makes is kept so `setTheme` can fire it the way
|
|
948
|
+
// shell.js writing html[data-theme] would. No `styles` means no
|
|
949
|
+
// getComputedStyle at all, the sandbox every other case has always run in.
|
|
950
|
+
const themeObservers = [];
|
|
951
|
+
class MutationObserver { constructor(fn) { this.fn = fn; } observe(target, opts) { themeObservers.push({ fn: this.fn, target, opts }); } disconnect() {} }
|
|
930
952
|
|
|
931
953
|
const sandbox = {
|
|
932
954
|
window: windowObj, document: documentObj, location: windowObj.location, localStorage: windowObj.localStorage,
|
|
933
955
|
innerWidth, innerHeight, devicePixelRatio: 1, matchMedia: windowObj.matchMedia, performance: windowObj.performance,
|
|
934
956
|
requestAnimationFrame: windowObj.requestAnimationFrame, addEventListener: windowObj.addEventListener,
|
|
935
957
|
setTimeout: windowObj.setTimeout, clearTimeout: windowObj.clearTimeout,
|
|
936
|
-
console, Event, URLSearchParams, Math, Number, Date, JSON, Map, Set, Promise, Object, Array, String, Infinity, NaN,
|
|
958
|
+
MutationObserver, console, Event, URLSearchParams, Math, Number, Date, JSON, Map, Set, Promise, Object, Array, String, Infinity, NaN,
|
|
937
959
|
};
|
|
960
|
+
if (styles) sandbox.getComputedStyle = (el) => ({ getPropertyValue: (name) => (el && String(el.className || '').split(/\s+/).includes('sky-stage') && name in styles ? styles[name] : '') });
|
|
938
961
|
sandbox.globalThis = sandbox;
|
|
939
962
|
vm.createContext(sandbox);
|
|
940
963
|
for (const [name, src] of SKY_SRC) vm.runInContext(src, sandbox, { filename: name });
|
|
@@ -970,6 +993,8 @@ export function bootSkyPage({
|
|
|
970
993
|
submit: () => { byId('file').fire('submit', new Event('submit')); },
|
|
971
994
|
click: (id) => { const el = byId(id); if (!el) throw new Error(`no element #${id}`); el.fire('click', new Event('click')); },
|
|
972
995
|
frame: () => { if (raf) raf(Date.now()); },
|
|
996
|
+
setTheme: (t) => { documentObj.documentElement.dataset.theme = t; for (const o of themeObservers) if (o.target === documentObj.documentElement && (!o.opts.attributeFilter || o.opts.attributeFilter.includes('data-theme'))) o.fn([{ type: 'attributes', attributeName: 'data-theme' }]); },
|
|
997
|
+
themeObservers: () => themeObservers.length,
|
|
973
998
|
ready: () => root.dataset.skyReady === '1',
|
|
974
999
|
};
|
|
975
1000
|
return out;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// tests/migrate_applied_check.mjs — migrate.sh never re-runs a migration that is already applied
|
|
2
|
+
// (task 1004488).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. migrate.sh runs under `set -euo pipefail`, and it used to ask "is this stem
|
|
5
|
+
// applied?" with `echo "$APPLIED" | grep -q "^stem$"`. grep -q exits on its first match; if echo
|
|
6
|
+
// has not finished writing the list, echo dies of SIGPIPE, pipefail fails the pipeline, and an
|
|
7
|
+
// applied migration reads as NOT applied. On the droplet that happened to a random handful of the
|
|
8
|
+
// ~300 checks in a run (task 1004388: 091, then 145 + 177, then 037 + 059 — all recorded as
|
|
9
|
+
// applied), so a deploy could silently re-run a data migration against the live database.
|
|
10
|
+
//
|
|
11
|
+
// HOW THIS MAKES A RACE DETERMINISTIC. A stand-in psql answers the "SELECT version FROM
|
|
12
|
+
// schema_migrations" query with every real core stem FIRST, then enough padding to overflow any
|
|
13
|
+
// pipe buffer. echo then blocks on a full pipe while grep -q has already matched and exited, so
|
|
14
|
+
// the old check fails for every stem, every run. The stub records each `-f <file>` it is asked to
|
|
15
|
+
// apply; with every stem recorded as applied, there must be none.
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test tests/migrate_applied_check.mjs
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, chmodSync, rmSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const MIGRATE = path.join(REPO_ROOT, 'scripts', 'migrate.sh');
|
|
28
|
+
|
|
29
|
+
// migrate.sh is POSIX bash; the stub psql below is a shell script. Windows CI runs the suite too.
|
|
30
|
+
const skip = process.platform === 'win32' ? 'POSIX shell stub' : false;
|
|
31
|
+
|
|
32
|
+
const sqlStems = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.sql')).map((f) => f.slice(0, -4)) : []);
|
|
33
|
+
|
|
34
|
+
// The core stems: the ones the core loop must skip.
|
|
35
|
+
function stems() {
|
|
36
|
+
return sqlStems(path.join(REPO_ROOT, 'migrations')).sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Every module's stems too, so the module loop that follows finds them applied as well.
|
|
40
|
+
function moduleStems() {
|
|
41
|
+
const root = path.join(REPO_ROOT, 'modules');
|
|
42
|
+
return readdirSync(root).flatMap((m) => sqlStems(path.join(root, m, 'migrations'))).sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rig() {
|
|
46
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'migrate-applied-'));
|
|
47
|
+
const list = path.join(dir, 'applied.txt');
|
|
48
|
+
const log = path.join(dir, 'applied-files.log');
|
|
49
|
+
// Real stems first (so grep -q matches early), then ~400 KB of padding (so echo is still writing).
|
|
50
|
+
const padding = Array.from({ length: 8000 }, (_, i) => `zz_padding_${String(i).padStart(6, '0')}_not_a_real_migration_stem`);
|
|
51
|
+
writeFileSync(list, [...stems(), ...moduleStems(), ...padding].join('\n') + '\n');
|
|
52
|
+
const psql = path.join(dir, 'psql');
|
|
53
|
+
writeFileSync(psql, [
|
|
54
|
+
'#!/usr/bin/env bash',
|
|
55
|
+
'# Stand-in psql: the applied list for the schema_migrations read, a log line for every -f.',
|
|
56
|
+
'prev=""',
|
|
57
|
+
'for a in "$@"; do',
|
|
58
|
+
` if [ "$prev" = "-f" ]; then echo "$a" >> ${JSON.stringify(log)}; fi`,
|
|
59
|
+
' case "$a" in *"SELECT version FROM schema_migrations"*)' + ` cat ${JSON.stringify(list)};; esac`,
|
|
60
|
+
' prev="$a"',
|
|
61
|
+
'done',
|
|
62
|
+
'exit 0',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n'));
|
|
65
|
+
chmodSync(psql, 0o755);
|
|
66
|
+
return { dir, log };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('a migration recorded as applied is skipped, even when the applied list overflows a pipe buffer', { skip }, () => {
|
|
70
|
+
const { dir, log } = rig();
|
|
71
|
+
try {
|
|
72
|
+
const env = { ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, PGDATABASE: 'stub_db', DATABASE_URL: '',
|
|
73
|
+
PGHOST: path.join(dir, 'no-such-socket-dir'), INIT_CWD: REPO_ROOT };
|
|
74
|
+
// The run goes on past the core loop (modules, gates) and may stop there against a stub; the core
|
|
75
|
+
// loop's own output is what this test reads, so the exit code is not asserted.
|
|
76
|
+
const r = spawnSync('bash', [MIGRATE], { cwd: REPO_ROOT, env, encoding: 'utf8', timeout: 120000 });
|
|
77
|
+
const out = String(r.stdout || '');
|
|
78
|
+
const skipped = (out.match(/^skip {3}/gm) || []).length;
|
|
79
|
+
assert.ok(skipped >= stems().length, `expected every core stem skipped, saw ${skipped} of ${stems().length}\n${out.slice(0, 2000)}\n${String(r.stderr || '').slice(-2000)}`);
|
|
80
|
+
const reapplied = (out.match(/^apply {2}(\S+)/gm) || []).map((l) => l.slice(7));
|
|
81
|
+
assert.deepEqual(reapplied, [], 'already-applied migrations were re-run: ' + reapplied.join(', '));
|
|
82
|
+
const files = existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter(Boolean).filter((f) => f.endsWith('.sql')) : [];
|
|
83
|
+
assert.deepEqual(files, [], 'psql -f was called for an applied migration');
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('migrate.sh asks "is it applied?" without a pipe', () => {
|
|
90
|
+
// The behavioural test above is the proof; this names the shape so a future edit that brings
|
|
91
|
+
// `echo ... | grep -q` back is refused at the line, not rediscovered on the droplet.
|
|
92
|
+
const src = readFileSync(MIGRATE, 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
93
|
+
assert.doesNotMatch(src, /echo [^\n|]*\|\s*grep -q/, 'an `echo … | grep -q` pipeline under pipefail can read a match as a miss');
|
|
94
|
+
});
|
|
@@ -181,3 +181,70 @@ test('a task title and a note line are quoted ledger data: their dashes render a
|
|
|
181
181
|
assert.ok(b.work().innerHTML.includes('>Deploy page, where the work is, phase 1</a>'));
|
|
182
182
|
assert.doesNotMatch(b.work().innerHTML, /Deploy page [—–]/);
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
// ---- this hall's own upgrades (task 1004297) ----------------------------------------
|
|
186
|
+
|
|
187
|
+
const withUpgrades = (over) => ({ ...FIXTURE, upgrades: { ok: true, rows: [] }, waiting: null, ...over });
|
|
188
|
+
const upgrade = (o) => ({ from: '1.19.1032', to: '1.19.1033', at: '2026-09-26T00:22:18.000Z', how: 'server', rolled_back: false, reason: null, ...o });
|
|
189
|
+
|
|
190
|
+
test('the upgrades group lists each move with who made it', async () => {
|
|
191
|
+
const b = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [
|
|
192
|
+
upgrade({}), upgrade({ from: '1.19.1030', to: '1.19.1032', how: 'door' }), upgrade({ from: '1.19.1029', to: '1.19.1030', how: 'sweep' }),
|
|
193
|
+
] } })));
|
|
194
|
+
await b.settle();
|
|
195
|
+
const html = b.work().innerHTML;
|
|
196
|
+
assert.match(html, /Upgrades on this hall/);
|
|
197
|
+
assert.match(html, />1\.19\.1032 → 1\.19\.1033</);
|
|
198
|
+
assert.match(html, /on the server/);
|
|
199
|
+
assert.match(html, /Upgrade pressed on \/deploy/);
|
|
200
|
+
assert.match(html, /the automatic update/);
|
|
201
|
+
assert.doesNotMatch(html, /was rolled back/, 'no warning when nothing rolled back');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a rolled-back upgrade is said plainly, and a rollback as the LAST move is a warning', async () => {
|
|
205
|
+
const back = upgrade({ from: '1.19.1033', to: '1.19.1034', rolled_back: true, reason: 'health check failed — after restart' });
|
|
206
|
+
const b = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [back, upgrade({})] } })));
|
|
207
|
+
await b.settle();
|
|
208
|
+
const html = b.work().innerHTML;
|
|
209
|
+
assert.match(html, />Tried 1\.19\.1033 → 1\.19\.1034</);
|
|
210
|
+
assert.match(html, /It did not come back healthy \(health check failed, after restart\), so the hall went back to 1\.19\.1033\./, 'the reason is ledger text: its dash renders as a comma');
|
|
211
|
+
assert.match(html, /<strong>The last upgrade here was rolled back\.<\/strong> 1\.19\.1034 was installed, did not come back healthy, and was undone\./);
|
|
212
|
+
|
|
213
|
+
assert.match(html, /This hall will not try it again for a day; the next newer version is installed as soon as it is published\./);
|
|
214
|
+
|
|
215
|
+
// The version still waits in the reading, but it is HELD by the sweep: "takes it on its next
|
|
216
|
+
// update" would be a promise the lane will not keep, so the rollback note speaks alone.
|
|
217
|
+
const heldBack = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [back] },
|
|
218
|
+
waiting: { versions: 1, first: '1.19.1034', since: new Date().toISOString(), minutes: 3, stalled: false } })));
|
|
219
|
+
await heldBack.settle();
|
|
220
|
+
assert.doesNotMatch(heldBack.work().innerHTML, /takes it on its next automatic update/);
|
|
221
|
+
|
|
222
|
+
const older = bootWork(() => ok(withUpgrades({ upgrades: { ok: true, rows: [upgrade({ to: '1.19.1035' }), back] } })));
|
|
223
|
+
await older.settle();
|
|
224
|
+
assert.doesNotMatch(older.work().innerHTML, /The last upgrade here was rolled back/, 'an old rollback is history, not a warning');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a stalled wait warns that the automation may have stopped; a fresh one is one quiet line', async () => {
|
|
228
|
+
const stalled = bootWork(() => ok(withUpgrades({ waiting: { versions: 3, first: '1.19.1041', since: '2026-09-26T08:00:00.000Z', minutes: 300, stalled: true } })));
|
|
229
|
+
await stalled.settle();
|
|
230
|
+
assert.match(stalled.work().innerHTML, /ov-note--warn"><p><strong>The automatic update may have stopped\.<\/strong> 1\.19\.1041 \(and 2 newer\) was published/);
|
|
231
|
+
|
|
232
|
+
const fresh = bootWork(() => ok(withUpgrades({ waiting: { versions: 1, first: '1.19.1042', since: new Date(Date.now() - 4 * 60000).toISOString(), minutes: 4, stalled: false } })));
|
|
233
|
+
await fresh.settle();
|
|
234
|
+
assert.doesNotMatch(fresh.work().innerHTML, /may have stopped/);
|
|
235
|
+
assert.match(fresh.work().innerHTML, /1\.19\.1042 was published 4 minutes ago; this hall takes it on its next automatic update\./);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('an unreadable ledger says so; a page with no ledger reading shows no group at all', async () => {
|
|
239
|
+
const bad = bootWork(() => ok(withUpgrades({ upgrades: { ok: false, error: 'this hall\'s upgrade ledger could not be read (<b>boom</b>)' } })));
|
|
240
|
+
await bad.settle();
|
|
241
|
+
assert.match(bad.work().innerHTML, /this hall's upgrade ledger could not be read \(<b>boom<\/b>\)\./);
|
|
242
|
+
|
|
243
|
+
const none = bootWork(() => ok({ ...FIXTURE, upgrades: null }));
|
|
244
|
+
await none.settle();
|
|
245
|
+
assert.doesNotMatch(none.work().innerHTML, /Upgrades on this hall/);
|
|
246
|
+
|
|
247
|
+
const empty = bootWork(() => ok(withUpgrades({})));
|
|
248
|
+
await empty.settle();
|
|
249
|
+
assert.match(empty.work().innerHTML, /No upgrade is recorded on this hall yet\./);
|
|
250
|
+
});
|
|
@@ -263,3 +263,85 @@ test('the handler passes the query through and answers 500 plainly when the read
|
|
|
263
263
|
assert.equal(status, 500);
|
|
264
264
|
assert.equal(err.error.code, 'work_read_failed');
|
|
265
265
|
});
|
|
266
|
+
|
|
267
|
+
// ---- this hall's own upgrades (task 1004297) --------------------------------------------
|
|
268
|
+
|
|
269
|
+
// The fixture pool refuses any query it was not written for; these cases answer the ledger
|
|
270
|
+
// read on top of it, so every earlier query is still held to the same strictness.
|
|
271
|
+
function withLedger(pool, answer) {
|
|
272
|
+
const inner = pool.query.bind(pool);
|
|
273
|
+
pool.query = async (sql, params) => {
|
|
274
|
+
if (/FROM core_upgrades/.test(sql)) {
|
|
275
|
+
pool.queries.push({ sql, params });
|
|
276
|
+
if (answer instanceof Error) throw answer;
|
|
277
|
+
return { rows: answer };
|
|
278
|
+
}
|
|
279
|
+
return inner(sql, params);
|
|
280
|
+
};
|
|
281
|
+
return pool;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
test('the upgrade ledger is read newest first, bounded, and each move says who made it', async () => {
|
|
285
|
+
const { read, pool } = setup();
|
|
286
|
+
withLedger(pool, [
|
|
287
|
+
{ from_version: '1.0.2', to_version: '1.0.3', applied_at: '2026-09-26T12:00:00Z', source: 'shell', note: null },
|
|
288
|
+
{ from_version: '1.0.1', to_version: '1.0.2', applied_at: '2026-09-25T12:00:00Z', source: 'owner-control', note: null },
|
|
289
|
+
{ from_version: '1.0.1', to_version: '1.0.2', applied_at: '2026-09-25T11:00:00Z', source: null, note: 'auto-subscription (patch)' },
|
|
290
|
+
]);
|
|
291
|
+
const d = await read();
|
|
292
|
+
assert.equal(d.upgrades.ok, true);
|
|
293
|
+
assert.deepEqual(d.upgrades.rows.map((r) => [r.from, r.to, r.how, r.rolled_back]), [
|
|
294
|
+
['1.0.2', '1.0.3', 'server', false],
|
|
295
|
+
['1.0.1', '1.0.2', 'door', false],
|
|
296
|
+
['1.0.1', '1.0.2', 'sweep', false],
|
|
297
|
+
]);
|
|
298
|
+
assert.equal(d.upgrades.rows[0].at, '2026-09-26T12:00:00.000Z');
|
|
299
|
+
const q = pool.queries.find((x) => /FROM core_upgrades/.test(x.sql));
|
|
300
|
+
assert.match(q.sql, /ORDER BY applied_at DESC, id DESC/);
|
|
301
|
+
assert.match(q.sql, /LIMIT \$1/);
|
|
302
|
+
assert.equal(q.params[0], 8);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('a rolled-back upgrade is flagged, with its reason and without the bookkeeping', async () => {
|
|
306
|
+
const { read, pool } = setup();
|
|
307
|
+
withLedger(pool, [{ from_version: '1.0.3', to_version: '1.0.4', applied_at: '2026-09-26T12:30:00Z', source: 'shell',
|
|
308
|
+
note: 'rolled_back: health check failed after restart (attempted 1.0.3 → 1.0.4, restored 1.0.3)' }]);
|
|
309
|
+
const r = (await read()).upgrades.rows[0];
|
|
310
|
+
assert.equal(r.rolled_back, true);
|
|
311
|
+
assert.equal(r.reason, 'health check failed after restart');
|
|
312
|
+
assert.equal(r.to, '1.0.4');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a ledger that cannot be read is reported as unreadable, never as "no upgrades"', async () => {
|
|
316
|
+
const { read, pool } = setup();
|
|
317
|
+
withLedger(pool, new Error('relation "core_upgrades" does not exist'));
|
|
318
|
+
const d = await read();
|
|
319
|
+
assert.equal(d.upgrades.ok, false);
|
|
320
|
+
assert.match(d.upgrades.error, /upgrade ledger could not be read \(relation "core_upgrades" does not exist\)/);
|
|
321
|
+
assert.ok(d.stages, 'and the rest of the page still reads');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('a published version waiting past the alarm says the automation may have stopped', async () => {
|
|
325
|
+
const { read, pool } = setup();
|
|
326
|
+
withLedger(pool, []);
|
|
327
|
+
const d = await read(); // runs 1.0.3; 1.0.4 was published 2026-09-25T10:00Z, 27 hours before `now`
|
|
328
|
+
assert.deepEqual(d.waiting, { versions: 2, first: '1.0.4', since: TIMES['1.0.4'], minutes: 27 * 60, stalled: true });
|
|
329
|
+
|
|
330
|
+
const fresh = await read({ now: () => Date.parse(TIMES['1.0.4']) + 10 * 60000 });
|
|
331
|
+
assert.equal(fresh.waiting.stalled, false, 'ten minutes is the system working, not stopped');
|
|
332
|
+
assert.equal(fresh.waiting.minutes, 10);
|
|
333
|
+
|
|
334
|
+
const current = await read({ live: '1.0.5' });
|
|
335
|
+
assert.equal(current.waiting, null, 'nothing newer: nothing waiting');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('another package has no "this hall" upgrades to show', async () => {
|
|
339
|
+
const { pool } = setup();
|
|
340
|
+
withLedger(pool, []);
|
|
341
|
+
const fg = fakeHttpsGet({ [`${ORIGIN}/other-pkg`]: { body: npmPackument({ versions: ['1.0.0'], latest: '1.0.0', times: {}, pkg: 'other-pkg' }) },
|
|
342
|
+
[`${ORIGIN}/other-pkg/-/other-pkg-1.0.0.tgz`]: { body: npmTarball({ notes: { '1.0.0': [] }, pkg: 'other-pkg' }) } });
|
|
343
|
+
const d = await readWork({ pool, pkg: 'other-pkg', get: fg.get, store: freshStore(), now: () => Date.parse('2026-09-26T13:00:00Z') });
|
|
344
|
+
assert.equal(d.upgrades, null);
|
|
345
|
+
assert.equal(d.waiting, null);
|
|
346
|
+
assert.ok(!pool.queries.some((x) => /FROM core_upgrades/.test(x.sql)), 'and the ledger is not read at all');
|
|
347
|
+
});
|