@bongos/core 1.21.4 → 1.21.6
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 +233 -108
- 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 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +37 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +541 -658
- package/docs/copy-registry.json +1205 -2340
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +47 -28
- package/docs/page-readings.json +1128 -1339
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +27 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +24 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +68 -2
- package/src/bongos/routes/modules.js +17 -0
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/provision.mjs +78 -0
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +428 -277
- package/tests/wizard_six_screens.mjs +126 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/autonomy_config_idle.mjs — idle on a setting is not idle on no work
|
|
2
|
+
// (task 1004539).
|
|
3
|
+
//
|
|
4
|
+
// The incident: ten hours fenced with no_builder_scope while the hall said
|
|
5
|
+
// "alive, waiting for work". These pin the done-when: runner health reports a
|
|
6
|
+
// configuration idle as NOT ok with the fix, the owner's kill switch does not,
|
|
7
|
+
// and the Discord notice is said once per change rather than once per loop.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const ci = require('../modules/autonomy/config-idle.js');
|
|
15
|
+
const { describeRunner } = require('../modules/autonomy/runner-health.js');
|
|
16
|
+
const runner = require('../scripts/gds/autobongos-run.js');
|
|
17
|
+
|
|
18
|
+
const NOW = Date.parse('2026-10-02T09:47:00.000Z');
|
|
19
|
+
const agoS = (s) => new Date(NOW - s * 1000).toISOString();
|
|
20
|
+
const hb = (over = {}) => ({ host: 'winbox', mode: 'full', last_seen_at: agoS(30), ...over });
|
|
21
|
+
|
|
22
|
+
// ── runner health: the hall's verdict ───────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
test('a runner idle on no_builder_scope is NOT ok, and the hall gets the fix', () => {
|
|
25
|
+
const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope' }), { nowMs: NOW });
|
|
26
|
+
assert.equal(d.state, 'idle_config');
|
|
27
|
+
assert.equal(d.ok, false, 'the incident: this read ok=true for ten hours');
|
|
28
|
+
assert.equal(d.idleCode, 'no_builder_scope');
|
|
29
|
+
assert.match(d.remedy, /Settings/);
|
|
30
|
+
assert.match(d.detail, /no goals are picked/);
|
|
31
|
+
assert.match(d.detail, /winbox/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('every configuration code reads not-ok with its own remedy', () => {
|
|
35
|
+
for (const code of Object.keys(ci.CONFIG_IDLE)) {
|
|
36
|
+
const d = describeRunner(hb({ last_event: `config_idle:${code}` }), { nowMs: NOW });
|
|
37
|
+
assert.equal(d.ok, false, code);
|
|
38
|
+
assert.equal(d.remedy, ci.CONFIG_IDLE[code].remedy, code);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a config-idle runner that goes quiet past the dead line is DEAD, not idle', () => {
|
|
43
|
+
// A dead runner cannot report its configuration; silence still wins.
|
|
44
|
+
const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope', last_seen_at: agoS(3 * 3600) }), { nowMs: NOW });
|
|
45
|
+
assert.equal(d.state, 'dead');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the kill switch, a plain wait and an unknown code stay ok', () => {
|
|
49
|
+
for (const last_event of ['fenced', 'waiting', 'nothing_claimable', 'hold', 'config_idle:kill_switch', 'config_idle:<b>x</b>']) {
|
|
50
|
+
const d = describeRunner(hb({ last_event }), { nowMs: NOW });
|
|
51
|
+
assert.equal(d.state, 'alive', last_event);
|
|
52
|
+
assert.equal(d.ok, true, last_event);
|
|
53
|
+
assert.equal(d.remedy, undefined, last_event);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── the runner side: which rows count, and the grace ────────────────────────
|
|
58
|
+
|
|
59
|
+
test('configIdleCode: configuration refusals count; deliberate stops and no-work do not', () => {
|
|
60
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_builder_scope' }), 'no_builder_scope');
|
|
61
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_allowlist' }), 'no_allowlist');
|
|
62
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'fence_unreadable' }), 'fence_unreadable');
|
|
63
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', refused_goals: [7] }), 'goal_not_allowlisted');
|
|
64
|
+
assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', task_id: 5 }), null, 'one task outside the allowlist is the fence working');
|
|
65
|
+
assert.equal(ci.configIdleCode({ event: 'hold', unknown: true, reason: 'no gauge file' }), 'gauge_unknown');
|
|
66
|
+
for (const row of [
|
|
67
|
+
{ event: 'fenced', code: 'kill_switch' },
|
|
68
|
+
{ event: 'fenced', code: 'builder_paused' },
|
|
69
|
+
{ event: 'fenced', code: 'not_permitted' },
|
|
70
|
+
{ event: 'fenced', code: 'protected_path', task_id: 3 },
|
|
71
|
+
{ event: 'hold', unknown: false, sleep_until: 1 },
|
|
72
|
+
{ event: 'hold', unknown: false, usage_limit: true },
|
|
73
|
+
{ event: 'nothing_claimable' },
|
|
74
|
+
null,
|
|
75
|
+
]) assert.equal(ci.configIdleCode(row), null, JSON.stringify(row));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the grace: nothing is said until the stretch has held for it', () => {
|
|
79
|
+
const t0 = NOW;
|
|
80
|
+
let tr = ci.trackIdle(null, { event: 'fenced', code: 'no_builder_scope' }, t0);
|
|
81
|
+
assert.equal(ci.idleEvent(tr, t0 + (ci.GRACE_S - 1) * 1000), null);
|
|
82
|
+
tr = ci.trackIdle(tr, { event: 'fenced', code: 'no_builder_scope' }, t0 + 600_000);
|
|
83
|
+
assert.equal(tr.sinceMs, t0, 'the same code continues the stretch');
|
|
84
|
+
assert.equal(ci.idleEvent(tr, t0 + ci.GRACE_S * 1000), 'config_idle:no_builder_scope');
|
|
85
|
+
// A blip-prone code waits longer.
|
|
86
|
+
const fu = ci.trackIdle(null, { event: 'fenced', code: 'fence_unreadable' }, t0);
|
|
87
|
+
assert.equal(ci.idleEvent(fu, t0 + ci.GRACE_S * 1000), null);
|
|
88
|
+
assert.equal(ci.idleEvent(fu, t0 + ci.LONG_GRACE_S * 1000), 'config_idle:fence_unreadable');
|
|
89
|
+
// A different code restarts the clock; any other row ends it.
|
|
90
|
+
assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'no_allowlist' }, t0 + 1).sinceMs, t0 + 1);
|
|
91
|
+
assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'kill_switch' }, t0 + 1), null);
|
|
92
|
+
assert.equal(ci.trackIdle(tr, { event: 'nothing_claimable' }, t0 + 1), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('markBeat leaves a working, booting or exiting beat alone', () => {
|
|
96
|
+
const tr = { code: 'no_builder_scope', sinceMs: NOW - 3600_000 };
|
|
97
|
+
assert.equal(ci.markBeat({ last_event: 'waiting' }, tr, NOW).last_event, 'config_idle:no_builder_scope');
|
|
98
|
+
assert.equal(ci.markBeat({ last_event: 'loop', mode: 'full' }, tr, NOW).mode, 'full');
|
|
99
|
+
assert.equal(ci.markBeat({ last_event: 'worked', working_task_id: 9 }, tr, NOW).last_event, 'worked');
|
|
100
|
+
assert.equal(ci.markBeat({ last_event: 'boot' }, tr, NOW).last_event, 'boot');
|
|
101
|
+
assert.equal(ci.markBeat({ last_event: 'upgrade_exit' }, tr, NOW).last_event, 'upgrade_exit');
|
|
102
|
+
assert.equal(ci.markBeat({ last_event: 'waiting' }, null, NOW).last_event, 'waiting');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('THE LOOP: a runner fenced on no_builder_scope says so on its heartbeat once the grace has passed', async () => {
|
|
106
|
+
// Driven through the real forever(), so the beat override is wired, not just written.
|
|
107
|
+
let clock = NOW;
|
|
108
|
+
const beats = [];
|
|
109
|
+
const deps = {
|
|
110
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
|
|
111
|
+
iteration: async () => ({ event: 'fenced', code: 'no_builder_scope', reason: 'r' }),
|
|
112
|
+
waitOrJump: async (waitS, d) => { clock += 10 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
|
|
113
|
+
record: (r) => r,
|
|
114
|
+
emit: () => {},
|
|
115
|
+
writeHeartbeat: () => true,
|
|
116
|
+
postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
|
|
117
|
+
codeDrifted: async () => false,
|
|
118
|
+
loadedHead: async () => null,
|
|
119
|
+
now: () => clock,
|
|
120
|
+
};
|
|
121
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
|
|
122
|
+
assert.equal(beats[0], 'boot');
|
|
123
|
+
// The first loop is inside the grace: pending, which the hall reads as a fence wait.
|
|
124
|
+
assert.equal(beats[1], 'loop');
|
|
125
|
+
assert.equal(beats[2], 'config_pending:no_builder_scope');
|
|
126
|
+
assert.equal(describeRunner(hb({ last_event: beats[2] }), { nowMs: NOW }).ok, true, 'inside the grace is not an alert');
|
|
127
|
+
assert.ok(beats.includes('config_idle:no_builder_scope'), `beats: ${beats.join(',')}`);
|
|
128
|
+
assert.equal(beats[beats.length - 1], 'config_idle:no_builder_scope', 'and keeps saying it while it holds');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('THE LOOP: the kill switch never turns into a config_idle beat', async () => {
|
|
132
|
+
let clock = NOW;
|
|
133
|
+
const beats = [];
|
|
134
|
+
const deps = {
|
|
135
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
|
|
136
|
+
iteration: async () => ({ event: 'fenced', code: 'kill_switch', reason: 'r' }),
|
|
137
|
+
waitOrJump: async (waitS, d) => { clock += 60 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
|
|
138
|
+
record: (r) => r, emit: () => {},
|
|
139
|
+
writeHeartbeat: () => true,
|
|
140
|
+
postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
|
|
141
|
+
codeDrifted: async () => false, loadedHead: async () => null, now: () => clock,
|
|
142
|
+
};
|
|
143
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
|
|
144
|
+
assert.ok(!beats.some((b) => String(b).startsWith('config_idle:')), beats.join(','));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── the notice: once per change ─────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
test('the Discord notice is DE-DUPLICATED: once when it starts, once when it clears', async () => {
|
|
150
|
+
const posts = [];
|
|
151
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
152
|
+
const beat = (lastEvent, extra = {}) => n.observe({ builderId: '3', host: 'WinBox', lastEvent, ...extra });
|
|
153
|
+
|
|
154
|
+
await beat('waiting');
|
|
155
|
+
assert.equal(posts.length, 0, 'quiet idle says nothing');
|
|
156
|
+
for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
|
|
157
|
+
// A runner restart mid-stretch: boot, then the same setting pending again.
|
|
158
|
+
for (const e of ['loop', 'waiting', 'upgrade_exit', 'boot', 'loop', 'config_pending:no_builder_scope', 'waiting']) await beat(e);
|
|
159
|
+
await beat('config_idle:no_builder_scope');
|
|
160
|
+
assert.equal(posts.length, 1, 'thirty beats and a restart are one notice');
|
|
161
|
+
assert.match(posts[0].text, /no goals are picked/);
|
|
162
|
+
assert.match(posts[0].text, /Settings/);
|
|
163
|
+
assert.equal(posts[0].channel, 'ship_news');
|
|
164
|
+
|
|
165
|
+
await beat('config_idle:no_allowlist');
|
|
166
|
+
assert.equal(posts.length, 2, 'a different cause is a new notice');
|
|
167
|
+
await beat('nothing_claimable');
|
|
168
|
+
assert.equal(posts.length, 3, 'clearing is said once');
|
|
169
|
+
assert.match(posts[2].text, /no longer held/);
|
|
170
|
+
assert.match(posts[2].text, /no goal is allowed/, 'the clear names the setting in words');
|
|
171
|
+
for (const p of posts) assert.doesNotMatch(p.text, /no_allowlist|no_builder_scope/, 'never a raw code in a sentence a person reads');
|
|
172
|
+
await beat('nothing_claimable');
|
|
173
|
+
await beat('waiting');
|
|
174
|
+
assert.equal(posts.length, 3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the kill switch never posts, and a working beat clears a notice', async () => {
|
|
178
|
+
const posts = [];
|
|
179
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
180
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'fenced' });
|
|
181
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:kill_switch' });
|
|
182
|
+
assert.equal(posts.length, 0);
|
|
183
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
184
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'loop', workingTaskId: 42 });
|
|
185
|
+
assert.equal(posts.length, 2);
|
|
186
|
+
// Separate runners are separate notices.
|
|
187
|
+
await n.observe({ builderId: 1, host: 'b', lastEvent: 'config_idle:gauge_unknown' });
|
|
188
|
+
await n.observe({ builderId: 2, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
189
|
+
assert.equal(posts.length, 4);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a gauge notice clears when the runner moves to an ordinary capacity hold, or the owner throws the switch', async () => {
|
|
193
|
+
for (const after of ['hold', 'fenced']) {
|
|
194
|
+
const posts = [];
|
|
195
|
+
const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
|
|
196
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
|
|
197
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: 'waiting' });
|
|
198
|
+
await n.observe({ builderId: 1, host: 'a', lastEvent: after });
|
|
199
|
+
assert.equal(posts.length, 2, after);
|
|
200
|
+
assert.match(posts[1].text, /no longer held/, after);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the host lands in Discord as plain code, never a link or formatting', () => {
|
|
205
|
+
const t = ci.noticeText({ action: 'raise', code: 'no_allowlist', host: '[click](https://evil.example)*@everyone*' });
|
|
206
|
+
assert.doesNotMatch(t, /\]\(|https?:|\*|@/);
|
|
207
|
+
assert.match(t, /on `clickhttpsevil\.exampleeveryone`/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('no Discord on the instance: nothing posts and nothing is remembered', async () => {
|
|
211
|
+
const n = ci.createNotifier({ post: null });
|
|
212
|
+
const r = await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
|
|
213
|
+
assert.deepEqual([r.posted, r.reason], [false, 'no_discord']);
|
|
214
|
+
assert.equal(n.notified.size, 0);
|
|
215
|
+
// A port that answers "no webhook for that channel" is the same.
|
|
216
|
+
const m = ci.createNotifier({ post: async () => ({ posted: false, reason: 'no_webhook_for_channel' }) });
|
|
217
|
+
await m.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
|
|
218
|
+
assert.equal(m.notified.size, 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('the hall draws idle_config as a warning with the fix, on the Gate page and in Settings', () => {
|
|
222
|
+
const fs = require('node:fs');
|
|
223
|
+
const gate = fs.readFileSync('modules/hall-ui/public/gate.js', 'utf8');
|
|
224
|
+
assert.match(gate, /idle_config: 'warn'/, 'the dot is a warning, not ok');
|
|
225
|
+
assert.match(gate, /h\.state === 'idle_config'[\s\S]{0,200}h\.remedy/, 'the checklist carries the remedy');
|
|
226
|
+
const panel = fs.readFileSync('modules/hall-ui/public/settings-autobongos.js', 'utf8');
|
|
227
|
+
assert.match(panel, /'GET', `\$\{API\}\/autonomy\/runners`/);
|
|
228
|
+
assert.match(panel, /h\.state !== 'idle_config'/);
|
|
229
|
+
assert.match(panel, /escapeHtml\(h\.remedy/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── the route: POST /autonomy/heartbeat posts through the discord.post port ──
|
|
233
|
+
|
|
234
|
+
test('POST /autonomy/heartbeat: thirty config-idle beats are ONE Discord post', async () => {
|
|
235
|
+
const path = require('node:path');
|
|
236
|
+
const express = require('express');
|
|
237
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
238
|
+
const api = require('../src/module-api.js');
|
|
239
|
+
const saved = { b: api.requireBuilder, p: api.requirePermission, r: api.resolveOptional, q: api.pool.query };
|
|
240
|
+
const posts = [];
|
|
241
|
+
api.requireBuilder = (r, _s, next) => { r.builder = { id: 7, rank: 'metic' }; next(); };
|
|
242
|
+
api.requirePermission = () => (_r, _s, next) => next();
|
|
243
|
+
api.resolveOptional = (name, dflt) => (name === 'discord.post' ? async (m) => { posts.push(m); return { posted: true }; } : dflt);
|
|
244
|
+
api.pool.query = async () => ({ rows: [{ host: 'winbox' }] });
|
|
245
|
+
const routePath = require.resolve(path.resolve('modules/autonomy/routes/autonomy.js'));
|
|
246
|
+
delete require.cache[routePath];
|
|
247
|
+
let server;
|
|
248
|
+
try {
|
|
249
|
+
const router = require(routePath)();
|
|
250
|
+
const app = express();
|
|
251
|
+
app.use(express.json()); app.use(attachFail); app.use(router);
|
|
252
|
+
server = app.listen(0);
|
|
253
|
+
await new Promise((r) => server.once('listening', r));
|
|
254
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
255
|
+
const beat = async (last_event) => {
|
|
256
|
+
const r = await fetch(`${base}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ host: 'WinBox', last_event }) });
|
|
257
|
+
assert.equal(r.status, 200);
|
|
258
|
+
await new Promise((res) => setImmediate(res));
|
|
259
|
+
};
|
|
260
|
+
await beat('fenced');
|
|
261
|
+
for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
|
|
262
|
+
await beat('waiting');
|
|
263
|
+
await beat('config_idle:no_builder_scope');
|
|
264
|
+
assert.equal(posts.length, 1);
|
|
265
|
+
assert.match(posts[0].text, /`WinBox`/);
|
|
266
|
+
assert.match(posts[0].text, /Settings/);
|
|
267
|
+
} finally {
|
|
268
|
+
if (server) server.close();
|
|
269
|
+
api.requireBuilder = saved.b; api.requirePermission = saved.p; api.resolveOptional = saved.r; api.pool.query = saved.q;
|
|
270
|
+
delete require.cache[routePath];
|
|
271
|
+
}
|
|
272
|
+
});
|
|
@@ -398,3 +398,32 @@ test('a trailing slash is still /deploy', async () => {
|
|
|
398
398
|
await new Promise((r) => setTimeout(r, 0));
|
|
399
399
|
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
test('the banner asks with the project\'s stored update rule (task 1004535)', async () => {
|
|
403
|
+
const r2 = createRequire(import.meta.url);
|
|
404
|
+
const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
|
|
405
|
+
const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
|
|
406
|
+
const realAuth = r2(authPath);
|
|
407
|
+
const savedBuilder = realAuth.requireBuilder;
|
|
408
|
+
const savedPerm = realAuth.requirePermission;
|
|
409
|
+
realAuth.requireBuilder = (_req, _res, next) => next();
|
|
410
|
+
realAuth.requirePermission = () => (_req, _res, next) => next();
|
|
411
|
+
delete r2.cache[routePath];
|
|
412
|
+
let seen;
|
|
413
|
+
const express = r2('express');
|
|
414
|
+
const app = express();
|
|
415
|
+
app.use(r2(routePath)({ coreUpdate: async (a) => { seen = a; return { reading: 'ok', behind: true, channel: a.channel }; }, selfChannel: async () => 'minor' }));
|
|
416
|
+
const server = app.listen(0);
|
|
417
|
+
await new Promise((res) => server.once('listening', res));
|
|
418
|
+
try {
|
|
419
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/core-update`);
|
|
420
|
+
assert.equal(res.status, 200);
|
|
421
|
+
assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
|
|
422
|
+
assert.equal((await res.json()).update.channel_source, 'deploy');
|
|
423
|
+
} finally {
|
|
424
|
+
server.close();
|
|
425
|
+
realAuth.requireBuilder = savedBuilder;
|
|
426
|
+
realAuth.requirePermission = savedPerm;
|
|
427
|
+
delete r2.cache[routePath];
|
|
428
|
+
}
|
|
429
|
+
});
|
|
@@ -866,10 +866,25 @@ await t('a box that reached the target by another road supersedes the failure',
|
|
|
866
866
|
|
|
867
867
|
await t('AN UNREADABLE REGISTRY LEAVES A FAILURE STANDING — not knowing is not evidence of recovery', () => {
|
|
868
868
|
const now = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x' };
|
|
869
|
-
|
|
869
|
+
// An unreadable registry is what the snapshot writer stores when `npm view` fails: an
|
|
870
|
+
// EMPTY available list, and so no recommendation (provision-core-upgrade.js snapshotCoreVersions).
|
|
871
|
+
const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, available: [], recommended: null }, null, now);
|
|
870
872
|
assert.equal(p.last_move.superseded, false, 'the direction to fail in is showing an old failure, never hiding a live one');
|
|
871
873
|
});
|
|
872
874
|
|
|
875
|
+
await t('an UP-TO-DATE box supersedes an old failure: a read registry with nothing on offer is not "unknown" (task 1004534)', () => {
|
|
876
|
+
// The live shape, 2026-10-02: cloudbongos served 1.21.5, the registry answered (1168
|
|
877
|
+
// versions), nothing newer was within its rule, and the page still led with a day-old
|
|
878
|
+
// "waiting on a decision" about 1.20.73. recommended=null was being read as "the
|
|
879
|
+
// registry could not be read", so the failure could never become history.
|
|
880
|
+
const old = { action: 'core-upgrade', state: 'error', target_version: '1.20.73', resolved_at: new Date().toISOString(), last_error: 'x', failure_class: 'needs_decision', failure_reason: 'outside_channel' };
|
|
881
|
+
const p = CU.previewFromSnapshot(ACTIVE, { served_version: '1.21.5', available: ['1.20.73', '1.21.5'], recommended: null, observed_at: new Date().toISOString() }, null, old);
|
|
882
|
+
assert.equal(p.behind, false);
|
|
883
|
+
assert.equal(p.last_move.superseded, true, 'the box is current; a failure about another version is history');
|
|
884
|
+
assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, true), true);
|
|
885
|
+
assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, false), false, 'an unread registry still leaves it standing');
|
|
886
|
+
});
|
|
887
|
+
|
|
873
888
|
await t('lastMoveSuperseded computes no version ORDERING — only identity', () => {
|
|
874
889
|
// The page's standing rule is that no version comparison happens outside the runner's
|
|
875
890
|
// channel resolution. This helper answers "is this news" without ever asking which of
|
|
@@ -885,6 +900,21 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
|
|
|
885
900
|
assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
|
|
886
901
|
});
|
|
887
902
|
|
|
903
|
+
console.log('\nthe platform\'s own rule, for Settings and the banner (task 1004535):');
|
|
904
|
+
|
|
905
|
+
await t('selfUpdateChannel reads the CONTROL-PLANE row\'s rule', async () => {
|
|
906
|
+
const sqls = [];
|
|
907
|
+
const db = { query: async (sql) => { sqls.push(sql); return { rows: [{ update_channel: 'minor' }] }; } };
|
|
908
|
+
assert.equal(await CU.selfUpdateChannel(db), 'minor');
|
|
909
|
+
assert.match(sqls[0], /hosting_shape = 'control-plane'/, 'this hall\'s own row, never a tenant\'s');
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await t('selfUpdateChannel answers null with no row, an unknown value, or a failed read', async () => {
|
|
913
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [] }) }), null);
|
|
914
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [{ update_channel: 'nightly' }] }) }), null);
|
|
915
|
+
assert.equal(await CU.selfUpdateChannel({ query: async () => { throw new Error('relation does not exist'); } }), null);
|
|
916
|
+
});
|
|
917
|
+
|
|
888
918
|
console.log('\nthe project\'s update rule (task 1004445):');
|
|
889
919
|
|
|
890
920
|
const channelHandler = (inst, calls) => updateChannelHandlerFor({
|
|
@@ -232,6 +232,26 @@ await t('the channel refusal names a step that EXISTS on /deploy, never "change
|
|
|
232
232
|
assert.match(major, /upgrading-the-core\.md/);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
await t('a target the box ALREADY SERVES is not a channel refusal — the move is a no-op (task 1004534)', () => {
|
|
236
|
+
// 2026-10-01 16:32: the sweep and the door both went for 1.20.73; the sweep got there
|
|
237
|
+
// first, and the door's press was refused as "outside this project's 'minor' update
|
|
238
|
+
// channel … across a major version or onto a prerelease" — false on every count.
|
|
239
|
+
const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
|
|
240
|
+
const compare = require('../scripts/gds/update-channel.js').compareSemver;
|
|
241
|
+
assert.equal(R.targetFault('1.20.73', ['1.20.72', '1.20.73'], '1.20.73', 'minor', channelAllows, compare), null,
|
|
242
|
+
'no fault: the runner\'s own already-serving check answers it as a no-op');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await t('a target BELOW what the box serves says so, and is never called outside the update rule (task 1004534)', () => {
|
|
246
|
+
const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
|
|
247
|
+
const compare = require('../scripts/gds/update-channel.js').compareSemver;
|
|
248
|
+
const f = R.targetFault('1.20.73', ['1.20.73', '1.21.5'], '1.21.5', 'minor', channelAllows, compare);
|
|
249
|
+
assert.ok(f, 'still refused — a move never goes backwards on its own');
|
|
250
|
+
assert.doesNotMatch(f, /update channel|major version|prerelease/, 'none of the channel sentence is true here');
|
|
251
|
+
assert.match(f, /already runs core 1\.21\.5/);
|
|
252
|
+
assert.equal(require('../scripts/gds/upgrade-outcome.js').runnerReason(f), 'downgrade');
|
|
253
|
+
});
|
|
254
|
+
|
|
235
255
|
await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
|
|
236
256
|
const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
|
|
237
257
|
assert.match(empty, /could not read the published core versions/);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// tests/discord_craft_broadcast.mjs — the Discord feed announces an artist's and
|
|
2
|
+
// an ideator's moments, not only shipped tasks (task 1004439 / WA6.RP09, goal
|
|
3
|
+
// 1000095, criterion wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: "a ratified idea and an approved page each post one line to the
|
|
6
|
+
// feed". Run for real, through the module's own posters with the webhook stubbed:
|
|
7
|
+
//
|
|
8
|
+
// 1. A RATIFIED IDEA. board.item.passed for a full_idea posts ONE line to
|
|
9
|
+
// #ship-feed naming its author; an amendment or a genesis stage posts nothing;
|
|
10
|
+
// the idea's title never leaves the hall.
|
|
11
|
+
// 2. A FULL IDEA AT THE BAR. idea.graded.full posts at economy's own pass bar
|
|
12
|
+
// and not below it, naming the developer when someone else developed it.
|
|
13
|
+
// 3. AN APPROVED PAGE. A page-tweak round's ship line says "page approved" and
|
|
14
|
+
// names its ARTIST (the shipped_by of task 1004432), never the applier; a
|
|
15
|
+
// security-flagged round still goes only to the security feed.
|
|
16
|
+
// 4. THE WIRING. The module subscribes to both events, and nothing here pays.
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
28
|
+
const { test, summary } = makeRunner();
|
|
29
|
+
const craft = require('../modules/discord/craft-broadcast.js');
|
|
30
|
+
const ship = require('../modules/discord/ship-broadcast.js');
|
|
31
|
+
const credits = require('../modules/economy/credits.js');
|
|
32
|
+
|
|
33
|
+
const BUILDERS = { 7: { display_name: 'Builder 7' }, 9: { display_name: 'Builder 9' }, 4: { display_name: 'Builder 4' } };
|
|
34
|
+
function harness({ channel = true } = {}) {
|
|
35
|
+
const posts = [];
|
|
36
|
+
return {
|
|
37
|
+
posts,
|
|
38
|
+
deps: {
|
|
39
|
+
getBuilder: async (id) => BUILDERS[id] || null,
|
|
40
|
+
hasChannel: () => channel,
|
|
41
|
+
post: async (ch, text) => { posts.push({ ch, text }); },
|
|
42
|
+
postWebhook: async (ch, text) => { posts.push({ ch, text }); },
|
|
43
|
+
reward: { IDEA_FULL_PASS_BAR: credits.IDEA_FULL_PASS_BAR },
|
|
44
|
+
log: () => {},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- 1. a ratified idea ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
await test('a ratified idea posts ONE line to #ship-feed, naming its author and linking the idea', async () => {
|
|
52
|
+
const h = harness();
|
|
53
|
+
const r = await craft.postIdeaRatified({ item_id: 31, subject_type: 'full_idea', subject_id: 812, idea_id: 812, author_id: 9, completeness_score: 80, passed_at: 'now' }, h.deps);
|
|
54
|
+
assert.equal(r.posted, true);
|
|
55
|
+
assert.equal(h.posts.length, 1, 'one line');
|
|
56
|
+
assert.equal(h.posts[0].ch, 'ship_feed');
|
|
57
|
+
assert.match(h.posts[0].text, /The board ratified an idea by Builder 9\./);
|
|
58
|
+
assert.match(h.posts[0].text, /\/#\/idea\/812$/, 'the hall link carries the rest to whoever may see it');
|
|
59
|
+
assert.ok(!h.posts[0].text.startsWith('#'), 'never read by the applause bot as a ship');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('an amendment or a genesis stage is the project\'s decision, not an ideator\'s moment — nothing posts', async () => {
|
|
63
|
+
for (const subject_type of ['constitutional_amendment', 'genesis_stage']) {
|
|
64
|
+
const h = harness();
|
|
65
|
+
const r = await craft.postIdeaRatified({ item_id: 2, subject_type, subject_id: 3, idea_id: null, author_id: 4 }, h.deps);
|
|
66
|
+
assert.equal(r.skipped, 'not_an_idea');
|
|
67
|
+
assert.equal(h.posts.length, 0);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await test('the idea\'s title is never posted, and a name cannot carry formatting or a link', async () => {
|
|
72
|
+
const h = harness();
|
|
73
|
+
BUILDERS[66] = { display_name: '@everyone [click](evil.example) **x**' };
|
|
74
|
+
await craft.postIdeaRatified({ subject_type: 'full_idea', idea_id: 5, author_id: 66, title: 'Secret plan for the private goal' }, h.deps);
|
|
75
|
+
assert.ok(!h.posts[0].text.includes('Secret plan'), 'a payload title would not be read even if one were sent');
|
|
76
|
+
assert.ok(!/[\[\]()*@]/.test(h.posts[0].text.split(' by ')[1].split('.')[0]), 'the shared name sanitizer ran');
|
|
77
|
+
delete BUILDERS[66];
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('an unconfigured feed is a named skip, and a failed post never throws', async () => {
|
|
81
|
+
const off = harness({ channel: false });
|
|
82
|
+
assert.equal((await craft.postIdeaRatified({ subject_type: 'full_idea', idea_id: 1, author_id: 9 }, off.deps)).skipped, 'channel_not_configured');
|
|
83
|
+
const broken = harness();
|
|
84
|
+
broken.deps.post = async () => { throw new Error('discord down'); };
|
|
85
|
+
const r = await craft.postIdeaRatified({ subject_type: 'full_idea', idea_id: 1, author_id: 9 }, broken.deps);
|
|
86
|
+
assert.equal(r.skipped, 'post_failed');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('an author who cannot be read leaves the line unnamed, never fails it', async () => {
|
|
90
|
+
const h = harness();
|
|
91
|
+
await craft.postIdeaRatified({ subject_type: 'full_idea', idea_id: 1, author_id: 12345 }, h.deps);
|
|
92
|
+
assert.match(h.posts[0].text, /^\u{1F3DB}\u{FE0F} The board ratified an idea\. /u);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---- 2. a Full Idea at the bar ----------------------------------------------------
|
|
96
|
+
|
|
97
|
+
await test('a Full Idea posts at economy\'s own pass bar, and not below it', async () => {
|
|
98
|
+
const bar = credits.IDEA_FULL_PASS_BAR;
|
|
99
|
+
const at = harness();
|
|
100
|
+
await craft.postFullIdeaPassed({ id: 40, idea_id: 40, grade: 'full', completeness_score: bar, captured_by: 9 }, at.deps);
|
|
101
|
+
assert.equal(at.posts.length, 1);
|
|
102
|
+
assert.match(at.posts[0].text, /Builder 9's Full Idea passed its completeness check\./);
|
|
103
|
+
const below = harness();
|
|
104
|
+
const r = await craft.postFullIdeaPassed({ id: 41, grade: 'full', completeness_score: bar - 1, captured_by: 9 }, below.deps);
|
|
105
|
+
assert.equal(r.skipped, 'below_bar');
|
|
106
|
+
assert.equal(below.posts.length, 0);
|
|
107
|
+
const unscored = harness();
|
|
108
|
+
assert.equal((await craft.postFullIdeaPassed({ id: 42, completeness_score: null, captured_by: 9 }, unscored.deps)).skipped, 'below_bar');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('a developed spark names its developer, as the credit split does; no economy, no bar, no line', async () => {
|
|
112
|
+
const h = harness();
|
|
113
|
+
await craft.postFullIdeaPassed({ id: 43, completeness_score: 90, captured_by: 9, developed_by: 7 }, h.deps);
|
|
114
|
+
assert.match(h.posts[0].text, /Builder 7's Full Idea/);
|
|
115
|
+
const none = harness();
|
|
116
|
+
none.deps.reward = null;
|
|
117
|
+
const r = await craft.postFullIdeaPassed({ id: 43, completeness_score: 90, captured_by: 9 }, { ...none.deps, reward: {} });
|
|
118
|
+
assert.equal(r.skipped, 'below_bar');
|
|
119
|
+
assert.equal(none.posts.length, 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('the reward port carries the bar the feed reads', () => {
|
|
123
|
+
const reward = require('../modules/economy/reward.js');
|
|
124
|
+
assert.equal(reward.IDEA_FULL_PASS_BAR, credits.IDEA_FULL_PASS_BAR);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---- 3. an approved page ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
await test('an approved page posts one line saying so, naming its ARTIST, not the applier', async () => {
|
|
130
|
+
const h = harness();
|
|
131
|
+
// shipped_by is the round's artist since task 1004432; the applier (4) is nowhere in the row
|
|
132
|
+
const r = await ship.postShipBroadcast({ id: 501, value_summary: 'The landing page reads plainly now.', security_sensitive: false, source: 'page-tweak', shipped_by: 7 }, h.deps);
|
|
133
|
+
assert.deepEqual(r.posted, ['ship_feed']);
|
|
134
|
+
assert.equal(h.posts.length, 1, 'one line');
|
|
135
|
+
assert.equal(h.posts[0].text, '#501 page approved — rewritten by Builder 7: The landing page reads plainly now.');
|
|
136
|
+
assert.ok(!h.posts[0].text.includes('Builder 4'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await test('an ordinary ship is unchanged, and a security-flagged page round goes only to the security feed', async () => {
|
|
140
|
+
const h = harness();
|
|
141
|
+
await ship.postShipBroadcast({ id: 502, value_summary: 'Faster claims.', security_sensitive: false, source: null, shipped_by: 4 }, h.deps);
|
|
142
|
+
assert.equal(h.posts[0].text, '#502 shipped — Faster claims.');
|
|
143
|
+
const sec = harness();
|
|
144
|
+
await ship.postShipBroadcast({ id: 503, value_summary: 'x', security_sensitive: true, source: 'page-tweak', shipped_by: 7 }, sec.deps);
|
|
145
|
+
assert.deepEqual(sec.posts.map((p) => p.ch), ['security_ship_feed']);
|
|
146
|
+
assert.ok(!sec.posts[0].text.includes('Builder 7'), 'the security line is untouched');
|
|
147
|
+
const unknown = harness();
|
|
148
|
+
await ship.postShipBroadcast({ id: 504, value_summary: 'y', security_sensitive: false, source: 'page-tweak', shipped_by: 9999 }, unknown.deps);
|
|
149
|
+
assert.equal(unknown.posts[0].text, '#504 page approved: y', 'an unreadable artist names nobody rather than the applier');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---- 4. the wiring ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
await test('the module subscribes to both events, and nothing on these paths pays', () => {
|
|
155
|
+
const routes = fs.readFileSync(path.join(ROOT, 'modules/discord/routes/discord.js'), 'utf8');
|
|
156
|
+
assert.match(routes, /api\.on\('board\.item\.passed', \(payload\) => craftBroadcast\.postIdeaRatified\(payload\)\)/);
|
|
157
|
+
assert.match(routes, /api\.on\('idea\.graded\.full', \(graded\) => craftBroadcast\.postFullIdeaPassed\(graded\)\)/);
|
|
158
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/discord/craft-broadcast.js'), 'utf8');
|
|
159
|
+
assert.ok(!/award|credit_log|INSERT/i.test(src.replace(/\/\/.*$/gm, '')), 'a mirror: it reads names and posts, it never books');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
summary();
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -340,7 +340,7 @@ test('a demo\'s genesis card: no decide form, the settled plan in one line, each
|
|
|
340
340
|
await new Promise((r) => setImmediate(r));
|
|
341
341
|
assert.equal(host.hidden, false);
|
|
342
342
|
assert.match(host.innerHTML, /Monarchy \/ BDFL, 3 people, 4 hours each/);
|
|
343
|
-
assert.match(host.innerHTML, /about 49
|
|
343
|
+
assert.match(host.innerHTML, /about 49 min\. /, 'one unit for a demo stage: "min", as the stage rows say (task 1004504)');
|
|
344
344
|
assert.match(host.innerHTML, /14 min, due /, 'the stage in progress is due at a time, not a date');
|
|
345
345
|
assert.match(host.innerHTML, /Stage 1 of 5 · scope · demo/);
|
|
346
346
|
assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
|
|
@@ -93,7 +93,9 @@ test('every /governance route gates via REAL middleware on exactly its PINNED at
|
|
|
93
93
|
// everything outside the Board Room family — the /board/ routes and the
|
|
94
94
|
// /constitution read (R18: reading the rule you vote under is part of
|
|
95
95
|
// casting) — still gates on government.manage.
|
|
96
|
-
|
|
96
|
+
// task 1004523: the docket reads the board family's atom too — opening the city
|
|
97
|
+
// is part of casting, and each source inside is scoped to its own atom.
|
|
98
|
+
const boardFamily = (key) => key.includes('/board/') || key.endsWith('/government/constitution') || key.endsWith('/government/docket');
|
|
97
99
|
for (const r of classified.filter((x) => !boardFamily(x.key))) {
|
|
98
100
|
assert.deepEqual(r.perms, ['government.manage'], `${r.key}: the admin surface gates on the manage atom`);
|
|
99
101
|
}
|