@bongos/core 1.21.70 → 1.21.72
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 +123 -63
- package/.claude/skills/goal-create/SKILL.md +21 -3
- 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 +5 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +46 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +388 -377
- package/docs/copy-registry.json +567 -448
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +912 -931
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/modules/public-landing/public/projects-hall-live.states.json +7 -1
- package/modules/public-landing/public/projects.html +581 -0
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +43 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +3 -3
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +11 -1
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/projects_hub_cloud_hop.mjs +303 -0
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- package/tests/upkeep_home.mjs +156 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// tests/goal_spec_task_db.mjs — a goal made in the hall carries its own spec task,
|
|
2
|
+
// and its other work waits behind it (task 1004585, ADR 0366 D3). REAL DB: the
|
|
3
|
+
// claim gate and the auto-promotion are the database's, so this proves the edge
|
|
4
|
+
// gateOnSpecTask writes is one they actually honour.
|
|
5
|
+
//
|
|
6
|
+
// Real-DB integration test (self-skips without a reachable Postgres; in the
|
|
7
|
+
// INTEGRATION set so the default DB-free lane never runs it).
|
|
8
|
+
//
|
|
9
|
+
// Run: DATABASE_URL=... node tests/goal_spec_task_db.mjs
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { Pool } = require('pg');
|
|
15
|
+
|
|
16
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
17
|
+
try {
|
|
18
|
+
await pool.query('SELECT 1');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.log(`goal_spec_task_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
21
|
+
await pool.end().catch(() => {});
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const seams = require('../src/module-seams.js');
|
|
26
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
27
|
+
const db = require('../modules/lifecycle/db.js');
|
|
28
|
+
const spec = require('../modules/lifecycle/goal-spec-task.js');
|
|
29
|
+
|
|
30
|
+
const VER = 'ZZZ-SPEC', WORKER = 90000401;
|
|
31
|
+
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
|
|
32
|
+
|
|
33
|
+
async function cleanup() {
|
|
34
|
+
const ids = (await q(`SELECT id FROM tasks WHERE version_id=$1`, [VER]).catch(() => [])).map((r) => r.id);
|
|
35
|
+
await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [ids]).catch(() => {});
|
|
36
|
+
await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, ids]).catch(() => {});
|
|
37
|
+
await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
|
|
38
|
+
await q(`DELETE FROM dependencies WHERE (from_kind='task' AND from_id = ANY($1::bigint[])) OR (to_kind='task' AND to_id = ANY($1::bigint[]))`, [ids]).catch(() => {});
|
|
39
|
+
await q(`DELETE FROM tasks WHERE version_id=$1`, [VER]).catch(() => {});
|
|
40
|
+
await q(`DELETE FROM goal_members WHERE goal_id IN (SELECT id FROM goals WHERE version_id=$1)`, [VER]).catch(() => {});
|
|
41
|
+
await q(`DELETE FROM goals WHERE version_id=$1`, [VER]).catch(() => {});
|
|
42
|
+
await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
|
|
43
|
+
await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let pass = 0;
|
|
47
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); console.log(` ok ${msg}`); pass++; };
|
|
48
|
+
const statusOf = async (id) => (await q(`SELECT status FROM tasks WHERE id=$1`, [id]))[0].status;
|
|
49
|
+
const edges = async (id) => (await q(`SELECT to_id FROM dependencies WHERE from_kind='task' AND from_id=$1 AND to_kind='task'`, [id])).map((r) => Number(r.to_id));
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await cleanup();
|
|
53
|
+
await q(`INSERT INTO versions (id,name,status,track) VALUES ($1,'spec','building','internal') ON CONFLICT (id) DO NOTHING`, [VER]);
|
|
54
|
+
await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,'spec-w','spec-worker','Spec Worker','archon')`, [WORKER]);
|
|
55
|
+
|
|
56
|
+
// A hall-made goal and its spec task (what POST /goals { spec_task: true } writes).
|
|
57
|
+
const goal = await db.createGoal({ versionId: VER, title: 'Hall goal', createdBy: WORKER });
|
|
58
|
+
const specTask = await db.createTask(spec.specTaskFields(goal, { createdBy: WORKER }));
|
|
59
|
+
ok(specTask.status === 'ready' && specTask.kind === 'spike', 'the spec task is born ready, a spike');
|
|
60
|
+
ok((await edges(specTask.id)).length === 0, 'the spec task does not wait on itself');
|
|
61
|
+
|
|
62
|
+
// 1) A task filed into the goal waits on the spec task, and the claim gate says so.
|
|
63
|
+
const work = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the first page', kind: 'feature', discipline: 'engineer', creditsReward: 10 });
|
|
64
|
+
ok((await edges(work.id)).includes(Number(specTask.id)), 'a filed task carries an edge to the spec task');
|
|
65
|
+
// unsatisfiedDeps is the read the claim gate refuses on (db-claims.js: a non-empty
|
|
66
|
+
// answer throws DEPS_NOT_SHIPPED). The full claim path is not driven here — its own
|
|
67
|
+
// DB test (claim_gate_open_blocker) is parked in this lane for unrelated setup.
|
|
68
|
+
const gating = await db.unsatisfiedDeps(work.id);
|
|
69
|
+
ok(gating.some((d) => d.kind === 'task' && Number(d.id) === Number(specTask.id)), 'the claim gate read names the spec task as unshipped');
|
|
70
|
+
|
|
71
|
+
// 2) Shipping the spec task releases it: the auto-promotion trigger moves it to ready.
|
|
72
|
+
await q(`UPDATE tasks SET status='shipped' WHERE id=$1`, [specTask.id]);
|
|
73
|
+
ok((await db.unsatisfiedDeps(work.id)).length === 0, 'nothing gates the task once the spec ships');
|
|
74
|
+
ok((await statusOf(work.id)) === 'ready', `the waiting task auto-promotes to ready (got ${await statusOf(work.id)})`);
|
|
75
|
+
|
|
76
|
+
// 3) A task filed after the spec shipped waits on nothing.
|
|
77
|
+
const later = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the second page', kind: 'feature', discipline: 'engineer' });
|
|
78
|
+
ok((await edges(later.id)).length === 0, 'a shipped spec gates nothing new');
|
|
79
|
+
|
|
80
|
+
// 4) A goal made WITHOUT a spec task behaves exactly as before.
|
|
81
|
+
const plain = await db.createGoal({ versionId: VER, title: 'CLI goal', createdBy: WORKER });
|
|
82
|
+
const plainTask = await db.createTask({ versionId: VER, goalId: plain.id, title: 'Build the third page', kind: 'feature', discipline: 'engineer' });
|
|
83
|
+
ok((await edges(plainTask.id)).length === 0, 'a goal with no spec task adds no edge');
|
|
84
|
+
|
|
85
|
+
// 5) A task MOVED into a goal with an open spec task waits on it too.
|
|
86
|
+
const goal2 = await db.createGoal({ versionId: VER, title: 'Second hall goal', createdBy: WORKER });
|
|
87
|
+
const spec2 = await db.createTask(spec.specTaskFields(goal2, { createdBy: WORKER }));
|
|
88
|
+
await db.updateTaskGoal(plainTask.id, goal2.id);
|
|
89
|
+
ok((await edges(plainTask.id)).includes(Number(spec2.id)), 'a moved-in task carries an edge to the open spec task');
|
|
90
|
+
|
|
91
|
+
console.log(`\n${pass} passed, 0 failed`);
|
|
92
|
+
} finally {
|
|
93
|
+
await cleanup();
|
|
94
|
+
await pool.end();
|
|
95
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// tests/projects_hub_cloud_hop.mjs
|
|
2
|
+
//
|
|
3
|
+
// The create wizard's done panel in two moments (task 1004578, from idea 1001205):
|
|
4
|
+
// WAIT — one status line, a thin bar and the cloud hop, a little game with one
|
|
5
|
+
// cloud per PUBLIC project on the platform — then ARRIVE — the steps only the
|
|
6
|
+
// founder can play, one at a time. The owner set the game's rules on a played
|
|
7
|
+
// prototype, and the nausea ones are the ones a refactor would quietly lose: the
|
|
8
|
+
// clouds stand still, the sky stands still, the view does not follow a jump.
|
|
9
|
+
//
|
|
10
|
+
// EXECUTED by the vm-slice idiom: momentFor/setMoment ride the same region the DNS
|
|
11
|
+
// test runs (renderProgress); the physics (hopStep), the score line (hopScore),
|
|
12
|
+
// the status sync (hopSync) and the pager (stepsPaint) are sliced function by
|
|
13
|
+
// function and run under narrow stubs. Every slice is asserted to exist, so a
|
|
14
|
+
// rename fails loudly instead of testing nothing. vm-born values are compared as
|
|
15
|
+
// primitives or through JSON.
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test tests/projects_hub_cloud_hop.mjs
|
|
18
|
+
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import vm from 'node:vm';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
28
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
29
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
30
|
+
const STYLE = HUB.slice(HUB.indexOf('<style>'), HUB.indexOf('</style>'));
|
|
31
|
+
const J = (v) => JSON.parse(JSON.stringify(v));
|
|
32
|
+
|
|
33
|
+
function between(from, to) {
|
|
34
|
+
const a = SCRIPT.indexOf(from);
|
|
35
|
+
const b = SCRIPT.indexOf(to, a + 1);
|
|
36
|
+
assert.ok(a > 0, `slice start must exist: ${from}`);
|
|
37
|
+
assert.ok(b > a, `slice end must follow it: ${to}`);
|
|
38
|
+
return SCRIPT.slice(a, b);
|
|
39
|
+
}
|
|
40
|
+
// one wizard-scope function, closed on the first `}` at the wizard's four-space indent
|
|
41
|
+
function fn(name) {
|
|
42
|
+
const m = SCRIPT.match(new RegExp(`\\n {4}function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n {4}\\}`));
|
|
43
|
+
assert.ok(m, `function ${name}() must be findable in projects.html`);
|
|
44
|
+
return m[0];
|
|
45
|
+
}
|
|
46
|
+
const HOP_CONSTS = (SCRIPT.match(/\n {4}var HOP_GAP = [^\n]*\n/) || [''])[0];
|
|
47
|
+
assert.ok(HOP_CONSTS.includes('HOP_JUMP'), 'the hop constants line must be findable');
|
|
48
|
+
const HOP_REGION = between('/* ══ THE CLOUD HOP (task 1004578)', '/* ── the hub\'s three doors ── */');
|
|
49
|
+
|
|
50
|
+
// ── the moment ──────────────────────────────────────────────────────────────────
|
|
51
|
+
const PROGRESS = between('var PROG_FLOWS = {', 'function startProgress(');
|
|
52
|
+
const OVERLAY = between('function verifiedAt(inst)', 'function chipMarkup(');
|
|
53
|
+
|
|
54
|
+
function bootMoment() {
|
|
55
|
+
const attrs = {};
|
|
56
|
+
const panel = { setAttribute: (k, v) => { attrs[k] = v; } };
|
|
57
|
+
const sandbox = {
|
|
58
|
+
$: (id) => (id === 'panel-done' ? panel : null),
|
|
59
|
+
esc: (s) => String(s), projectPageLink: (l) => l, relTime: () => 'now', canRestart: () => false,
|
|
60
|
+
FRESH_MS: 1, LIVE_MOMENT_MS: 1, state: {}, paintInviteGate() {}, lastProgHtml: '',
|
|
61
|
+
};
|
|
62
|
+
vm.createContext(sandbox);
|
|
63
|
+
vm.runInContext(PROGRESS + '\n' + OVERLAY, sandbox);
|
|
64
|
+
return { sandbox, attrs };
|
|
65
|
+
}
|
|
66
|
+
const NOW = () => new Date().toISOString();
|
|
67
|
+
const live = { status: 'active', domain: 'mercury.cloudbongos.com', dns_resolves: true, last_verified_up: true, last_verified_at: NOW(), open_intent: null };
|
|
68
|
+
|
|
69
|
+
test('the moment follows the same branches renderProgress speaks', () => {
|
|
70
|
+
const { sandbox } = bootMoment();
|
|
71
|
+
const m = (inst) => sandbox.momentFor(inst);
|
|
72
|
+
assert.equal(m({ status: 'requested' }), 'wait', 'filed: the hop');
|
|
73
|
+
assert.equal(m({ status: 'provisioning' }), 'wait', 'standing up: the hop');
|
|
74
|
+
assert.equal(m({ ...live, dns_resolves: false, last_verified_up: null }), 'wait', 'address still being set up: keep playing');
|
|
75
|
+
assert.equal(m({ ...live, last_verified_up: null }), 'wait', 'standing, not checked yet: keep playing');
|
|
76
|
+
assert.equal(m({ ...live, last_verified_up: false }), 'wait', 'not answering right now: keep playing');
|
|
77
|
+
assert.equal(m(live), 'arrive', 'answering on a resolved address: arrive');
|
|
78
|
+
assert.equal(m({ ...live, open_intent: { action: 'standup', state: 'running' } }), 'arrive',
|
|
79
|
+
'an intent renderProgress does not speak for (a running standup) falls through to the status');
|
|
80
|
+
assert.equal(m({ ...live, open_intent: { action: 'provision' } }), 'wait', 'a queued standup is still a wait');
|
|
81
|
+
assert.equal(m({ status: 'torn_down', open_intent: { action: 'provision' } }), 'wait', 'a queued revive is a wait');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a snag, an ending, a settings change or an addressless project keeps the plain panel', () => {
|
|
85
|
+
const { sandbox } = bootMoment();
|
|
86
|
+
const m = (inst) => sandbox.momentFor(inst);
|
|
87
|
+
assert.equal(m({ status: 'error' }), 'plain');
|
|
88
|
+
assert.equal(m({ status: 'tearing_down' }), 'plain');
|
|
89
|
+
assert.equal(m({ status: 'torn_down' }), 'plain');
|
|
90
|
+
assert.equal(m({ ...live, open_intent: { action: 'teardown' } }), 'plain');
|
|
91
|
+
assert.equal(m({ ...live, open_intent: { action: 'settings-apply', state: 'running' } }), 'plain');
|
|
92
|
+
assert.equal(m({ ...live, domain: null }), 'plain', 'no address will never answer — nothing to arrive at');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('renderProgress sets the moment before it writes the strip, and only for a real read', () => {
|
|
96
|
+
const { sandbox, attrs } = bootMoment();
|
|
97
|
+
const nodes = { liveProgress: { innerHTML: '', querySelector: () => null }, 'done-h': { textContent: '' }, doneLede: { innerHTML: '' } };
|
|
98
|
+
sandbox.$ = ((orig) => (id) => nodes[id] || orig(id))(sandbox.$);
|
|
99
|
+
sandbox.renderProgress({ status: 'provisioning' });
|
|
100
|
+
assert.equal(attrs['data-moment'], 'wait');
|
|
101
|
+
sandbox.renderProgress(live);
|
|
102
|
+
assert.equal(attrs['data-moment'], 'arrive');
|
|
103
|
+
const body = fn('renderProgress');
|
|
104
|
+
assert.ok(body.indexOf('setMoment(momentFor(inst))') < body.indexOf('setProgress('), 'the moment is decided first');
|
|
105
|
+
assert.match(body, /if \(inst\) setMoment\(momentFor\(inst\)\);/, 'a null read never moves the panel');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the panel leaves the hop on a stopped watch and starts on it at renderDone', () => {
|
|
109
|
+
const sp = between('function startProgress(', 'function resetForNewProject(');
|
|
110
|
+
assert.equal((sp.match(/setMoment\('plain'\);/g) || []).length, 2, 'the ~33-minute hand-off and a gone instance both drop to plain');
|
|
111
|
+
assert.match(fn('renderDone'), /setMoment\('wait'\);/, 'a fresh done panel opens on the hop');
|
|
112
|
+
assert.match(fn('resetForNewProject'), /hopReset\(\);/, 'a second project starts with a fresh hop');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── the physics — the owner's rules ─────────────────────────────────────────────
|
|
116
|
+
function bootHop({ clouds = 3, reduced = false } = {}) {
|
|
117
|
+
const score = { textContent: '' };
|
|
118
|
+
const sandbox = { $: (id) => (id === 'hopScore' ? score : null), REDUCED: reduced, Math };
|
|
119
|
+
vm.createContext(sandbox);
|
|
120
|
+
vm.runInContext(HOP_CONSTS + 'var hop = { w: 600, feed: [], clouds: [], best: 0, maxed: false, p: null, cam: 0, camT: 0, L: false, R: false };\n' +
|
|
121
|
+
fn('hopCloudX') + fn('hopCloudW') + fn('hopScore') + fn('hopStep'), sandbox);
|
|
122
|
+
const hop = sandbox.hop;
|
|
123
|
+
hop.clouds.push({ i: 0, base: true, y: 0 });
|
|
124
|
+
// one fixed column of clouds straight up the middle, so a plain jump lands
|
|
125
|
+
for (let i = 1; i <= clouds; i++) hop.clouds.push({ i, f: 0.5, cw: 100, y: -i * sandbox.HOP_GAP, name: 'P' + i, acc: 'sea' });
|
|
126
|
+
hop.p = { x: 300, y: -14, vy: 0, on: hop.clouds[0], face: 1 };
|
|
127
|
+
hop.cam = hop.camT = -340 * 0.8;
|
|
128
|
+
return { sandbox, hop, score };
|
|
129
|
+
}
|
|
130
|
+
function run(sb, frames) { for (let i = 0; i < frames; i++) sb.hopStep(1); }
|
|
131
|
+
|
|
132
|
+
test('a jump lands on the cloud above and the best height counts it', () => {
|
|
133
|
+
const { sandbox, hop, score } = bootHop();
|
|
134
|
+
const before = J(hop.clouds.map((c) => [c.f, c.y]));
|
|
135
|
+
hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
|
|
136
|
+
run(sandbox, 80);
|
|
137
|
+
assert.equal(hop.p.on && hop.p.on.i, 1, 'landed on the first project cloud');
|
|
138
|
+
assert.equal(hop.best, 1);
|
|
139
|
+
assert.equal(score.textContent, 'Best: 1 of 3 projects');
|
|
140
|
+
assert.deepEqual(J(hop.clouds.map((c) => [c.f, c.y])), before, 'the clouds never move — moving clouds read as nausea');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the view holds still during a jump and glides only after the landing', () => {
|
|
144
|
+
const { sandbox, hop } = bootHop();
|
|
145
|
+
const cam0 = hop.cam;
|
|
146
|
+
hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
|
|
147
|
+
let airborneMoved = false;
|
|
148
|
+
for (let i = 0; i < 80 && !hop.p.on; i++) { sandbox.hopStep(1); if (!hop.p.on && Math.abs(hop.cam - cam0) > 0.01) airborneMoved = true; }
|
|
149
|
+
assert.equal(airborneMoved, false, 'no camera motion while the player is in the air');
|
|
150
|
+
assert.ok(hop.p.on, 'and it did land');
|
|
151
|
+
run(sandbox, 60);
|
|
152
|
+
assert.ok(hop.cam < cam0 - 10, 'once landed higher, the view glides up');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a miss falls, and the full-width base cloud always catches', () => {
|
|
156
|
+
const { sandbox, hop } = bootHop();
|
|
157
|
+
hop.p.on = hop.clouds[2]; hop.p.y = hop.clouds[2].y - 14; hop.best = 2;
|
|
158
|
+
hop.p.x = 600 - 10; // walk off the right edge of a centred cloud
|
|
159
|
+
run(sandbox, 200);
|
|
160
|
+
assert.equal(hop.p.on && hop.p.on.base, true, 'it fell past the lower clouds and landed on Your planet');
|
|
161
|
+
assert.equal(hop.best, 2, 'falling never takes the best height away');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('steering moves the player, held to the stage', () => {
|
|
165
|
+
const { sandbox, hop } = bootHop();
|
|
166
|
+
hop.R = true; run(sandbox, 500);
|
|
167
|
+
assert.equal(hop.p.x, 590, 'right stops 10px from the edge');
|
|
168
|
+
hop.R = false; hop.L = true; run(sandbox, 500);
|
|
169
|
+
assert.equal(hop.p.x, 10, 'and left at 10px');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('past the last cloud: max projects reached, build more to jump more', () => {
|
|
173
|
+
const { sandbox, hop, score } = bootHop({ clouds: 1 });
|
|
174
|
+
hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
|
|
175
|
+
run(sandbox, 80);
|
|
176
|
+
assert.equal(hop.maxed, true);
|
|
177
|
+
assert.equal(score.textContent, 'Max projects reached — build more to jump more');
|
|
178
|
+
const jump = fn('hopJump');
|
|
179
|
+
assert.match(jump, /!hop\.maxed/, 'and the jumping stops');
|
|
180
|
+
assert.match(HOP_REGION, /'Max projects reached'/);
|
|
181
|
+
assert.match(HOP_REGION, /'Build more to jump more'/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the clouds are the public planets only, in the map\'s colours', () => {
|
|
185
|
+
const feed = fn('hopLoadFeed');
|
|
186
|
+
assert.match(feed, /p\.orb === 'planet'/, 'never a private star or a stealth black hole');
|
|
187
|
+
assert.match(feed, /localeCompare\(String\(b\.origin\)\)/, 'the accent walk runs over the origin-sorted catalog, like assignAccents');
|
|
188
|
+
assert.match(feed, /ORB_ACCENT_NAMES\.indexOf\(p\.planet_accent\) !== -1 \? p\.planet_accent/, 'the owner\'s pick wins');
|
|
189
|
+
assert.match(fn('hopBuild'), /hop\.clouds\.length === 1/, 'no public project at all reads as max reached');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── motion and the world ────────────────────────────────────────────────────────
|
|
193
|
+
test('the loop never runs under reduced motion; the rest frame is a full still sky', () => {
|
|
194
|
+
assert.match(fn('hopRunning'), /return !REDUCED && !document\.hidden && hop\.target === 'wait'/, 'the gate, like the map\'s skyActive (C9)');
|
|
195
|
+
const start = fn('hopStart');
|
|
196
|
+
assert.ok(start.indexOf('hopRunning()') < start.indexOf('requestAnimationFrame'), 'nothing is scheduled before the gate');
|
|
197
|
+
assert.match(start, /hopDraw\(performance\.now\(\)\)/, 'a stopped hop draws its still frame');
|
|
198
|
+
assert.equal((HOP_REGION.match(/requestAnimationFrame\(/g) || []).length, 4,
|
|
199
|
+
'the start, the frame (playing and curtain) and the curtain\'s kick-off — no other loop');
|
|
200
|
+
assert.match(fn('hopFrame'), /if \(!hopRunning\(\)\) \{ hopDraw\(now\); return; \}/,
|
|
201
|
+
'every frame re-asks the gate, so turning reduced motion on stops it');
|
|
202
|
+
assert.match(fn('hopMoment'), /!REDUCED && hop\.raf/, 'the curtain plays only from a running game');
|
|
203
|
+
assert.match(STYLE, /\.hop\.isStill \.hopRow\{display:none;\}/, 'reduced motion has nothing to steer');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the game reads its colours off the page tokens — no literal but white or black with alpha', () => {
|
|
207
|
+
const hexes = HOP_REGION.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
|
|
208
|
+
assert.deepEqual(hexes, [], 'no hex in the hop');
|
|
209
|
+
// hopCss() is the builder ('rgba(' + a token's channels), not a colour
|
|
210
|
+
const literals = (HOP_REGION.match(/'rgba?\([^']*'/g) || []).filter((m) => m !== "'rgba('");
|
|
211
|
+
for (const m of literals) assert.match(m, /^'rgba\(0,0,0,/, `only a black scrim may be literal: ${m}`);
|
|
212
|
+
assert.match(HOP_REGION, /var HOP_WHITE = \[255, 255, 255\];/, 'white is the one other literal');
|
|
213
|
+
assert.match(fn('hopColors'), /hopRead\('var\(--' \+ nm \+ '\)'\)/, 'every colour resolves from a token');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── the status line and the arrival pager ───────────────────────────────────────
|
|
217
|
+
function el(cls = []) {
|
|
218
|
+
const set = new Set(cls);
|
|
219
|
+
const attrs = {};
|
|
220
|
+
return {
|
|
221
|
+
classList: { contains: (c) => set.has(c), toggle: (c, on) => (on ? set.add(c) : set.delete(c)), add: (c) => set.add(c), remove: (c) => set.delete(c) },
|
|
222
|
+
setAttribute: (k, v) => { attrs[k] = v; }, getAttribute: (k) => attrs[k], attrs, hidden: false, textContent: '', style: {},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
test('the status line and the bar read what the strip says', () => {
|
|
227
|
+
const st = { textContent: '' }, fill = { style: {} };
|
|
228
|
+
const sandbox = { $: (id) => ({ hopStatus: st, hopBarFill: fill })[id] || null, Math };
|
|
229
|
+
vm.createContext(sandbox);
|
|
230
|
+
vm.runInContext(fn('hopSync'), sandbox);
|
|
231
|
+
const strip = {
|
|
232
|
+
querySelector: (q) => (q === '.pnote' ? { textContent: 'Raising the server, database, and address — nothing needed from you.' } : q === '.stages li.now' ? {} : null),
|
|
233
|
+
querySelectorAll: (q) => ({ length: q === '.stages li' ? 3 : 1 }),
|
|
234
|
+
};
|
|
235
|
+
sandbox.hopSync(strip);
|
|
236
|
+
assert.equal(st.textContent, 'Raising the server, database, and address — nothing needed from you.');
|
|
237
|
+
assert.equal(fill.style.width, '50%', 'one done and one under way of three');
|
|
238
|
+
assert.match(fn('setProgress'), /hopSync\(el\)/, 'every strip write syncs the line');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function bootSteps(items, signin) {
|
|
242
|
+
const list = { children: items, querySelector: (q) => items.find((li) => q.includes(li.key) || (q === 'li.cur' && li.classList.contains('cur'))) || null };
|
|
243
|
+
const panel = el(signin ? ['hopSignin'] : []);
|
|
244
|
+
const nodes = { 'panel-done': panel, humanSteps: list, hopWhile: el(), stepCount: el(), stepBack: el(), stepNext: el() };
|
|
245
|
+
const sandbox = { $: (id) => nodes[id] || null, Math, Array };
|
|
246
|
+
vm.createContext(sandbox);
|
|
247
|
+
vm.runInContext('var stepIdx = -1, stepAuto = true;' + fn('stepsPaint') + fn('stepGo'), sandbox);
|
|
248
|
+
return { sandbox, nodes };
|
|
249
|
+
}
|
|
250
|
+
function li(key, cls = []) { const e = el(cls); e.key = key; e.focus = () => {}; return e; }
|
|
251
|
+
|
|
252
|
+
test('the arrival shows one step at a time, opening on the first not yet done', () => {
|
|
253
|
+
const items = [li('github-repo', ['sdone']), li('oauth-app'), li('device-flow', ['slocked']), li('enter-hall', ['slocked'])];
|
|
254
|
+
const { sandbox, nodes } = bootSteps(items);
|
|
255
|
+
sandbox.stepsPaint(true);
|
|
256
|
+
assert.deepEqual(items.map((i) => i.classList.contains('cur')), [false, true, false, false]);
|
|
257
|
+
assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
|
|
258
|
+
assert.equal(items[1].attrs['data-n'], '02', 'the step on show carries its own number — a hidden step never counts');
|
|
259
|
+
assert.equal(nodes.stepBack.hidden, false);
|
|
260
|
+
assert.equal(nodes.stepNext.hidden, false);
|
|
261
|
+
// the checklist ticks itself: the step on show finishing moves the founder on
|
|
262
|
+
items[1].classList.add('sdone');
|
|
263
|
+
sandbox.stepsPaint();
|
|
264
|
+
assert.equal(nodes.stepCount.textContent, 'Step 3 of 4');
|
|
265
|
+
// once they page for themselves, their choice holds
|
|
266
|
+
sandbox.stepGo(-1);
|
|
267
|
+
items[2].classList.add('sdone');
|
|
268
|
+
sandbox.stepsPaint();
|
|
269
|
+
assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
|
|
270
|
+
sandbox.stepGo(1); sandbox.stepGo(1);
|
|
271
|
+
assert.equal(nodes.stepNext.hidden, true, 'no Continue past the last step');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('while waiting, the sign-in step is offered only when it can be done', () => {
|
|
275
|
+
const open = bootSteps([li('github-repo'), li('oauth-app')]);
|
|
276
|
+
open.sandbox.stepsPaint();
|
|
277
|
+
assert.equal(open.nodes.hopWhile.hidden, false, 'a domain and a public platform: offer it');
|
|
278
|
+
const locked = bootSteps([li('github-repo'), li('oauth-app', ['slocked'])]);
|
|
279
|
+
locked.sandbox.stepsPaint();
|
|
280
|
+
assert.equal(locked.nodes.hopWhile.hidden, true, 'locked: nothing to offer');
|
|
281
|
+
const done = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])]);
|
|
282
|
+
done.sandbox.stepsPaint();
|
|
283
|
+
assert.equal(done.nodes.hopWhile.hidden, true, 'already wired: nothing to offer');
|
|
284
|
+
const opened = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])], true);
|
|
285
|
+
opened.sandbox.stepsPaint();
|
|
286
|
+
assert.equal(opened.nodes.hopWhile.hidden, false, 'but an open one keeps its door so it can be closed');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── the markup and the sheet ────────────────────────────────────────────────────
|
|
290
|
+
test('the wait stands the strip, the locked checklist and the later lines down; the arrival pages', () => {
|
|
291
|
+
for (const id of ['hop', 'hopStatus', 'hopBarFill', 'hopCanvas', 'hopLeft', 'hopJump', 'hopRight', 'hopScore', 'hopWhile', 'hopSignin', 'stepPager', 'stepCount', 'stepBack', 'stepNext']) {
|
|
292
|
+
assert.ok(HUB.includes(`id="${id}"`), `#${id} is baked into the panel`);
|
|
293
|
+
}
|
|
294
|
+
const panel = HUB.slice(HUB.indexOf('id="panel-done"'), HUB.indexOf('</section>', HUB.indexOf('id="panel-done"')));
|
|
295
|
+
assert.ok(panel.indexOf('id="liveProgress"') < panel.indexOf('id="hop"') && panel.indexOf('id="hop"') < panel.indexOf('id="wizInvite"'),
|
|
296
|
+
'the hop sits where the strip was, above the invite');
|
|
297
|
+
assert.match(panel, /id="hopStatus" role="status" aria-live="polite"/, 'the line is the live region while the strip is hidden');
|
|
298
|
+
assert.match(STYLE, /#panel-done\[data-moment="wait"\] #liveProgress,/);
|
|
299
|
+
assert.match(STYLE, /#panel-done\[data-moment="wait"\]:not\(\.hopSignin\) #humanSteps\{display:none;\}/);
|
|
300
|
+
assert.match(STYLE, /#panel-done\[data-moment="arrive"\] \.humanSteps>li:not\(\.cur\)\{display:none;\}/);
|
|
301
|
+
assert.match(STYLE, /#panel-done\[data-moment="arrive"\] #liveProgress \.stages\{display:none;\}/, 'the arrival keeps the note with the address, not the dots');
|
|
302
|
+
assert.match(STYLE, /@media \(prefers-reduced-motion:no-preference\)\{\n {2}#panel-done\[data-moment="arrive"\] \.humanSteps>li\.cur,/, 'the arrival\'s fade-in is motion-gated');
|
|
303
|
+
});
|
|
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
|
|
|
78
78
|
${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
|
|
79
79
|
${NEUTRAL_PREFIX}_DEMO_PEOPLE=
|
|
80
80
|
${NEUTRAL_PREFIX}_DEMO_HOURS=
|
|
81
|
+
${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
|
|
81
82
|
`;
|
|
82
83
|
|
|
83
84
|
t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
|
|
@@ -147,6 +148,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
147
148
|
[`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
|
|
148
149
|
// the birth marker and a demo's box (task 1004506), empty on an un-stamped row
|
|
149
150
|
[`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
|
|
151
|
+
// where the hall's Update button goes (task 1004608), empty when no hub origin resolves
|
|
152
|
+
[`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
|
|
150
153
|
});
|
|
151
154
|
const body = P.webEnvBody(inst, {});
|
|
152
155
|
for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
|
|
@@ -247,7 +250,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
|
|
|
247
250
|
assert.equal(r.ok, true);
|
|
248
251
|
assert.equal(r.healthy, null, 'nothing ran, nothing observed');
|
|
249
252
|
assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
|
|
250
|
-
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
253
|
+
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
251
254
|
assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
|
|
252
255
|
assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
|
|
253
256
|
});
|
|
@@ -630,5 +633,26 @@ await ta('a failed write reports the error without the value', async () => {
|
|
|
630
633
|
assert.ok(!r.error.includes(SECRET), r.error);
|
|
631
634
|
});
|
|
632
635
|
|
|
636
|
+
console.log('\nhubUpgradeEnvVarsFor — where a hosted project\'s Update button goes (task 1004608):');
|
|
637
|
+
|
|
638
|
+
t('both tenant shapes get the hub door for THEIR slug; no other shape gets the key; it rides standup AND the push', () => {
|
|
639
|
+
const saved = process.env.PROVISION_HUB_ORIGIN;
|
|
640
|
+
process.env.PROVISION_HUB_ORIGIN = 'https://hub.example.com/';
|
|
641
|
+
try {
|
|
642
|
+
const key = `${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`;
|
|
643
|
+
for (const shape of ['cloud-host', 'co-tenant']) {
|
|
644
|
+
const inst = { slug: 'mercury', port: 3009, hosting_shape: shape, target_ref: 'will/mercury' };
|
|
645
|
+
assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor(inst), { [key]: 'https://hub.example.com/deploy?project=mercury' }, shape);
|
|
646
|
+
assert.equal(P.settingsEnvVarsFor(inst)[key], 'https://hub.example.com/deploy?project=mercury', `${shape} push`);
|
|
647
|
+
assert.match(P.webEnvBody(inst, {}), new RegExp(`^${key}=https://hub\\.example\\.com/deploy\\?project=mercury$`, 'm'), `${shape} standup`);
|
|
648
|
+
}
|
|
649
|
+
for (const shape of ['byo-host', 'control-plane']) {
|
|
650
|
+
assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor({ slug: 'x', hosting_shape: shape }), {}, shape);
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
if (saved === undefined) delete process.env.PROVISION_HUB_ORIGIN; else process.env.PROVISION_HUB_ORIGIN = saved;
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
633
657
|
console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
|
|
634
658
|
process.exit(failed ? 1 : 0);
|
|
@@ -180,7 +180,8 @@ async function drive(deps) {
|
|
|
180
180
|
const build = require('../src/bongos/routes/core-update.js');
|
|
181
181
|
const express = require('express');
|
|
182
182
|
const app = express();
|
|
183
|
-
|
|
183
|
+
// The ask state's stored row is the DB's; these tests never reach one (task 1004595).
|
|
184
|
+
app.use(build({ askDeps: () => ({ hub: 'https://cloudbongos.com', getSetting: async () => null }), ...deps }));
|
|
184
185
|
auth.requireBuilder = saved;
|
|
185
186
|
const server = app.listen(0);
|
|
186
187
|
await new Promise((r) => server.once('listening', r));
|
|
@@ -205,6 +206,22 @@ test('a hosted project: the Update button goes to the hub\'s door for THIS proje
|
|
|
205
206
|
assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=hoodify-finance' });
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
// Task 1004608: Will's mercury (a cloud-host that keeps its own GitHub app) has no sign-in
|
|
210
|
+
// federation, so the panel told its owner to run `bongos upgrade` on the platform's server.
|
|
211
|
+
test('a hosted project that does not federate sign-in still gets the hub\'s door, from HUB_UPGRADE_URL', async () => {
|
|
212
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
|
|
213
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: 'https://cloudbongos.com/deploy?project=mercury' }) });
|
|
214
|
+
assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('HUB_UPGRADE_URL is taken only as a hub /deploy address; anything else stays the cli answer', async () => {
|
|
218
|
+
for (const bad of ['', 'javascript:alert(1)', 'https://evil.example/phish', 'https://cloudbongos.com/deploy?project=a b', '/deploy']) {
|
|
219
|
+
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
|
|
220
|
+
doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: bad }) });
|
|
221
|
+
assert.deepEqual(r.body.door, { kind: 'cli', url: null }, bad);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
208
225
|
test('a self-hosted instance has no door to link, and says how it is upgraded instead', async () => {
|
|
209
226
|
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
|
|
210
227
|
assert.deepEqual(r.body.door, { kind: 'cli', url: null });
|
|
@@ -94,8 +94,8 @@ t('offers the wrong-instance escape hatch (bongos login)', () => {
|
|
|
94
94
|
t('a hosted origin never routes to bongos dev', () => {
|
|
95
95
|
for (const deps of [true, false]) {
|
|
96
96
|
const card = instanceNotRunningCard({ origin: 'https://example-instance.cloudbongos.com', depsMissing: deps });
|
|
97
|
-
assert.ok(!/^\s*\d+\.\s*`bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
|
|
98
|
-
assert.ok(/Don't run `bongos dev`/.test(card), 'must say plainly not to, and why');
|
|
97
|
+
assert.ok(!/^\s*\d+\.\s*`(npx )?bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
|
|
98
|
+
assert.ok(/Don't run `npx bongos dev`/.test(card), 'must say plainly not to, and why');
|
|
99
99
|
assert.ok(/empty database/.test(card), 'must name the consequence, not just forbid it');
|
|
100
100
|
}
|
|
101
101
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// tests/upgrade_operator.mjs — on a project its operator runs, `bongos upgrade` from a
|
|
2
|
+
// builder's own computer says who upgrades it and how to ask, with no server steps
|
|
3
|
+
// (task 1004596).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS AT RISK. A builder on a hosted project ran `bongos upgrade` from their laptop and
|
|
6
|
+
// got "cannot determine which database to migrate. Set PGDATABASE…". Their assistant then
|
|
7
|
+
// wrote them server instructions full of guessed names for a machine they cannot reach.
|
|
8
|
+
// This pins (1) the wording that replaces that dead end and (2) the condition that shows
|
|
9
|
+
// it — including that it never fires where a database is known, which is the operator's
|
|
10
|
+
// own run on the server.
|
|
11
|
+
//
|
|
12
|
+
// Run: node --test tests/upgrade_operator.mjs
|
|
13
|
+
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const op = require('../scripts/gds/upgrade-operator.js');
|
|
20
|
+
const upgrade = require('../scripts/gds/upgrade.js');
|
|
21
|
+
|
|
22
|
+
const HUB = { kind: 'hub', url: 'https://hub.example.com/deploy?project=mercury', hall: 'https://mercury.example.com/builders' };
|
|
23
|
+
|
|
24
|
+
test('the message says who upgrades it and how to ask — and nothing to run on a server', () => {
|
|
25
|
+
const msg = op.operatorRunMessage(HUB);
|
|
26
|
+
assert.match(msg, /^This project is upgraded by its operator, not from this computer\./);
|
|
27
|
+
assert.match(msg, /https:\/\/mercury\.example\.com\/builders\/settings#software-update/);
|
|
28
|
+
assert.match(msg, /bongos feedback "Please upgrade this project to the latest version"/);
|
|
29
|
+
assert.match(msg, /There are no server steps for you to take\./);
|
|
30
|
+
// None of what sent the last assistant down the wrong road.
|
|
31
|
+
for (const bad of [/PGDATABASE/, /ssh/i, /sudo/, /--health-url/, /--service/, /systemctl/, /<[a-z_-]+>/i, /log in to the server/i]) {
|
|
32
|
+
assert.doesNotMatch(msg, bad, `the operator message must not mention ${bad}`);
|
|
33
|
+
}
|
|
34
|
+
// Without a known hall it still names the place, in words.
|
|
35
|
+
assert.match(op.operatorRunMessage({ kind: 'hub' }), /Open Settings → Software update and use the button there\./);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a database known here (the server) → no message, and the project is never even asked', async () => {
|
|
39
|
+
let asked = false;
|
|
40
|
+
const door = await op.operatorRunsThisProject({ instanceDir: '/srv/x' }, {
|
|
41
|
+
resolveInstanceDb: () => ({ db: 'mercury', ran: true }),
|
|
42
|
+
readUpdateDoor: async () => { asked = true; return HUB; },
|
|
43
|
+
});
|
|
44
|
+
assert.equal(door, null);
|
|
45
|
+
assert.equal(asked, false, 'the operator\'s own run must not depend on any network answer');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('no database here + the project says its door is the hub → operator-run', async () => {
|
|
49
|
+
for (const probe of [{ db: null, ran: true }, { db: null, ran: false }]) {
|
|
50
|
+
const door = await op.operatorRunsThisProject({ instanceDir: '/laptop/x' }, {
|
|
51
|
+
resolveInstanceDb: () => probe, readUpdateDoor: async () => HUB,
|
|
52
|
+
});
|
|
53
|
+
assert.deepEqual(door, HUB);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('operatorNotice is the message exactly when the project is operator-run', async () => {
|
|
58
|
+
const deps = { resolveInstanceDb: () => ({ db: null, ran: true }) };
|
|
59
|
+
assert.equal(await op.operatorNotice({ instanceDir: '/x' }, { ...deps, readUpdateDoor: async () => HUB }), op.operatorRunMessage(HUB));
|
|
60
|
+
assert.equal(await op.operatorNotice({ instanceDir: '/x' }, { ...deps, readUpdateDoor: async () => ({ kind: 'cli' }) }), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('no database here, but a self-hosted / provisioning project, or no answer → behave as before', async () => {
|
|
64
|
+
for (const answer of [{ kind: 'cli', url: null }, { kind: 'local', url: '/deploy' }, null]) {
|
|
65
|
+
const door = await op.operatorRunsThisProject({ instanceDir: '/laptop/x' }, {
|
|
66
|
+
resolveInstanceDb: () => ({ db: null, ran: true }), readUpdateDoor: async () => answer,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(door, null, `door ${JSON.stringify(answer)} must not trigger the operator message`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function capture(stream) {
|
|
73
|
+
const orig = stream.write.bind(stream);
|
|
74
|
+
let out = '';
|
|
75
|
+
stream.write = (chunk, ...rest) => { out += String(chunk); return true; };
|
|
76
|
+
return { text: () => out, restore: () => { stream.write = orig; } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('`bongos upgrade --to …` on an operator-run project refuses with the message before anything moves', async () => {
|
|
80
|
+
const err = capture(process.stderr);
|
|
81
|
+
let code;
|
|
82
|
+
try {
|
|
83
|
+
code = await upgrade.main(['--to', '99.0.0', '--dry-run'], { operatorNotice: async () => op.operatorRunMessage(HUB) });
|
|
84
|
+
} finally { err.restore(); }
|
|
85
|
+
assert.equal(code, 1);
|
|
86
|
+
assert.match(err.text(), /✖ This project is upgraded by its operator/);
|
|
87
|
+
assert.doesNotMatch(err.text(), /cannot determine which database/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('bare `bongos upgrade` keeps its pre-check result and adds who upgrades it', async () => {
|
|
91
|
+
const out = capture(process.stdout);
|
|
92
|
+
let code;
|
|
93
|
+
try {
|
|
94
|
+
code = await upgrade.main([], { cmdUpgrade: async () => 0, operatorNotice: async () => op.operatorRunMessage(HUB) });
|
|
95
|
+
} finally { out.restore(); }
|
|
96
|
+
assert.equal(code, 0, 'the pre-check exit code is unchanged');
|
|
97
|
+
assert.match(out.text(), /This project is upgraded by its operator/);
|
|
98
|
+
|
|
99
|
+
const quiet = capture(process.stdout);
|
|
100
|
+
try {
|
|
101
|
+
code = await upgrade.main([], { cmdUpgrade: async () => 1, operatorNotice: async () => null });
|
|
102
|
+
} finally { quiet.restore(); }
|
|
103
|
+
assert.equal(code, 1);
|
|
104
|
+
assert.doesNotMatch(quiet.text(), /upgraded by its operator/, 'not an operator-run project → no message');
|
|
105
|
+
});
|