@bongos/core 1.19.924 → 1.19.926
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 +34 -24
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/idea.css +154 -0
- package/modules/hall-ui/public/idea.js +192 -0
- package/modules/ideas/inbox.js +1 -0
- package/modules/ideas/routes/inbox.js +24 -1
- package/modules/ideas/trail-removal.js +115 -0
- package/modules/ideas/trail.js +56 -21
- package/modules/provisioning/core-upgrade.js +6 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +39 -9
- package/scripts/gds/provision-repo.js +26 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +190 -0
- package/tests/core_upgrade_runner.mjs +94 -0
- package/tests/helpers.mjs +99 -0
- package/tests/idea_descent_surface.mjs +458 -0
|
@@ -91,6 +91,31 @@ const CORE_PKG_DIR = 'node_modules/@bongos/core';
|
|
|
91
91
|
// one constant so the backup dir owner + the backup unit's User= can't drift from it.
|
|
92
92
|
const APP_USER = 'lars';
|
|
93
93
|
function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
|
|
94
|
+
// WHERE THE INSTANCE THIS CORE SERVES ACTUALLY LIVES (task 1004123).
|
|
95
|
+
//
|
|
96
|
+
// REPO_ROOT is `path.resolve(__dirname, '..', '..')` — it points at whatever copy of
|
|
97
|
+
// the core is EXECUTING, which is not the same place as the instance being upgraded.
|
|
98
|
+
// Two shapes, and the code had only ever met the first:
|
|
99
|
+
//
|
|
100
|
+
// • a core CHECKOUT run in place (`/home/lars/cloudbongos`) — the instance root IS
|
|
101
|
+
// the checkout, so REPO_ROOT was right by coincidence;
|
|
102
|
+
// • a VENDORED core (`<instance>/node_modules/@bongos/core`) — REPO_ROOT is the
|
|
103
|
+
// package, and the instance is three directories up.
|
|
104
|
+
//
|
|
105
|
+
// The live control plane became the second shape when its runner unit was re-homed
|
|
106
|
+
// (`WorkingDirectory=/home/lars/cloudbongos-instance`, ExecStart into the vendored
|
|
107
|
+
// core), and nothing noticed: REPO_ROOT still resolved, just to the wrong tree. So
|
|
108
|
+
// `readInstalledCoreVersion` answered null instead of 1.19.917, every guard that
|
|
109
|
+
// reads `installed` silently stopped guarding, and an upgrade would have pinned and
|
|
110
|
+
// npm-installed INSIDE the core package while the instance's own pin stayed put.
|
|
111
|
+
//
|
|
112
|
+
// This is a string question, not a filesystem one: the vendored layout is the only
|
|
113
|
+
// way a core ends up under `node_modules/`, so the suffix IS the answer. Keeping it
|
|
114
|
+
// pure means the control-plane path is testable without a box.
|
|
115
|
+
function selfInstanceRoot(repoRoot = REPO_ROOT) {
|
|
116
|
+
const suffix = path.sep + CORE_PKG_DIR.split('/').join(path.sep);
|
|
117
|
+
return repoRoot.endsWith(suffix) ? repoRoot.slice(0, -suffix.length) : repoRoot;
|
|
118
|
+
}
|
|
94
119
|
// Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
|
|
95
120
|
// step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
|
|
96
121
|
// precondition, like the runbook's `git pull`). Run with cwd = standaloneRoot.
|
|
@@ -1107,6 +1132,7 @@ module.exports = {
|
|
|
1107
1132
|
safeVersionLabel,
|
|
1108
1133
|
scaffoldStandaloneRepo,
|
|
1109
1134
|
seedFirstVersionCmd,
|
|
1135
|
+
selfInstanceRoot,
|
|
1110
1136
|
standaloneInstallCmd,
|
|
1111
1137
|
standaloneMigrateCmd,
|
|
1112
1138
|
standalonePullCmd,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.926'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -456,5 +456,195 @@ await t('no verdict at all is null — never an implied pass', () => {
|
|
|
456
456
|
assert.equal(p.behind, true, 'and the rest of the reading is unaffected');
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
|
|
460
|
+
console.log('\nthe body the page actually sends (task 1004124):');
|
|
461
|
+
|
|
462
|
+
// THE REAL VALIDATOR, NOT A STUB. Every other handler test here injects
|
|
463
|
+
// `validateOrRespond: () => false`, which is right for asserting branch behaviour and
|
|
464
|
+
// is exactly why this shipped broken: the route declares `{ strict: true }`, so a field
|
|
465
|
+
// the schema does not NAME is a 400 before the handler runs — and task 1004121 added
|
|
466
|
+
// the `dry_run` read without the declaration. Two correct halves, one contract, and no
|
|
467
|
+
// test across it. `src/bongos/routes/core-update.js` documents this same class in its
|
|
468
|
+
// own comment; this is the provisioning half learning it.
|
|
469
|
+
const HELPERS = require('../src/bongos/routes/_helpers.js');
|
|
470
|
+
|
|
471
|
+
const validatorHarness = () => {
|
|
472
|
+
const calls = [];
|
|
473
|
+
const provisioning = {
|
|
474
|
+
getInstanceById: async () => ACTIVE,
|
|
475
|
+
enqueueIntent: async (_db, id, action, requestedBy, targetVersion) => {
|
|
476
|
+
calls.push({ id, action, requestedBy, targetVersion });
|
|
477
|
+
return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
478
|
+
},
|
|
479
|
+
recordEvent: async () => {},
|
|
480
|
+
};
|
|
481
|
+
const handler = CU.makeCoreUpgradeHandler({
|
|
482
|
+
provisioning, pool: {}, parseId: () => 7,
|
|
483
|
+
validateOrRespond: HELPERS.validateOrRespond, log: { error() {} },
|
|
484
|
+
});
|
|
485
|
+
let body = null;
|
|
486
|
+
let status = 200;
|
|
487
|
+
const res = {
|
|
488
|
+
json: (b) => { body = b; return res; },
|
|
489
|
+
fail: (code, o) => { body = { code, ...(typeof o === 'object' ? o : { status: o }) }; status = (o && o.status) || o || 400; return res; },
|
|
490
|
+
status: (c) => { status = c; return res; },
|
|
491
|
+
};
|
|
492
|
+
return { calls, handler, res, read: () => ({ body, status }) };
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
await t('the preview payload the page posts is ACCEPTED, not refused as an unknown field', async () => {
|
|
496
|
+
// deploy.js:183 sends exactly `{ to, dry_run: true }`. Live, before the fix, this
|
|
497
|
+
// answered 400 unknown_field — so the whole "see what would change" button was dead.
|
|
498
|
+
const h = validatorHarness();
|
|
499
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: true } }, h.res);
|
|
500
|
+
const { body } = h.read();
|
|
501
|
+
assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
|
|
502
|
+
const queued = h.calls.find((c) => c.action === 'core-upgrade-preflight');
|
|
503
|
+
assert.ok(queued, 'and it reaches the queue as a PREVIEW, not as a move');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await t('the move payload still validates, and still queues a move', async () => {
|
|
507
|
+
const h = validatorHarness();
|
|
508
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925' } }, h.res);
|
|
509
|
+
const { body } = h.read();
|
|
510
|
+
assert.ok(!body || body.code !== 'validation_failed', `the body must validate — got ${JSON.stringify(body)}`);
|
|
511
|
+
assert.ok(h.calls.find((c) => c.action === 'core-upgrade'), 'no dry_run means a real move');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await t('strict is still strict — a typo is still refused', async () => {
|
|
515
|
+
// The fix is a declaration, not a loosening. Widening to non-strict would have made
|
|
516
|
+
// the button work and silently swallowed every future typo, which is the trade
|
|
517
|
+
// _helpers.js exists to refuse.
|
|
518
|
+
const h = validatorHarness();
|
|
519
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_runn: true } }, h.res);
|
|
520
|
+
const { body } = h.read();
|
|
521
|
+
assert.equal(body && body.code, 'validation_failed', 'an undeclared field is still a 400');
|
|
522
|
+
assert.equal(h.calls.length, 0, 'and nothing was queued');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await t('dry_run must be a boolean — a truthy string does not become a preview', async () => {
|
|
526
|
+
const h = validatorHarness();
|
|
527
|
+
await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.925', dry_run: 'yes' } }, h.res);
|
|
528
|
+
const { body } = h.read();
|
|
529
|
+
assert.equal(body && body.code, 'validation_failed',
|
|
530
|
+
'the handler compares === true, so a string would silently queue a MOVE where a preview was asked for');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
// A REAL REQUEST THROUGH THE ASSEMBLED ROUTER. The harness above proves the SCHEMA
|
|
535
|
+
// (it injects the very function object the route injects — api.validateOrRespond IS
|
|
536
|
+
// _helpers.validateOrRespond), but it cannot see the wiring: middleware order, a
|
|
537
|
+
// missing body parser, a mis-shaped handler mount. Those are invisible to any test
|
|
538
|
+
// that calls the factory directly, which is the same lesson
|
|
539
|
+
// src/bongos/routes/core-update.js records about its own asyncHandler bug. So: boot
|
|
540
|
+
// the actual router over express and POST to it.
|
|
541
|
+
await t('a real POST through the assembled router accepts the preview body', async () => {
|
|
542
|
+
const api = require('../src/module-api.js');
|
|
543
|
+
const prov = require('../modules/provisioning/provisioning.js');
|
|
544
|
+
const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
|
|
545
|
+
|
|
546
|
+
const saved = {};
|
|
547
|
+
const stub = (obj, key, value) => {
|
|
548
|
+
saved[key] = Object.getOwnPropertyDescriptor(obj, key);
|
|
549
|
+
Object.defineProperty(obj, key, { value, configurable: true, writable: true });
|
|
550
|
+
};
|
|
551
|
+
// Only the two auth gates and the data layer are replaced. parseId and
|
|
552
|
+
// validateOrRespond stay REAL — they are the contract under test.
|
|
553
|
+
stub(api, 'requireBuilder', (req, _res, next) => { req.builder = { id: 42 }; next(); });
|
|
554
|
+
stub(api, 'requirePermission', () => (_req, _res, next) => next());
|
|
555
|
+
stub(api, 'pool', {});
|
|
556
|
+
const queued = [];
|
|
557
|
+
const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
|
|
558
|
+
prov.getInstanceById = async () => ACTIVE;
|
|
559
|
+
prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
|
|
560
|
+
queued.push({ id, action, requestedBy, targetVersion });
|
|
561
|
+
return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
562
|
+
};
|
|
563
|
+
prov.recordEvent = async () => {};
|
|
564
|
+
delete require.cache[routePath];
|
|
565
|
+
|
|
566
|
+
const express = require('express');
|
|
567
|
+
const app = express();
|
|
568
|
+
app.use(express.json());
|
|
569
|
+
app.use(require(routePath)());
|
|
570
|
+
const server = app.listen(0);
|
|
571
|
+
await new Promise((r) => server.once('listening', r));
|
|
572
|
+
try {
|
|
573
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/provisioning/instances/41/core-upgrade`, {
|
|
574
|
+
method: 'POST',
|
|
575
|
+
headers: { 'content-type': 'application/json' },
|
|
576
|
+
body: JSON.stringify({ to: '1.19.925', dry_run: true }),
|
|
577
|
+
});
|
|
578
|
+
const body = await res.json().catch(() => null);
|
|
579
|
+
assert.notEqual(res.status, 400, `the page's own payload must not be refused — got ${res.status} ${JSON.stringify(body)}`);
|
|
580
|
+
const preview = queued.find((q) => q.action === 'core-upgrade-preflight');
|
|
581
|
+
assert.ok(preview, `and it reaches the queue as a preview — queued ${JSON.stringify(queued)}`);
|
|
582
|
+
assert.equal(preview.targetVersion, '1.19.925');
|
|
583
|
+
assert.equal(preview.requestedBy, 'api:archon:42', 'attributed through the real auth seam');
|
|
584
|
+
} finally {
|
|
585
|
+
server.close();
|
|
586
|
+
for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
|
|
587
|
+
prov.getInstanceById = savedProv.get;
|
|
588
|
+
prov.enqueueIntent = savedProv.enq;
|
|
589
|
+
prov.recordEvent = savedProv.rec;
|
|
590
|
+
delete require.cache[routePath];
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
// THE AUDIT, AS A GUARD RATHER THAN A CLAIM. The task asked for a sweep of the other
|
|
596
|
+
// provisioning write routes for this same shape. A sweep performed once and reported in
|
|
597
|
+
// prose is indistinguishable, later, from one never performed — and the next route to
|
|
598
|
+
// add a strict schema gets no help from it. So the sweep is a test: it re-runs on every
|
|
599
|
+
// build and fails on the next occurrence rather than on this one.
|
|
600
|
+
await t('every strict body schema in this module declares each field its handler reads', () => {
|
|
601
|
+
const dir = join(repoRoot, 'modules', 'provisioning');
|
|
602
|
+
const files = [];
|
|
603
|
+
const walk = (d) => {
|
|
604
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
605
|
+
if (e.name === 'node_modules' || e.name === 'tests') continue;
|
|
606
|
+
const full = join(d, e.name);
|
|
607
|
+
if (e.isDirectory()) walk(full);
|
|
608
|
+
else if (e.name.endsWith('.js')) files.push(full);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
walk(dir);
|
|
612
|
+
|
|
613
|
+
const offenders = [];
|
|
614
|
+
let strictSites = 0;
|
|
615
|
+
for (const f of files) {
|
|
616
|
+
const src = readFileSync(f, 'utf8');
|
|
617
|
+
const lines = src.split('\n');
|
|
618
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
619
|
+
if (!lines[i].includes('validateOrRespond(req')) continue;
|
|
620
|
+
// The call spans until its `)) return;`; the handler body follows it.
|
|
621
|
+
let j = i;
|
|
622
|
+
while (j < lines.length && j < i + 40 && !lines[j].includes(')) return;')) j += 1;
|
|
623
|
+
const call = lines.slice(i, j + 1).join('\n');
|
|
624
|
+
if (!/strict:\s*true/.test(call)) continue; // extras are ignored by design
|
|
625
|
+
strictSites += 1;
|
|
626
|
+
// The SCHEMA argument only — brace-matched from the `{` after `res,`. Scanning the
|
|
627
|
+
// whole call would also count the options object's keys as declared fields, which
|
|
628
|
+
// is exactly the kind of over-broad match that turns a guard into decoration.
|
|
629
|
+
const open = call.indexOf('{', call.indexOf('res,'));
|
|
630
|
+
let depth = 0;
|
|
631
|
+
let close = open;
|
|
632
|
+
for (; close < call.length; close += 1) {
|
|
633
|
+
if (call[close] === '{') depth += 1;
|
|
634
|
+
else if (call[close] === '}') { depth -= 1; if (!depth) break; }
|
|
635
|
+
}
|
|
636
|
+
const schemaSrc = call.slice(open, close + 1);
|
|
637
|
+
const declared = new Set([...schemaSrc.matchAll(/(\w+)\s*:\s*\{/g)].map((m) => m[1]));
|
|
638
|
+
const body = lines.slice(j + 1, j + 140).join('\n');
|
|
639
|
+
const read = new Set([...body.matchAll(/req\.body\.(\w+)/g)].map((m) => m[1]));
|
|
640
|
+
const missing = [...read].filter((k) => !declared.has(k));
|
|
641
|
+
if (missing.length) offenders.push(`${f.replace(repoRoot + '/', '')}:${i + 1} reads ${missing.join(', ')} but does not declare it`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
assert.ok(strictSites > 0, 'the scan found no strict schema at all — it has stopped looking');
|
|
645
|
+
assert.deepEqual(offenders, [],
|
|
646
|
+
'under strict, a field the schema does not name is a 400 BEFORE the handler runs, so reading it is unreachable code and a broken endpoint');
|
|
647
|
+
});
|
|
648
|
+
|
|
459
649
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
460
650
|
if (failed) process.exit(1);
|
|
@@ -36,6 +36,7 @@ const R = require('../scripts/gds/provision-core-upgrade.js');
|
|
|
36
36
|
const U = require('../scripts/gds/upgrade.js');
|
|
37
37
|
const P = require('../modules/provisioning/provisioning.js');
|
|
38
38
|
const G = require('../modules/provisioning/control-plane-guard.js');
|
|
39
|
+
const RP = require('../scripts/gds/provision-repo.js');
|
|
39
40
|
|
|
40
41
|
let passed = 0;
|
|
41
42
|
let failed = 0;
|
|
@@ -553,5 +554,98 @@ await t('a preview of nothing is refused rather than cached as a pass', async ()
|
|
|
553
554
|
assert.equal(d.queries.filter((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql)).length, 0);
|
|
554
555
|
});
|
|
555
556
|
|
|
557
|
+
console.log('\nwhere the instance actually is (task 1004123):');
|
|
558
|
+
|
|
559
|
+
// The vendored layout the live control plane runs in: the core is a PACKAGE inside the
|
|
560
|
+
// instance, so the directory the core executes from is three levels below the directory
|
|
561
|
+
// that must be upgraded.
|
|
562
|
+
const VENDORED = '/home/lars/cloudbongos-instance/node_modules/@bongos/core';
|
|
563
|
+
const INSTANCE = '/home/lars/cloudbongos-instance';
|
|
564
|
+
const LEGACY = '/home/lars/cloudbongos';
|
|
565
|
+
|
|
566
|
+
await t('selfInstanceRoot walks out of a vendored core, and leaves a plain checkout alone', () => {
|
|
567
|
+
assert.equal(RP.selfInstanceRoot(VENDORED), INSTANCE, 'the package is not the instance');
|
|
568
|
+
assert.equal(RP.selfInstanceRoot(LEGACY), LEGACY, 'a checkout run in place IS the instance');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await t('a path that merely CONTAINS the package name is not mistaken for one', () => {
|
|
572
|
+
// Guarding the string test itself: only a trailing segment means "I am the vendored
|
|
573
|
+
// core", and an instance that happens to live under a similarly-named directory must
|
|
574
|
+
// not be truncated into nonsense.
|
|
575
|
+
const decoy = '/srv/node_modules/@bongos/core-tools';
|
|
576
|
+
assert.equal(RP.selfInstanceRoot(decoy), decoy);
|
|
577
|
+
assert.equal(RP.selfInstanceRoot('/srv/node_modules/@bongos/core/sub'), '/srv/node_modules/@bongos/core/sub');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await t('the runner reads the installed version from the INSTANCE, not from the core package', async () => {
|
|
581
|
+
// The defect this replaces: readInstalledCoreVersion was handed REPO_ROOT, which in
|
|
582
|
+
// the vendored shape holds no node_modules/@bongos/core at all — so it answered null
|
|
583
|
+
// on the live control plane for as long as the shape has existed.
|
|
584
|
+
const asked = [];
|
|
585
|
+
const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => INSTANCE };
|
|
586
|
+
d.readInstalledCoreVersion = (dir) => { asked.push(dir); return '1.19.760'; };
|
|
587
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
588
|
+
assert.deepEqual(asked, [INSTANCE], 'asked the instance root exactly once');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
await t('the CLI runs where it lives and NAMES the instance it is aimed at', async () => {
|
|
592
|
+
const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => INSTANCE };
|
|
593
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
594
|
+
const cmd = upgradeCall(d);
|
|
595
|
+
assert.ok(cmd.includes(`--instance ${INSTANCE} `),
|
|
596
|
+
`without this the upgrade pins and installs inside the core package and the real pin never moves — got: ${cmd}`);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
await t('the legacy shape still omits --instance, because there is nothing to disambiguate', async () => {
|
|
600
|
+
const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => require('../scripts/gds/provision-config.js').REPO_ROOT };
|
|
601
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
602
|
+
assert.doesNotMatch(upgradeCall(d), /--instance /, 'a checkout run in place is its own instance');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
await t('the version snapshot asks the registry from the instance, not from inside the package', async () => {
|
|
606
|
+
// Weakest of the three sites — `recommended` is derived from the box's own /version,
|
|
607
|
+
// so aiming this one wrongly changes no answer today. It is guarded anyway: npm reads
|
|
608
|
+
// its configuration from the directory it runs in, and an unguarded line is a line
|
|
609
|
+
// that regresses quietly. Found by mutating each of the three sites in turn; this was
|
|
610
|
+
// the one no test noticed.
|
|
611
|
+
const cwds = [];
|
|
612
|
+
const d = makeDeps({ served: '1.19.760' });
|
|
613
|
+
d.selfInstanceRoot = () => INSTANCE;
|
|
614
|
+
d.updateChannel = { ...d.updateChannel, listAvailableVersions: ({ cwd }) => { cwds.push(cwd); return { ok: true, versions: ['1.19.760', '1.19.762'] }; } };
|
|
615
|
+
await R.snapshotCoreVersions(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d);
|
|
616
|
+
assert.deepEqual(cwds, [INSTANCE]);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
await t('the PREVIEW aims at the instance too — a dry run of the wrong directory is the lie', async () => {
|
|
620
|
+
// This is the shape that made the defect dangerous rather than merely broken: dry-run
|
|
621
|
+
// against the core package and upgrade.js reports `(none) → <target>` with green
|
|
622
|
+
// pre-flight ticks, so the owner is shown a clean bill of health for a run that will
|
|
623
|
+
// restart production and roll back. Both legs share one builder; both must aim.
|
|
624
|
+
const box = { id: 41, slug: 'cloudbongos', port: 3002, hosting_shape: 'control-plane', db_name: 'cloudbongos' };
|
|
625
|
+
const selfRoot = () => INSTANCE;
|
|
626
|
+
const move = R.upgradeInvocation(box, '1.19.922', { privileged: true, selfRoot });
|
|
627
|
+
const peek = R.upgradeInvocation(box, '1.19.922', { privileged: true, dryRun: true, selfRoot });
|
|
628
|
+
assert.ok(peek.cmd.includes(`--instance ${INSTANCE} `), `the preview names its target — got: ${peek.cmd}`);
|
|
629
|
+
assert.equal(move.instanceDir, peek.instanceDir, 'and it is the same target the move would take');
|
|
630
|
+
assert.equal(peek.instanceDir, INSTANCE);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await t('an unreadable installed version is a REFUSAL, not a skipped channel check', () => {
|
|
634
|
+
// Fail-closed. This is the guard that would have caught the directory bug on the
|
|
635
|
+
// first press instead of after four live restarts.
|
|
636
|
+
const f = R.targetFault('1.19.760', ['1.19.758', '1.19.760'], null, 'patch', () => true);
|
|
637
|
+
assert.match(f, /could not read the core version installed/);
|
|
638
|
+
assert.match(f, /cannot be bounded by the 'patch' channel/);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
await t('the runner refuses, and runs NOTHING, when it cannot see where the instance is', async () => {
|
|
642
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
643
|
+
d.readInstalledCoreVersion = () => null;
|
|
644
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
|
|
645
|
+
assert.equal(r.ok, false);
|
|
646
|
+
assert.match(r.error, /could not read the core version installed/);
|
|
647
|
+
assert.equal(d.ranWith.filter((c) => /upgrade\.js/.test(c.cmd)).length, 0, 'the box was not touched');
|
|
648
|
+
});
|
|
649
|
+
|
|
556
650
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
557
651
|
if (failed) process.exit(1);
|
package/tests/helpers.mjs
CHANGED
|
@@ -630,5 +630,104 @@ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
|
|
|
630
630
|
};
|
|
631
631
|
}
|
|
632
632
|
|
|
633
|
+
|
|
634
|
+
// bootIdeaPage — boot the REAL modules/hall-ui/public/idea.js in a vm against a
|
|
635
|
+
// stub DOM, and hand back the HTML it wrote into #idea-body plus every request
|
|
636
|
+
// it made.
|
|
637
|
+
//
|
|
638
|
+
// The sibling of bootProfilePage above, and here for the same reason: the
|
|
639
|
+
// descent surface (task 1004094 / BV2.R10) is asserted at the RENDERED layer,
|
|
640
|
+
// because the first of the three findings inherited from the abandoned task
|
|
641
|
+
// 1004054 is that testing the server-side read alone is exactly how a renderer
|
|
642
|
+
// bug survives. What a reader is handed is HTML, so that is what the suite reads.
|
|
643
|
+
//
|
|
644
|
+
// Unlike profile.js, this page writes straight into an element's innerHTML
|
|
645
|
+
// rather than through OTBKit's ledger, so the capture is the element itself.
|
|
646
|
+
const IDEA_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea.js'), 'utf8');
|
|
647
|
+
export function bootIdeaPage({ idea = null, status = 200, template = null, viewer = { id: 1 }, ideaId = '1' } = {}) {
|
|
648
|
+
const els = new Map();
|
|
649
|
+
const makeEl = () => ({
|
|
650
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
|
|
651
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
652
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
653
|
+
addEventListener() {},
|
|
654
|
+
querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
|
|
655
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
656
|
+
});
|
|
657
|
+
const documentObj = {
|
|
658
|
+
title: '',
|
|
659
|
+
body: { dataset: { page: 'idea' } },
|
|
660
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
661
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
662
|
+
querySelector(sel) {
|
|
663
|
+
const id = String(sel).replace(/^#/, '');
|
|
664
|
+
// The mark control's button and error slot exist only once the page has
|
|
665
|
+
// rendered one. Returning a live element for every selector would make
|
|
666
|
+
// the boot look as though it had wired a control that is not on the page.
|
|
667
|
+
if (id === 'ig-mark-btn' || id === 'ig-mark-err') {
|
|
668
|
+
return /id="ig-mark-btn"/.test(els.get('idea-body')?.innerHTML || '') ? this.getElementById(id) : null;
|
|
669
|
+
}
|
|
670
|
+
return this.getElementById(id);
|
|
671
|
+
},
|
|
672
|
+
querySelectorAll() { return []; },
|
|
673
|
+
createElement() { return makeEl(); },
|
|
674
|
+
addEventListener() {},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const requests = [];
|
|
678
|
+
const client = {
|
|
679
|
+
inbox: {
|
|
680
|
+
getInboxId: async ({ id }) => {
|
|
681
|
+
requests.push(`/inbox/${id}`);
|
|
682
|
+
return { status, ok: status === 200, data: status === 200 ? { idea } : null };
|
|
683
|
+
},
|
|
684
|
+
getInboxTemplate: async () => {
|
|
685
|
+
requests.push('/inbox/template');
|
|
686
|
+
return template
|
|
687
|
+
? { status: 200, ok: true, data: { template } }
|
|
688
|
+
: { status: 500, ok: false, data: null };
|
|
689
|
+
},
|
|
690
|
+
postInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
|
|
691
|
+
deleteInboxIdSpark: async () => ({ status: 200, ok: true, data: { idea } }),
|
|
692
|
+
},
|
|
693
|
+
me: {
|
|
694
|
+
getMe: async () => {
|
|
695
|
+
requests.push('/me');
|
|
696
|
+
return viewer
|
|
697
|
+
? { status: 200, ok: true, data: { builder: viewer } }
|
|
698
|
+
: { status: 401, ok: false, data: null };
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const windowObj = {
|
|
704
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
705
|
+
BongosClient: { createClient: () => client },
|
|
706
|
+
OTB: {
|
|
707
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
708
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
709
|
+
fmtDate: (iso) => String(iso ?? '').slice(0, 10),
|
|
710
|
+
toast() {},
|
|
711
|
+
},
|
|
712
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/idea/${ideaId}`, assign() {} },
|
|
713
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
714
|
+
addEventListener() {},
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const sandbox = {
|
|
718
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
719
|
+
console, setTimeout, clearTimeout, URLSearchParams,
|
|
720
|
+
};
|
|
721
|
+
sandbox.globalThis = sandbox;
|
|
722
|
+
vm.createContext(sandbox);
|
|
723
|
+
vm.runInContext(IDEA_JS, sandbox, { filename: 'idea.js' });
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
requests,
|
|
727
|
+
html: () => documentObj.getElementById('idea-body').innerHTML,
|
|
728
|
+
title: () => documentObj.title,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
633
732
|
// One microtask turn — profile.js boots through a chain of awaits.
|
|
634
733
|
export const settle = () => new Promise((r) => setTimeout(r, 0));
|