@bongos/core 1.21.32 → 1.21.34
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 +68 -43
- 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 +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
- package/docs/api/openapi.json +96 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +29 -25
- package/docs/copy-registry.json +65 -29
- package/docs/design/craft-mode-direction.md +69 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -1
- package/docs/page-readings.json +270 -201
- package/modules/government/catalog.js +3 -0
- package/modules/government/migrations/government_025_module_delist.sql +36 -0
- package/modules/hall-ui/public/modules.js +8 -1
- package/modules/hall-ui/public/street-desk.js +285 -0
- package/modules/hall-ui/public/street-inside.js +8 -4
- package/modules/hall-ui/public/street.css +59 -0
- package/modules/hall-ui/public/street.html +9 -3
- package/modules/hall-ui/public/street.js +219 -17
- package/modules/hall-ui/public/street.states.json +83 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +21 -5
- package/src/bongos/module-store.js +31 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/modules.js +35 -0
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/hall_matter_card.mjs +1 -1
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_street_amend.mjs +1 -1
- package/tests/hall_street_desk.mjs +328 -0
- package/tests/hall_street_inside.mjs +1 -1
- package/tests/module_catalog.mjs +34 -6
- package/tests/module_store_delist.mjs +146 -0
|
@@ -258,7 +258,7 @@ async function bootPage({ feeds, posts = {} }) {
|
|
|
258
258
|
};
|
|
259
259
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite });
|
|
260
260
|
ctx.globalThis = ctx;
|
|
261
|
-
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
261
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
262
262
|
await window.GovStreetPage.ready;
|
|
263
263
|
return { window, byId, calls, focused };
|
|
264
264
|
}
|
package/tests/hall_street.mjs
CHANGED
|
@@ -376,7 +376,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
|
|
|
376
376
|
assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
|
|
377
377
|
for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
378
378
|
const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
379
|
-
assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
|
|
379
|
+
assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526), the floor desk (task 1004558) and the drafted amendment (task 1004530)');
|
|
380
380
|
assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
|
|
381
381
|
});
|
|
382
382
|
|
|
@@ -253,7 +253,7 @@ async function bootPage({ hash = '#building=review', cons = constitution(), answ
|
|
|
253
253
|
};
|
|
254
254
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, setTimeout, clearTimeout });
|
|
255
255
|
ctx.globalThis = ctx;
|
|
256
|
-
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
256
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-amend.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
257
257
|
await window.GovStreetPage.ready;
|
|
258
258
|
await tick();
|
|
259
259
|
return { window, byId, focused, gets, posts, history };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// tests/hall_street_desk.mjs — THE FLOOR DESK AND THE LINE RULE (task 1004558,
|
|
2
|
+
// goal 1000125; round 2 of the Governor City).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS. The chosen floor of a building is the desk from the round 2
|
|
5
|
+
// board (docs/design/mocks/governor-city/Desks.dc.html), built by
|
|
6
|
+
// GovDesk.deskModel() (street-desk.js) and drawn by street.js. What can go wrong:
|
|
7
|
+
// * THE LINE RULE breaking on real data: a row name over three words, a
|
|
8
|
+
// second fact, a third button, a tile that reads zero or carries a
|
|
9
|
+
// sentence, a status that is orange when nothing needs you, an empty list
|
|
10
|
+
// where a quiet floor should be one lit line. Pinned by running EVERY
|
|
11
|
+
// building and EVERY floor of a deliberately long-winded docket through
|
|
12
|
+
// GovDesk.breaks() — the test that fails when the docket produces a line
|
|
13
|
+
// the rule does not allow;
|
|
14
|
+
// * fit() drifting from the board's own fit() (run from the board's
|
|
15
|
+
// Component class) or the limits drifting from the board's numbers;
|
|
16
|
+
// * a row's button sending anything but the matter card's own request to
|
|
17
|
+
// the existing route, or a reason arriving as free text instead of a chip;
|
|
18
|
+
// * the game layer going quiet: the act that does not clear its row, count a
|
|
19
|
+
// pip on the today meter, light the cleared floor with a tick, or say a toast.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/hall_street_desk.mjs
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
require(path.join(PUB, 'city-draw.js'));
|
|
35
|
+
const S = require(path.join(PUB, 'street-draw.js'));
|
|
36
|
+
const I = require(path.join(PUB, 'street-inside.js'));
|
|
37
|
+
const C = require(path.join(PUB, 'street-card.js'));
|
|
38
|
+
const D = require(path.join(PUB, 'street-desk.js'));
|
|
39
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
40
|
+
const NOW = Date.parse('2026-10-03T12:00:00.000Z');
|
|
41
|
+
const j = (v) => JSON.parse(JSON.stringify(v));
|
|
42
|
+
|
|
43
|
+
// the board's own Component class, run beside the desk
|
|
44
|
+
function board() {
|
|
45
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
|
|
46
|
+
const script = src.split('data-dc-script')[1].split('>').slice(1).join('>').split('</script>')[0];
|
|
47
|
+
const DCLogic = class { constructor(p) { this.props = p || {}; this.state = {}; } setState(s) { Object.assign(this.state, s); } };
|
|
48
|
+
const Comp = new Function('DCLogic', 'setTimeout', 'clearTimeout', `${script}\nreturn Component;`)(DCLogic, () => 0, () => {});
|
|
49
|
+
return new Comp({ mode: 'dark' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── a long-winded docket: every source, long words everywhere ─────────────────
|
|
53
|
+
const LONG = 'The extraordinarily long-winded proposal to reorganise every single floor of the hall before winter';
|
|
54
|
+
const SOURCES = [['board', 'charter'], ['blockers', 'planning'], ['access_requests', 'people'], ['goal_join_requests', 'people'], ['override_requests', 'release'], ['removal_proposals', 'disputes'], ['redteam_reports', 'disputes'], ['criteria_uat', 'review'], ['core_update', 'release'], ['gate_approvals', 'release'], ['autobongos_fence', 'automation'], ['module_rechecks', 'release'], ['grade_appeals', 'disputes'], ['standing_rules', 'rules'], ['treasury_spending', 'treasury']];
|
|
55
|
+
function matter(over = {}) {
|
|
56
|
+
return { id: 'blockers:1', source: 'blockers', building: 'planning', rule: 'majority', state: 'needs', title: 'A matter', short: 'a matter', where: 'Planning', opened_at: '2026-09-29T10:00:00.000Z', deadline: null, proposer: null, tally: null, href: null, ref: null, ...over };
|
|
57
|
+
}
|
|
58
|
+
function docket(matters, over = {}) {
|
|
59
|
+
const counts = { needs: 0, moving: 0, settled: 0 };
|
|
60
|
+
matters.forEach((m) => { counts[m.state] += 1; });
|
|
61
|
+
return {
|
|
62
|
+
viewer: { id: '95' }, counts, line: '', matters,
|
|
63
|
+
buildings: S.CIVIC_KEYS.map((key) => ({ key, name: key[0].toUpperCase() + key.slice(1), group: '', counts: {}, matter_ids: [] })),
|
|
64
|
+
sources: SOURCES.map(([source, building]) => ({ source, building, label: source, status: 'live', count: 0, reason: null })),
|
|
65
|
+
readings: ['flow', 'promises', 'money', 'people', 'fairness', 'rules'].map((key) => ({ key, label: key, building: 'people', status: 'live', value: 0, figure: key === 'money' ? '$1,234,567.89 left this month' : '0 people', line: LONG, hot: false, reason: null })),
|
|
66
|
+
floors: Object.values(I.PLAN).flatMap((p) => p.floors).map((f) => f[2] && f[2].floor).filter(Boolean).map((key, i) => ({ key, building: key.split('.')[0], status: 'live', count: i % 3 === 0 ? '0' : i % 3 === 1 ? '1,204,331' : '', line: i % 2 ? LONG : '12 things that the floor counts', lines: [[LONG, 'today']], hot: i % 4 === 0, reason: null })),
|
|
67
|
+
history: S.CIVIC_KEYS.flatMap((b) => [0, 1, 2, 3, 4].map((n) => ({ id: `h:${b}:${n}`, source: 'government', building: b, title: LONG, at: '2026-09-2' + n + 'T10:00:00.000Z', outcome: 'passed after a very long sitting' }))),
|
|
68
|
+
...over,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function longMatters() {
|
|
72
|
+
const out = [];
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const [source, building] of SOURCES) {
|
|
75
|
+
for (const state of ['needs', 'moving', 'settled']) {
|
|
76
|
+
n += 1;
|
|
77
|
+
out.push(matter({ id: `${source}:${n}`, source, building, state, title: `${LONG} ${n}`, short: 'a matter with a very long short description', where: 'Somewhere far away in the hall, vouched for by everyone', proposer: { name: 'Maximiliana Theodora Alexandrovna-Wellington III', login: 'maxi' }, deadline: '2026-10-05T10:00:00.000Z', opened_at: `2026-0${(n % 9) + 1}-1${n % 9}T10:00:00.000Z`, href: source === 'goal_join_requests' ? '/builders/goal/42' : source === 'core_update' ? '/settings#software-update' : null, tally: source === 'board' ? { open: 3, needs: 'needs 4 of 5' } : null }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
out.push(matter({ id: 'board:900', source: 'board', building: 'planning', short: 'idea to ratify', title: LONG, state: 'needs' }));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
const ROOM = { open: [{ id: '1', subject_type: 'constitutional_amendment', subject: { title: LONG, author_id: '7' }, votes: [] }, { id: '900', subject_type: 'full_idea', subject: { title: LONG }, votes: [] }], closed: [] };
|
|
84
|
+
|
|
85
|
+
function everyDesk(dk, extra = {}) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const key of S.CIVIC_KEYS) {
|
|
88
|
+
I.PLAN[key].floors.forEach((_f, floor) => out.push(D.deskModel({ key, floor, docket: dk, goals: [{ id: '1', title: LONG, status: 'open' }], now: NOW, room: ROOM, viewerId: '95', ...extra })));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── the line rule ─────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
test('fit() is the board\'s fit(), with a word cap on top', () => {
|
|
96
|
+
const b = board();
|
|
97
|
+
for (const [s, n] of [['Governor city', 20], [LONG, 20], [LONG, 44], ['Ana Ruiz', 18], ['exactly twenty chars', 20], [' spaced out ', 30]]) {
|
|
98
|
+
assert.equal(D.fit(s, n), b.fit(String(s).replace(/\s+/g, ' ').trim(), n), `${s} @ ${n}`);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(D.fit('Raise the hosting limit', 20, 3), 'Raise the hosting…', 'a cut by words says so');
|
|
101
|
+
assert.equal(D.fit('Ana Ruiz', 20, 3), 'Ana Ruiz', 'nothing cut, nothing marked');
|
|
102
|
+
for (const n of [5, 12, 20]) for (const w of [1, 2, 3]) assert.ok(D.fit(LONG, n, w).length <= n, 'never longer than its cap');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the limits are the board\'s numbers: name 20, fact 26, facts 24 (three), status 44, record 30 (four), three tiles, ten pips', () => {
|
|
106
|
+
const L = D.LIMITS;
|
|
107
|
+
assert.deepEqual([L.name, L.nameWords, L.fact, L.factLen, L.facts, L.actions, L.status, L.recordLen, L.record, L.tiles, L.tileWords, L.todayTarget], [20, 3, 26, 24, 3, 2, 44, 30, 4, 3, 2, 10]);
|
|
108
|
+
const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
|
|
109
|
+
for (const pin of ['this.fit(r.t, 20)', 'this.fit(r.f, 26)', 'this.fit(t, 24)', '.slice(0, 3)', "this.fit('nothing needs you · ' + F.quiet, 44)", "this.fit(t + ' · now', 30)", '.slice(0, 4)', 'const target = 10']) assert.ok(src.includes(pin), `the board says ${pin}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('THE LINE RULE: no row, tile or status any docket produces breaks it, on every floor of every building', () => {
|
|
113
|
+
const dk = docket(longMatters());
|
|
114
|
+
const desks = everyDesk(dk);
|
|
115
|
+
assert.equal(desks.length, S.CIVIC_KEYS.reduce((a, k) => a + I.PLAN[k].floors.length, 0));
|
|
116
|
+
const broken = desks.flatMap((d) => D.breaks(d));
|
|
117
|
+
assert.deepEqual(broken, []);
|
|
118
|
+
// the stress docket really reaches every part of the rule
|
|
119
|
+
const rows = desks.flatMap((d) => d.rows);
|
|
120
|
+
assert.ok(rows.length >= 30, 'rows were drawn');
|
|
121
|
+
assert.ok(rows.some((r) => r.actions.length === 2) && rows.some((r) => r.chips) && rows.some((r) => r.wait), 'two buttons, chips and a wait pill were all exercised');
|
|
122
|
+
assert.ok(desks.some((d) => d.quiet), 'a quiet floor was exercised');
|
|
123
|
+
assert.ok(desks.some((d) => d.tiles.length === 3), 'three tiles were exercised');
|
|
124
|
+
assert.ok(desks.some((d) => d.hot), 'an orange status was exercised');
|
|
125
|
+
const moving = everyDesk(docket(longMatters().filter((m) => m.state !== 'needs')));
|
|
126
|
+
assert.ok(moving.some((d) => !d.hot && d.rows.length), 'a grey status over rows was exercised');
|
|
127
|
+
assert.deepEqual(moving.flatMap((d) => D.breaks(d)), []);
|
|
128
|
+
assert.ok(rows.some((r) => r.name.endsWith('…')), 'a long title was cut, and says so');
|
|
129
|
+
// the same docket with acts done, floors cleared, and with nothing readable
|
|
130
|
+
assert.deepEqual(everyDesk(dk, { done: Object.fromEntries(dk.matters.map((m) => [m.id, true])), cleared: true, mine: [LONG, 'let in Ana'], today: 123456 }).flatMap((d) => D.breaks(d)), []);
|
|
131
|
+
assert.deepEqual(everyDesk(null).flatMap((d) => D.breaks(d)), []);
|
|
132
|
+
assert.deepEqual(everyDesk(docket([])).flatMap((d) => D.breaks(d)), []);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('breaks() names every way a desk can break the rule (so the rule test can fail)', () => {
|
|
136
|
+
const dk = docket(longMatters());
|
|
137
|
+
const good = D.deskModel({ key: 'people', floor: 4, docket: dk, now: NOW });
|
|
138
|
+
assert.deepEqual(D.breaks(good), []);
|
|
139
|
+
const bad = j(good);
|
|
140
|
+
bad.rows[0].name = 'Four words in here';
|
|
141
|
+
bad.rows[0].fact = 'x'.repeat(27);
|
|
142
|
+
bad.rows[0].actions = [{ label: 'a' }, { label: 'b' }, { label: 'c' }];
|
|
143
|
+
bad.rows[0].facts = ['a', 'b', 'c', 'd'];
|
|
144
|
+
bad.tiles = [{ v: '0', l: 'need you' }, { v: '3', l: 'three whole words' }, { v: '4', l: 'ok' }, { v: '5', l: 'ok' }];
|
|
145
|
+
bad.status = 'x'.repeat(45);
|
|
146
|
+
bad.hot = false;
|
|
147
|
+
const why = D.breaks(bad).join('\n');
|
|
148
|
+
for (const w of ['row name', 'row fact', 'more than two buttons', 'row facts', 'tile value', 'tile label', 'more than three tiles', 'status too long', 'orange only when']) assert.match(why, new RegExp(w), w);
|
|
149
|
+
const empty = j(good); empty.rows = []; empty.quiet = false;
|
|
150
|
+
assert.match(D.breaks(empty).join(), /quiet and rows disagree/, 'never an empty list');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── which matters sit on which floor ──────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('a floor holds its own source; the ideas floor holds the open ideas; the rest sit at the front desk; settled is the record', () => {
|
|
156
|
+
const ms = [
|
|
157
|
+
matter({ id: 'access_requests:1', source: 'access_requests', building: 'people', state: 'needs' }),
|
|
158
|
+
matter({ id: 'access_requests:2', source: 'access_requests', building: 'people', state: 'settled' }),
|
|
159
|
+
matter({ id: 'board:5', source: 'board', building: 'people', state: 'needs' }),
|
|
160
|
+
matter({ id: 'board:6', source: 'board', building: 'planning', short: 'idea to ratify', state: 'moving' }),
|
|
161
|
+
];
|
|
162
|
+
const dk = docket(ms);
|
|
163
|
+
const at = (k, f) => D.floorMatters(k, f, dk).map((m) => m.id);
|
|
164
|
+
assert.deepEqual(at('people', 4), ['access_requests:1'], 'Waiting to join: the open request only');
|
|
165
|
+
assert.deepEqual(at('people', 0), ['board:5'], 'a sitting sits at the front desk');
|
|
166
|
+
assert.deepEqual(at('planning', 2), ['board:6'], 'Ideas to ratify');
|
|
167
|
+
assert.deepEqual(at('planning', 0), [], 'an idea is not also at the front desk');
|
|
168
|
+
assert.equal(D.firstNeeds('people', dk), 0);
|
|
169
|
+
assert.equal(D.firstNeeds('rules', dk), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── the acts are the card's own requests ──────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
test('a row\'s buttons are the card\'s acts under the board\'s words, and send the card\'s own request', () => {
|
|
175
|
+
const ms = [
|
|
176
|
+
matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } }),
|
|
177
|
+
matter({ id: 'goal_join_requests:12', source: 'goal_join_requests', building: 'people', state: 'needs', proposer: { name: 'Kai Mo' }, href: '/builders/goal/42', ref: { id: 12 } }),
|
|
178
|
+
matter({ id: 'override_requests:13', source: 'override_requests', building: 'release', state: 'needs', ref: { id: 13 } }),
|
|
179
|
+
matter({ id: 'gate_approvals:14', source: 'gate_approvals', building: 'release', state: 'needs', ref: { id: 14 } }),
|
|
180
|
+
matter({ id: 'board:1', source: 'board', building: 'charter', state: 'needs', ref: { type: 'board_item', id: '1' } }),
|
|
181
|
+
];
|
|
182
|
+
const dk = docket(ms);
|
|
183
|
+
const row = (k, f) => D.rowsFor(k, f, dk, { room: ROOM, now: NOW, viewerId: '95' })[0];
|
|
184
|
+
const ana = row('people', 4);
|
|
185
|
+
assert.equal(ana.name, 'Ana Ruiz', 'a person is named by their name');
|
|
186
|
+
assert.deepEqual(ana.actions.map((a) => [a.key, a.label, a.tone]), [['fine', 'let in', 'primary'], ['refuse', 'not now', 'quiet']]);
|
|
187
|
+
assert.deepEqual(j(C.request(ana.card, 'fine')), { method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } });
|
|
188
|
+
assert.deepEqual(j(C.request(row('people', 6).card, 'refuse')), { method: 'POST', url: '/api/bongos/goals/42/requests/12/respond', body: { action: 'deny' } });
|
|
189
|
+
const rel = D.rowsFor('release', 0, dk, { now: NOW });
|
|
190
|
+
assert.deepEqual(rel[0].actions.map((a) => a.label), ['release', 'hold']);
|
|
191
|
+
assert.equal(C.request(rel[0].card, 'release').url, '/api/bongos/override-requests/13/decide');
|
|
192
|
+
assert.equal(C.request(D.rowsFor('release', 5, dk, { now: NOW })[0].card, 'release').url, '/api/bongos/gate-approvals/14/approve');
|
|
193
|
+
// a vote: yes and no, and the no's reason is a chip, never free text
|
|
194
|
+
const vote = row('charter', 0);
|
|
195
|
+
assert.deepEqual(vote.actions.map((a) => [a.key, a.label]), [['for', 'yes'], ['object', 'no']]);
|
|
196
|
+
assert.equal(vote.chips.for, 'object');
|
|
197
|
+
assert.deepEqual(vote.chips.options.map((c) => c.key), C.SECTIONS.map((s) => s.key), 'every chip is a section the server accepts');
|
|
198
|
+
const chip = vote.chips.options[0];
|
|
199
|
+
assert.equal(C.check(vote.card, 'object', chip.input), null, 'one tap is a complete objection');
|
|
200
|
+
assert.deepEqual(j(C.request(vote.card, 'object', chip.input).body), { direction: 'no', reason_md: chip.input.reason, section_key: 'scope' });
|
|
201
|
+
// before the sitting is read the row waits, never guesses a ballot
|
|
202
|
+
assert.equal(D.rowsFor('charter', 0, dk, { now: NOW })[0].wait, 'reading…');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── the page: the desk acts in place, and the game layer answers ──────────────
|
|
206
|
+
|
|
207
|
+
function fakeDom() {
|
|
208
|
+
const byId = new Map();
|
|
209
|
+
const focused = { el: null };
|
|
210
|
+
const mk = (tag) => ({
|
|
211
|
+
tagName: tag, attrs: {}, kids: [], className: '', listeners: {}, _text: '', hidden: false,
|
|
212
|
+
style: { setProperty(k, v) { this[k] = v; } },
|
|
213
|
+
clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
|
|
214
|
+
get children() { const a = this.kids.slice(); a[Symbol.isConcatSpreadable] = false; return a; },
|
|
215
|
+
get firstChild() { return this.kids[0] || null; },
|
|
216
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
|
|
217
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
|
218
|
+
appendChild(c) { this.kids.push(c); c.parentNode = this; return c; },
|
|
219
|
+
removeChild(c) { this.kids = this.kids.filter((x) => x !== c); return c; },
|
|
220
|
+
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
|
221
|
+
fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() {} })); },
|
|
222
|
+
focus() { focused.el = this; },
|
|
223
|
+
set textContent(v) { this._text = String(v); this.kids = []; },
|
|
224
|
+
get textContent() { return this._text; },
|
|
225
|
+
get innerHTML() { throw new Error('no innerHTML'); },
|
|
226
|
+
set innerHTML(v) { throw new Error('no innerHTML'); },
|
|
227
|
+
});
|
|
228
|
+
const html = read('street.html').replace(/<!--[\s\S]*?-->/g, '');
|
|
229
|
+
for (const id of [...html.matchAll(/id="([A-Za-z]+)"/g)].map((m) => m[1])) {
|
|
230
|
+
const el = mk('x');
|
|
231
|
+
el.setAttribute('id', id);
|
|
232
|
+
const tag = new RegExp(`<[^>]*\\bid="${id}"[^>]*>`).exec(html);
|
|
233
|
+
if (tag && /\shidden(?=[\s>])/.test(tag[0])) el.hidden = true;
|
|
234
|
+
}
|
|
235
|
+
return { document: { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_n, t) => mk(t) }, byId, focused };
|
|
236
|
+
}
|
|
237
|
+
async function bootPage(hash, ms, { refuse = false } = {}) {
|
|
238
|
+
const { document, byId, focused } = fakeDom();
|
|
239
|
+
const sent = [];
|
|
240
|
+
const store = new Map();
|
|
241
|
+
let matters = ms.slice();
|
|
242
|
+
const feeds = () => ({ '/api/bongos/government/docket': docket(matters), '/api/bongos/goals': { goals: [] }, '/api/bongos/goals/rollups': { rollups: [] } });
|
|
243
|
+
const location = { hash, href: '' };
|
|
244
|
+
const window = {
|
|
245
|
+
innerWidth: 1440, innerHeight: 900, location,
|
|
246
|
+
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
247
|
+
matchMedia: () => ({ matches: true, addEventListener() {} }),
|
|
248
|
+
addEventListener() {}, history: { pushState() {} },
|
|
249
|
+
};
|
|
250
|
+
const fetch = (url, init) => {
|
|
251
|
+
if (init && init.method && init.method !== 'GET') {
|
|
252
|
+
sent.push({ method: init.method, url, body: JSON.parse(init.body || '{}') });
|
|
253
|
+
if (refuse) return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: { code: 'not_pending' } }) });
|
|
254
|
+
matters = matters.filter((m) => !url.includes('/' + String(m.id).split(':')[1]));
|
|
255
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true }) });
|
|
256
|
+
}
|
|
257
|
+
const body = feeds()[url];
|
|
258
|
+
return Promise.resolve(body === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => body });
|
|
259
|
+
};
|
|
260
|
+
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, Set });
|
|
261
|
+
ctx.globalThis = ctx;
|
|
262
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
263
|
+
await window.GovStreetPage.ready;
|
|
264
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
265
|
+
const deskEl = () => (byId.get('insideOpenA').hidden ? byId.get('insideOpenB') : byId.get('insideOpenA'));
|
|
266
|
+
const part = (cls) => deskEl().children.find((c) => c.className.split(' ').includes(cls));
|
|
267
|
+
return { window, byId, focused, sent, store, deskEl, part };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test('the page: the desk acts in place — the row clears, the floor lights with a tick, the meter takes a pip, the toast speaks', async () => {
|
|
271
|
+
const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 }, opened_at: '2026-10-01T12:00:00.000Z' })];
|
|
272
|
+
const p = await bootPage('#building=people', ms);
|
|
273
|
+
const html = read('street.html');
|
|
274
|
+
assert.ok(html.indexOf('/builders/street-desk.js?') > html.indexOf('/builders/street-inside.js?') && html.indexOf('/builders/street-desk.js?') < html.indexOf('/builders/street.js?'), 'the desk loads after the interior and before the page');
|
|
275
|
+
assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'the building opens on the floor that needs you');
|
|
276
|
+
const head = p.deskEl().children[0].children[1];
|
|
277
|
+
assert.deepEqual([head.children[1].textContent, head.children[2].textContent, head.children[2].className], ['Waiting to join', '1 waiting · oldest 2 days', 'desk-status desk-status--hot']);
|
|
278
|
+
assert.deepEqual(p.part('desk-tiles').children.map((t) => [t.children[0].textContent, t.children[1].textContent]), [['1', 'waiting'], ['5', 'on record']], 'the first tile is the count in the floor noun, as on the board; a zero tile (nothing moving) is hidden');
|
|
279
|
+
const row = p.part('desk-rows').children[0];
|
|
280
|
+
const name = row.children[0].children[1];
|
|
281
|
+
assert.equal(name.children[0].textContent, 'Ana Ruiz');
|
|
282
|
+
name.fire('click');
|
|
283
|
+
const opened = p.part('desk-rows').children[0];
|
|
284
|
+
assert.equal(opened.children[0].children[1].attrs['aria-expanded'], 'true');
|
|
285
|
+
assert.ok(opened.children[1].children.length >= 1 && opened.children[1].children.length <= 3, 'at most three facts open in place');
|
|
286
|
+
const acts = opened.children[0].children[2].children;
|
|
287
|
+
assert.deepEqual(acts.map((b) => b.textContent), ['not now', 'let in'], 'the primary sits rightmost');
|
|
288
|
+
acts[1].fire('click');
|
|
289
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
|
290
|
+
assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } }], 'the card\'s own route, nothing else');
|
|
291
|
+
assert.match(p.byId.get('streetToast').textContent, /Ana Ruiz is invited in/);
|
|
292
|
+
assert.equal(p.byId.get('streetToast').hidden, false);
|
|
293
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
|
|
294
|
+
assert.equal(p.byId.get('streetTodayPips').children.filter((x) => x.className.includes('--on')).length, 1, 'one pip per decision');
|
|
295
|
+
assert.equal(JSON.parse(p.store.get('gov-desk-today')).n, 1, 'the day keeps its count');
|
|
296
|
+
const quiet = p.part('desk-quiet');
|
|
297
|
+
assert.ok(quiet && quiet.className.includes('desk-quiet--cleared'), 'a cleared floor is one lit line, never an empty list');
|
|
298
|
+
assert.deepEqual(quiet.children.slice(1).map((x) => x.textContent), ['Everyone is in', 'lit up · 1 decided today']);
|
|
299
|
+
assert.equal(p.part('desk-rows'), undefined);
|
|
300
|
+
assert.equal(p.part('desk-record').children[1].textContent, 'let in Ana Ruiz · now', 'the record line starts with what you did');
|
|
301
|
+
assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'you stay on the floor you cleared');
|
|
302
|
+
// the drawing: the cleared floor's windows lit and its tick drawn
|
|
303
|
+
const bv = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 4, cleared: [4] });
|
|
304
|
+
const plain = I.buildingModel({ key: 'people', docket: docket([]), goals: [], mode: 'dark', now: NOW, floor: 0 });
|
|
305
|
+
assert.ok(bv.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'a tick on the cleared floor');
|
|
306
|
+
assert.ok(!plain.shapes.some((s) => /l6 6 12-13$/.test(s.d)), 'no tick on a floor nobody cleared');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('the page: a refused act keeps the row and says why; no pip is counted', async () => {
|
|
310
|
+
const ms = [matter({ id: 'access_requests:11', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Ana Ruiz' }, ref: { id: 11 } })];
|
|
311
|
+
const p = await bootPage('#building=people', ms, { refuse: true });
|
|
312
|
+
p.part('desk-rows').children[0].children[0].children[2].children[1].fire('click');
|
|
313
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
|
314
|
+
assert.equal(p.byId.get('streetToast').textContent, 'Someone already decided this.');
|
|
315
|
+
assert.equal(p.part('desk-rows').children.length, 1);
|
|
316
|
+
assert.equal(p.byId.get('streetTodayN').textContent, '0 decided');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('the stylesheet: the desk in tokens only, its motion dies under reduced motion, the chips clear 24px', () => {
|
|
320
|
+
const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
321
|
+
const desk = css.split('\n').filter((l) => /^\.(desk|street-today)/.test(l)).join('\n');
|
|
322
|
+
assert.ok(desk.length > 0);
|
|
323
|
+
assert.doesNotMatch(desk, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no literal colours on the desk');
|
|
324
|
+
assert.match(css, /\.desk-chip \{[^}]*height: 34px/);
|
|
325
|
+
assert.match(css, /\.desk-row--gone \{[^}]*animation: desk-gone/);
|
|
326
|
+
assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
|
|
327
|
+
assert.match(read('street.html'), /id="streetTodayPips"/);
|
|
328
|
+
});
|
|
@@ -508,7 +508,7 @@ async function bootPage({ hash = '', feeds = {}, innerWidth = 1440 } = {}) {
|
|
|
508
508
|
};
|
|
509
509
|
const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map });
|
|
510
510
|
ctx.globalThis = ctx;
|
|
511
|
-
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
511
|
+
for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street.js']) vm.runInContext(read(f), ctx);
|
|
512
512
|
await window.GovStreetPage.ready;
|
|
513
513
|
await new Promise((r) => setTimeout(r, 0));
|
|
514
514
|
const fireWin = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
|
package/tests/module_catalog.mjs
CHANGED
|
@@ -225,10 +225,11 @@ test('formatCatalog: a search with no hits says so instead of printing an empty
|
|
|
225
225
|
assert.ok(!/KEY\s+SOURCE/.test(out), 'no header row over an empty result');
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
test('cmdList: --json emits the rows + summary, and honours --catalog/--search', () => {
|
|
228
|
+
test('cmdList: --json emits the rows + summary, and honours --catalog/--search', async () => {
|
|
229
229
|
const lines = [];
|
|
230
|
-
const code = mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
|
|
230
|
+
const code = await mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
|
|
231
231
|
log: (s) => lines.push(s),
|
|
232
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
232
233
|
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
233
234
|
});
|
|
234
235
|
assert.equal(code, 0);
|
|
@@ -239,17 +240,18 @@ test('cmdList: --json emits the rows + summary, and honours --catalog/--search',
|
|
|
239
240
|
assert.deepEqual(payload.modules.map((m) => m.key), ['provisioning']);
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
test('cmdList: --search with no value is a usage error, not a silent empty listing', () => {
|
|
243
|
+
test('cmdList: --search with no value is a usage error, not a silent empty listing', async () => {
|
|
243
244
|
const errs = [];
|
|
244
|
-
const code = mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
|
|
245
|
+
const code = await mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
|
|
245
246
|
assert.equal(code, 2);
|
|
246
247
|
assert.match(errs.join('\n'), /usage: bongos module list/);
|
|
247
248
|
});
|
|
248
249
|
|
|
249
|
-
test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', () => {
|
|
250
|
+
test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', async () => {
|
|
250
251
|
const errs = [];
|
|
251
|
-
const code = mod.cmdList([], {
|
|
252
|
+
const code = await mod.cmdList([], {
|
|
252
253
|
log: () => {},
|
|
254
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
253
255
|
errlog: (s) => errs.push(s),
|
|
254
256
|
gather: () => ({ rows: sample(), skipped: [{ key: 'rotted', reason: 'coreVersion "^0.1.0" not satisfied by core 1.19.0' }], errors: [], core: '1.19.0' }),
|
|
255
257
|
});
|
|
@@ -257,6 +259,32 @@ test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently
|
|
|
257
259
|
assert.match(errs.join('\n'), /rotted: not listed — coreVersion/);
|
|
258
260
|
});
|
|
259
261
|
|
|
262
|
+
test('cmdList: an installed store module the store delisted is marked, and only instance modules are asked about (task 1003797)', async () => {
|
|
263
|
+
const lines = [];
|
|
264
|
+
const asked = [];
|
|
265
|
+
const code = await mod.cmdList([], {
|
|
266
|
+
log: (s) => lines.push(s),
|
|
267
|
+
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
268
|
+
checkStore: async (rows) => {
|
|
269
|
+
asked.push(...rows.map((r) => r.key));
|
|
270
|
+
return { checked: true, latest: Object.fromEntries(rows.map((r) => [r.key, { version: '9.9.9', status: 'delisted' }])) };
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
assert.equal(code, 0);
|
|
274
|
+
const instanceKeys = sample().filter((r) => r.source === 'instance').map((r) => r.key);
|
|
275
|
+
assert.deepEqual(asked, instanceKeys, 'a core module is never a store install, so it is not asked about');
|
|
276
|
+
const out = lines.join('\n');
|
|
277
|
+
assert.match(out, /DELISTED/);
|
|
278
|
+
assert.match(out, /your copy keeps working, but no more versions will come/);
|
|
279
|
+
const json = [];
|
|
280
|
+
await mod.cmdList(['--json'], {
|
|
281
|
+
log: (s) => json.push(s),
|
|
282
|
+
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
283
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
284
|
+
});
|
|
285
|
+
assert.ok(JSON.parse(json.join('\n')).modules.every((m) => m.store_delisted === false), 'offline: nothing is marked');
|
|
286
|
+
});
|
|
287
|
+
|
|
260
288
|
// ---------------------------------------------------------------------------
|
|
261
289
|
// The live tree — the catalog must actually describe THIS checkout
|
|
262
290
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tests/module_store_delist.mjs — a Metic+ person can take a module out of the store,
|
|
2
|
+
// and a delisted module actually leaves it, while every instance that already holds
|
|
3
|
+
// it keeps it (task 1003797, ADR 0338 D2). Drives the real POST
|
|
4
|
+
// /store/modules/:key/delist handler off the real router, and the real store reads
|
|
5
|
+
// behind acquire / download / update checks, against a stand-in pool (no port, no
|
|
6
|
+
// real DB — the module_store_reassess.mjs harness).
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/module_store_delist.mjs
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
|
|
20
|
+
const fresh = () => ({
|
|
21
|
+
modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed', delisted_at: null } },
|
|
22
|
+
versions: [{ id: 7, module_key: 'weather', version: '1.2.0', core_version: '^1.20.0', tree_sha256: 't', tarball_sha256: 'x', tarball_bytes: 10, artifact_path: 'weather/weather-1.2.0.tgz', published_at: '2026-09-30T00:00:00Z' }],
|
|
23
|
+
sql: [],
|
|
24
|
+
});
|
|
25
|
+
let state = fresh();
|
|
26
|
+
const query = async (sql, params = []) => {
|
|
27
|
+
state.sql.push(sql);
|
|
28
|
+
if (/^\s*UPDATE store_modules SET status = 'delisted'/.test(sql)) {
|
|
29
|
+
const m = state.modules[params[0]];
|
|
30
|
+
if (!m || m.status !== 'listed') return { rows: [] };
|
|
31
|
+
m.status = 'delisted'; m.delisted_at = '2026-10-01T00:00:00Z';
|
|
32
|
+
return { rows: [{ ...m }] };
|
|
33
|
+
}
|
|
34
|
+
if (/FROM store_modules WHERE module_key = ANY\(\$1\) AND status = 'delisted'/.test(sql)) {
|
|
35
|
+
return { rows: params[0].map((k) => state.modules[k]).filter((m) => m && m.status === 'delisted') };
|
|
36
|
+
}
|
|
37
|
+
if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
|
|
38
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
39
|
+
}
|
|
40
|
+
if (/JOIN store_modules m/.test(sql)) {
|
|
41
|
+
return { rows: state.versions.filter((v) => params[0].includes(v.module_key)).map((v) => ({ ...v, status: state.modules[v.module_key].status })) };
|
|
42
|
+
}
|
|
43
|
+
if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
|
|
44
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0]) };
|
|
45
|
+
}
|
|
46
|
+
return { rows: [] };
|
|
47
|
+
};
|
|
48
|
+
const pool = { connect: async () => ({ query, release() {} }), query };
|
|
49
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
50
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
|
|
51
|
+
|
|
52
|
+
const store = require('../src/bongos/module-store.js');
|
|
53
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
54
|
+
|
|
55
|
+
const PATH = '/store/modules/:key/delist';
|
|
56
|
+
function handlerFor(router) {
|
|
57
|
+
const layer = router.stack.find((l) => l.route && l.route.path === PATH && l.route.methods.post);
|
|
58
|
+
assert.ok(layer, `no POST ${PATH} on the router`);
|
|
59
|
+
const stack = layer.route.stack;
|
|
60
|
+
return { handle: stack[stack.length - 1].handle, depth: stack.length };
|
|
61
|
+
}
|
|
62
|
+
async function invoke(handle, { key = 'weather', body = {} } = {}) {
|
|
63
|
+
const res = {
|
|
64
|
+
statusCode: 200, body: undefined,
|
|
65
|
+
json(b) { this.body = b; return this; },
|
|
66
|
+
status(c) { this.statusCode = c; return this; },
|
|
67
|
+
set() { return this; },
|
|
68
|
+
fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
|
|
69
|
+
};
|
|
70
|
+
let nextErr = null;
|
|
71
|
+
await handle({ params: { key }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
|
|
72
|
+
if (nextErr) throw nextErr;
|
|
73
|
+
return res;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { handle, depth } = handlerFor(buildModulesRouter());
|
|
77
|
+
|
|
78
|
+
test('the route is gated by its own Metic+ atom, granted to metic and archon, and pinned', () => {
|
|
79
|
+
assert.ok(depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
|
|
80
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
81
|
+
assert.match(src, /router\.post\('\/store\/modules\/:key\/delist', auth\.requireBuilder, auth\.requirePermission\('module\.delist'\)/);
|
|
82
|
+
const { byKey } = require('../modules/government/catalog.js');
|
|
83
|
+
const p = byKey('module.delist');
|
|
84
|
+
assert.equal(p.floor, 'metic');
|
|
85
|
+
assert.equal(p.system, false);
|
|
86
|
+
const mig = fs.readFileSync(path.join(ROOT, 'modules/government/migrations/government_025_module_delist.sql'), 'utf8');
|
|
87
|
+
for (const rank of ['metic', 'archon']) assert.match(mig, new RegExp(`SELECT '${rank}', unnest\\(ARRAY\\[\\s*'module\\.delist'`));
|
|
88
|
+
const pins = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
|
|
89
|
+
assert.match(pins, /'POST \/store\/modules\/:key\/delist': 'perm:module\.delist'/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('no reason, no delist — and only `reason` is accepted', async () => {
|
|
93
|
+
state = fresh();
|
|
94
|
+
for (const body of [{}, { reason: '' }, { reason: ' ' }]) {
|
|
95
|
+
const res = await invoke(handle, { body });
|
|
96
|
+
assert.equal(res.statusCode, 400);
|
|
97
|
+
assert.equal(res.body.error, 'reason_required');
|
|
98
|
+
}
|
|
99
|
+
assert.equal((await invoke(handle, { body: { reason: 'r', revoke: true } })).statusCode, 400, 'unknown fields are refused');
|
|
100
|
+
assert.equal(state.modules.weather.status, 'listed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a bad key is refused, an unknown module is 404, a second delist is 409', async () => {
|
|
104
|
+
state = fresh();
|
|
105
|
+
assert.equal((await invoke(handle, { key: 'Bad_Key', body: { reason: 'r' } })).statusCode, 400);
|
|
106
|
+
const missing = await invoke(handle, { key: 'nope', body: { reason: 'r' } });
|
|
107
|
+
assert.equal(missing.statusCode, 404);
|
|
108
|
+
assert.equal(missing.body.error, 'module_not_found');
|
|
109
|
+
assert.equal((await invoke(handle, { body: { reason: 'r' } })).statusCode, 200);
|
|
110
|
+
const again = await invoke(handle, { body: { reason: 'r' } });
|
|
111
|
+
assert.equal(again.statusCode, 409);
|
|
112
|
+
assert.equal(again.body.error, 'already_delisted');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a delisted module leaves the store: no new install, download, version or update', async () => {
|
|
116
|
+
state = fresh();
|
|
117
|
+
assert.equal((await store.resolveAcquirable('weather', { db: pool })).ok, true, 'installable before');
|
|
118
|
+
const res = await invoke(handle, { body: { reason: 'a security issue was reported' } });
|
|
119
|
+
assert.equal(res.statusCode, 200);
|
|
120
|
+
assert.equal(res.body.status, 'delisted');
|
|
121
|
+
assert.ok(res.body.delisted_at);
|
|
122
|
+
// acquire, tarball and acquired all go through resolveAcquirable.
|
|
123
|
+
const acq = await store.resolveAcquirable('weather', { db: pool });
|
|
124
|
+
assert.equal(acq.ok, false);
|
|
125
|
+
assert.equal(acq.code, 'module_delisted');
|
|
126
|
+
assert.equal((await store.resolveAcquirable('weather', { version: '1.2.0', db: pool })).code, 'module_delisted');
|
|
127
|
+
// `bongos upgrade` sees the status and tells the holder no more versions will come.
|
|
128
|
+
const latest = await store.latestVersions(['weather'], { db: pool });
|
|
129
|
+
assert.equal(latest.weather.status, 'delisted');
|
|
130
|
+
// The hall's Modules tab marks it.
|
|
131
|
+
assert.deepEqual([...(await store.delistedKeySet(['weather', 'other'], { db: pool }))], ['weather']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a delist never touches holders, entitlements or scores (ADR 0338 D2, ADR 0359)', async () => {
|
|
135
|
+
state = fresh();
|
|
136
|
+
await invoke(handle, { body: { reason: 'r' } });
|
|
137
|
+
assert.ok(state.sql.every((q) => !/module_entitlements|module_assessment_scores|module_assessment_signals/.test(q)),
|
|
138
|
+
'the delist writes no entitlement, score or signal row');
|
|
139
|
+
// An installed module loads from the instance's own disk: the loader never asks
|
|
140
|
+
// the store, so a delist cannot stop a held copy from mounting.
|
|
141
|
+
for (const f of fs.readdirSync(path.join(ROOT, 'src/module-loader'))) {
|
|
142
|
+
if (!f.endsWith('.js')) continue;
|
|
143
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/module-loader', f), 'utf8');
|
|
144
|
+
assert.doesNotMatch(src, /store_modules|delisted|\/store\/modules/, `${f} must not consult the store`);
|
|
145
|
+
}
|
|
146
|
+
});
|