@bongos/core 1.19.1044 → 1.19.1046
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 +106 -121
- package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
- package/docs/adr/README.md +1 -1
- package/docs/copy-inventory.md +142 -158
- package/docs/copy-registry.json +173 -360
- package/docs/file-map.md +2 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/claim-action.js +250 -0
- package/modules/hall-ui/public/collab.css +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals-page.js +28 -44
- package/modules/hall-ui/public/goals-render.js +7 -28
- package/modules/hall-ui/public/goals.html +6 -3
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/{people.css → roster.css} +7 -11
- package/modules/hall-ui/public/roster.html +5 -5
- package/modules/hall-ui/public/roster.js +6 -7
- package/modules/hall-ui/public/roster.states.json +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +2 -2
- package/modules/hall-ui/public/shell.js +3 -10
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +2 -9
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +5 -2
- package/modules/hall-ui/public/work.js +35 -123
- package/modules/hall-ui/records/builders-roster.md +1 -1
- package/modules/hall-ui/records/roster-and-ledger.md +2 -2
- package/modules/platform-identity/account-visibility.js +3 -3
- package/modules/platform-identity/platform-identity.js +2 -2
- package/modules/platform-identity/routes/profile.js +1 -1
- package/modules/platform-identity/routes/sso.js +4 -3
- package/modules/platform-identity/scouting-authz.js +2 -2
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/auth.js +9 -24
- package/src/bongos/serve-internal.js +13 -19
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +1 -1
- package/tests/auth_resolve_failure.mjs +6 -6
- package/tests/claim_action.mjs +417 -0
- package/tests/core_upgrade_owner_door.mjs +1 -1
- package/tests/edit_hints.mjs +8 -8
- package/tests/effective_visibility_predicate.mjs +1 -1
- package/tests/goal_category_ui.mjs +3 -0
- package/tests/goal_criterion_confirm_ui.mjs +3 -0
- package/tests/goal_forest_page.mjs +3 -0
- package/tests/goal_invite_consent.mjs +3 -1
- package/tests/goal_mine_lens.mjs +3 -0
- package/tests/government_parity.mjs +1 -1
- package/tests/hall_builders_page_pass.mjs +2 -2
- package/tests/hall_mine_lens.mjs +3 -0
- package/tests/hall_nav.mjs +8 -8
- package/tests/hall_page_gate_map.mjs +14 -11
- package/tests/hall_roster_world.mjs +47 -78
- package/tests/module_contributions.mjs +0 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
- package/tests/profile_rollup_consent.mjs +4 -4
- package/tests/profile_route.mjs +3 -26
- package/tests/scouting_owner_access_and_reach.mjs +4 -8
- package/modules/hall-ui/public/people.html +0 -79
- package/modules/hall-ui/public/people.js +0 -321
- package/modules/hall-ui/public/people.states.json +0 -89
- package/tests/people_directory_clip.mjs +0 -269
- package/tests/people_name_only.mjs +0 -263
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// tests/claim_action.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003458 — the hall has ONE claim/release action, and it answers every
|
|
4
|
+
// refusal the server can give.
|
|
5
|
+
//
|
|
6
|
+
// Two halves:
|
|
7
|
+
// 1. the refusal copy is COMPLETE — a drift guard reads the server's own
|
|
8
|
+
// throw sites and fails if a code exists that this file has no sentence
|
|
9
|
+
// for. That is what makes "a new surface needs no new refusal handling"
|
|
10
|
+
// a fact rather than a hope;
|
|
11
|
+
// 2. the copies are GONE — work.js and goals-render.js no longer define their
|
|
12
|
+
// own, which is the deletion the refactor is proven by.
|
|
13
|
+
//
|
|
14
|
+
// Before this, both boards carried their own switch over 8 of the server's
|
|
15
|
+
// codes, drifted in wording (the same refusal said "The board will refresh."
|
|
16
|
+
// on /work and "Reloading." on /goals, and the goals copy had lost the
|
|
17
|
+
// "Wait for it to ship." advice), and neither had a single test.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/claim_action.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { makeRunner } from './helpers.mjs';
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const repoRoot = join(__dirname, '..');
|
|
30
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
const CA = require('../modules/hall-ui/public/claim-action.js');
|
|
35
|
+
const { friendlyClaimError, makeClaimAction, errCode, errDetails } = CA;
|
|
36
|
+
|
|
37
|
+
const envelope = (code, details = {}, message = '') => ({ error: { code, message, details } });
|
|
38
|
+
const GENERIC = /^The board could not complete that \(/;
|
|
39
|
+
|
|
40
|
+
// == 1. the refusal copy is complete ========================================
|
|
41
|
+
|
|
42
|
+
// The server's throw sites. Scraped, not hand-listed, so the guard cannot go
|
|
43
|
+
// stale the way the two hand-written switches did.
|
|
44
|
+
function serverRefusalCodes() {
|
|
45
|
+
const out = new Set();
|
|
46
|
+
for (const f of [
|
|
47
|
+
['modules', 'lifecycle', 'db-claims.js'],
|
|
48
|
+
['modules', 'lifecycle', 'goal-authz.js'],
|
|
49
|
+
]) {
|
|
50
|
+
for (const m of read(...f).matchAll(/code:\s*'([A-Za-z_]+)'/g)) out.add(m[1]);
|
|
51
|
+
}
|
|
52
|
+
const routes = read('modules', 'lifecycle', 'routes', 'claims.js');
|
|
53
|
+
for (const m of routes.matchAll(/'(session_[a-z_]+|newcomer_must_ship_first)'/g)) out.add(m[1]);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Batch-SHAPE errors, deliberately not this switch's job: a batch refusal is
|
|
58
|
+
// rendered per item by task-lib.js's friendlyBatchReason, and `batch_failed`
|
|
59
|
+
// has its own branch at both call sites (it must list the failing tasks, which
|
|
60
|
+
// a single sentence cannot). Excluded explicitly so the guard stays loud about
|
|
61
|
+
// everything else.
|
|
62
|
+
const BATCH_SHAPE = new Set(['BATCH_FAILED', 'BATCH_EMPTY', 'BATCH_DUPLICATE_IDS']);
|
|
63
|
+
|
|
64
|
+
await test('every server refusal code has a sentence — the drift guard', () => {
|
|
65
|
+
const codes = [...serverRefusalCodes()].filter((c) => !BATCH_SHAPE.has(c)).sort();
|
|
66
|
+
assert.ok(codes.length >= 14, `expected the scrape to find the refusal chain, got ${codes.length}`);
|
|
67
|
+
const unhandled = codes.filter((c) => GENERIC.test(friendlyClaimError(409, envelope(c))));
|
|
68
|
+
assert.deepEqual(unhandled, [],
|
|
69
|
+
`these server refusals fall through to the generic line — add a case in claim-action.js: ${unhandled.join(', ')}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('the five codes NEITHER board used to handle are covered', () => {
|
|
73
|
+
// The regression this task exists to close. Each of these rendered as
|
|
74
|
+
// "The board could not complete that (409). Try again." before the extraction.
|
|
75
|
+
for (const code of ['REBASE_REQUIRED', 'TASK_NOT_FOUND', 'OPEN_BLOCKER',
|
|
76
|
+
'GOAL_MEMBERSHIP_REQUIRED', 'MODULE_CONFLICT']) {
|
|
77
|
+
const line = friendlyClaimError(409, envelope(code));
|
|
78
|
+
assert.doesNotMatch(line, GENERIC, `${code} still falls through`);
|
|
79
|
+
assert.ok(line.length > 20, `${code} needs a real sentence`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('no refusal ever renders a raw object or undefined', () => {
|
|
84
|
+
for (const code of [...serverRefusalCodes()]) {
|
|
85
|
+
const line = friendlyClaimError(409, envelope(code));
|
|
86
|
+
assert.equal(typeof line, 'string');
|
|
87
|
+
assert.doesNotMatch(line, /\[object Object\]|undefined|null/);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('reads the ADR 0116 envelope AND the flat legacy shape', () => {
|
|
92
|
+
// task 1002462: `data.error` became an OBJECT and every string branch silently
|
|
93
|
+
// missed, so each refusal rendered as the generic line.
|
|
94
|
+
const viaEnvelope = friendlyClaimError(409, envelope('ALREADY_CLAIMED'));
|
|
95
|
+
const viaFlat = friendlyClaimError(409, { error: 'ALREADY_CLAIMED' });
|
|
96
|
+
assert.equal(viaEnvelope, viaFlat);
|
|
97
|
+
assert.doesNotMatch(viaEnvelope, GENERIC);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('the server\'s own hint wins where it names the task', () => {
|
|
101
|
+
// REBASE_REQUIRED and GOAL_MEMBERSHIP_REQUIRED carry a message that names the
|
|
102
|
+
// specific task or goal; a generic sentence would be strictly less useful.
|
|
103
|
+
const msg = 'Task #123 needs rebasing — run /strand-fix 123.';
|
|
104
|
+
assert.equal(friendlyClaimError(409, envelope('REBASE_REQUIRED', {}, msg)), msg);
|
|
105
|
+
// …and it still answers when the server sends none.
|
|
106
|
+
assert.doesNotMatch(friendlyClaimError(409, envelope('REBASE_REQUIRED')), GENERIC);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('TOUCHES_CONFLICT names the overlapping claims and keeps the advice', () => {
|
|
110
|
+
const line = friendlyClaimError(409, envelope('TOUCHES_CONFLICT', { conflicts: [{ title: 'Fix the pager' }] }));
|
|
111
|
+
assert.match(line, /Fix the pager/);
|
|
112
|
+
// The goals copy had dropped this half; it is the only part that tells the
|
|
113
|
+
// builder what to DO.
|
|
114
|
+
assert.match(line, /Wait for it to ship/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('MODULE_CONFLICT names the module when the server sends one', () => {
|
|
118
|
+
assert.match(friendlyClaimError(409, envelope('MODULE_CONFLICT', { module_key: 'lifecycle' })), /"lifecycle"/);
|
|
119
|
+
assert.doesNotMatch(friendlyClaimError(409, envelope('MODULE_CONFLICT')), GENERIC);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('an unknown code still fails closed onto the generic line', () => {
|
|
123
|
+
assert.match(friendlyClaimError(409, envelope('SOMETHING_NEW_2027')), GENERIC);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// == 2. the action ==========================================================
|
|
127
|
+
|
|
128
|
+
function harness({ fail = null } = {}) {
|
|
129
|
+
const posts = [], toasts = [];
|
|
130
|
+
let refreshes = 0;
|
|
131
|
+
const action = makeClaimAction({
|
|
132
|
+
postJSON: async (path, body) => {
|
|
133
|
+
posts.push({ path, body });
|
|
134
|
+
if (fail) throw fail;
|
|
135
|
+
return { ok: true };
|
|
136
|
+
},
|
|
137
|
+
toast: (m) => toasts.push(m),
|
|
138
|
+
refresh: async () => { refreshes += 1; },
|
|
139
|
+
API: '/api/bongos',
|
|
140
|
+
});
|
|
141
|
+
return { action, posts, toasts, r: () => refreshes };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await test('claim posts the task id and does NOT bind the session', () => {
|
|
145
|
+
const h = harness();
|
|
146
|
+
return h.action.claim(42).then(() => {
|
|
147
|
+
assert.deepEqual(h.posts, [{ path: '/api/bongos/claims', body: { task_id: 42, bind_session: false } }]);
|
|
148
|
+
assert.match(h.toasts[0], /#42/);
|
|
149
|
+
assert.equal(h.r(), 1, 'a success refreshes the board');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await test('release resolves the claim as abandoned (there is no DELETE /claims/:id)', () => {
|
|
154
|
+
const h = harness();
|
|
155
|
+
return h.action.release('c9', 42).then(() => {
|
|
156
|
+
assert.equal(h.posts[0].path, '/api/bongos/claims/c9/resolve');
|
|
157
|
+
assert.deepEqual(h.posts[0].body, { outcome: 'abandoned' });
|
|
158
|
+
assert.match(h.toasts[0], /Released #42/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('a refusal toasts the MAPPED sentence, not the raw error', async () => {
|
|
163
|
+
const h = harness({ fail: Object.assign(new Error('x'), { status: 409, data: envelope('OPEN_BLOCKER') }) });
|
|
164
|
+
const out = await h.action.claim(42);
|
|
165
|
+
assert.equal(out.ok, false);
|
|
166
|
+
assert.equal(h.toasts[0], friendlyClaimError(409, envelope('OPEN_BLOCKER')));
|
|
167
|
+
assert.doesNotMatch(h.toasts[0], GENERIC);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('a 409 refreshes the board — it is showing something stale', async () => {
|
|
171
|
+
const h = harness({ fail: Object.assign(new Error('x'), { status: 409, data: envelope('ALREADY_CLAIMED') }) });
|
|
172
|
+
await h.action.claim(42);
|
|
173
|
+
assert.equal(h.r(), 1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('a non-409 failure does NOT refresh', async () => {
|
|
177
|
+
const h = harness({ fail: Object.assign(new Error('x'), { status: 500, data: {} }) });
|
|
178
|
+
await h.action.claim(42);
|
|
179
|
+
assert.equal(h.r(), 0, 'a 500 says nothing about the board being stale');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('the auth-redirect sentinel is swallowed, never toasted', async () => {
|
|
183
|
+
const h = harness({ fail: new Error('redirecting to auth') });
|
|
184
|
+
const out = await h.action.claim(42);
|
|
185
|
+
assert.equal(out.redirected, true);
|
|
186
|
+
assert.deepEqual(h.toasts, [], 'a toast during a redirect is noise the builder never reads');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('a refresh that throws after a refusal does not mask the refusal', async () => {
|
|
190
|
+
const posts = [], toasts = [];
|
|
191
|
+
const action = makeClaimAction({
|
|
192
|
+
postJSON: async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('ALREADY_CLAIMED') }); },
|
|
193
|
+
toast: (m) => toasts.push(m),
|
|
194
|
+
refresh: async () => { throw new Error('network died'); },
|
|
195
|
+
});
|
|
196
|
+
const out = await action.claim(42); // must not reject
|
|
197
|
+
assert.equal(out.ok, false);
|
|
198
|
+
assert.equal(toasts.length, 1, 'the refusal was still told');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('the dry run reports what a bulk claim WOULD do', async () => {
|
|
202
|
+
const action = makeClaimAction({
|
|
203
|
+
postJSON: async (path, body) => {
|
|
204
|
+
assert.equal(path, '/api/bongos/claims/batch/validate');
|
|
205
|
+
assert.deepEqual(body, { task_ids: [7, 8] });
|
|
206
|
+
return { parallel_safe: true, results: [{ task_id: 7, ok: true }] };
|
|
207
|
+
},
|
|
208
|
+
toast: () => {}, refresh: async () => {},
|
|
209
|
+
});
|
|
210
|
+
const out = await action.validateMany([7, 8]);
|
|
211
|
+
assert.equal(out.parallelSafe, true);
|
|
212
|
+
assert.equal(out.byId.get('7').ok, true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('a dry run that FAILS answers "not safe, know nothing" — never throws, never toasts', async () => {
|
|
216
|
+
// The caller uses this to decide whether to offer a parallel prompt. Copying a
|
|
217
|
+
// prompt for tasks nobody could verify is worse than offering nothing, and an
|
|
218
|
+
// unavailable pre-check is not a refusal to report at the builder.
|
|
219
|
+
const toasts = [];
|
|
220
|
+
const action = makeClaimAction({
|
|
221
|
+
postJSON: async () => { throw Object.assign(new Error('down'), { status: 500 }); },
|
|
222
|
+
toast: (m) => toasts.push(m), refresh: async () => {},
|
|
223
|
+
});
|
|
224
|
+
const out = await action.validateMany([7]);
|
|
225
|
+
assert.equal(out.ok, false);
|
|
226
|
+
assert.equal(out.parallelSafe, false);
|
|
227
|
+
assert.equal(out.byId.size, 0);
|
|
228
|
+
assert.deepEqual(toasts, [], 'a best-effort pre-check must stay silent');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// == 3. the deletion ========================================================
|
|
232
|
+
|
|
233
|
+
// The assertion that was MISSING in the first round, and the gap it left is the
|
|
234
|
+
// whole reason this section exists: the shared action can be defined, unit-tested
|
|
235
|
+
// and documented as the contract while every real call site still hand-rolls its
|
|
236
|
+
// own POST + toast + refresh. Green tests, dead code, and a CLAUDE.md rule
|
|
237
|
+
// describing behaviour the code does not have. Assert the call sites, not the
|
|
238
|
+
// module.
|
|
239
|
+
|
|
240
|
+
await test('NOBODY posts to /claims except through the shared action', () => {
|
|
241
|
+
// The falsifiable form of "proven by deletion". If a surface reintroduces its
|
|
242
|
+
// own claim POST, this fails — which is what "a new claim surface needs no new
|
|
243
|
+
// refusal handling" has to mean in practice.
|
|
244
|
+
const offenders = [];
|
|
245
|
+
for (const f of ['work.js', 'goals-page.js', 'goals-render.js', 'task.js']) {
|
|
246
|
+
const src = read('modules', 'hall-ui', 'public', f);
|
|
247
|
+
if (/postJSON\(\s*`\$\{API\}\/claims/.test(src)) offenders.push(f);
|
|
248
|
+
}
|
|
249
|
+
assert.deepEqual(offenders, [],
|
|
250
|
+
`these still hand-roll a claim/release POST instead of calling claimAction: ${offenders.join(', ')}`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('both boards CONSTRUCT the shared action, not just its error text', () => {
|
|
254
|
+
for (const f of ['work.js', 'goals-page.js']) {
|
|
255
|
+
const src = read('modules', 'hall-ui', 'public', f);
|
|
256
|
+
assert.match(src, /makeClaimAction\(\{/, `${f} must build the action`);
|
|
257
|
+
assert.match(src, /describeBatchReason: friendlyBatchReason/,
|
|
258
|
+
`${f} must inject the batch reason mapper, or a batch refusal loses its per-task detail`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await test('every claim/release entry point routes through it', () => {
|
|
263
|
+
const sites = {
|
|
264
|
+
'work.js': ['doClaim', 'doRelease', 'doBulkClaim', 'doBulkRelease'],
|
|
265
|
+
'goals-page.js': ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim'],
|
|
266
|
+
};
|
|
267
|
+
for (const [f, fns] of Object.entries(sites)) {
|
|
268
|
+
const src = read('modules', 'hall-ui', 'public', f);
|
|
269
|
+
for (const fn of fns) {
|
|
270
|
+
const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
|
|
271
|
+
assert.ok(m, `${f}: ${fn} not found`);
|
|
272
|
+
assert.match(m[0], /claimAction\.(claim|release|claimMany|releaseMany)\(/,
|
|
273
|
+
`${f}: ${fn} does not go through the shared action`);
|
|
274
|
+
assert.doesNotMatch(m[0], /friendlyClaimError\(/,
|
|
275
|
+
`${f}: ${fn} still maps its own refusal`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await test('no call site reloads on top of the action\'s own refresh', async () => {
|
|
281
|
+
// The regression the extraction introduced on /goals: the action's injected
|
|
282
|
+
// refresh reloads the open goal's task list, and doGoalBulkClaim's finally
|
|
283
|
+
// reloaded it AGAIN — so every bulk claim there fetched and re-rendered
|
|
284
|
+
// twice. work.js's doBulkClaim is the correct shape: redraw the toolbar,
|
|
285
|
+
// never re-fetch. A finally that awaits a loader is the tell.
|
|
286
|
+
const src = read('modules', 'hall-ui', 'public', 'goals-page.js');
|
|
287
|
+
for (const fn of ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim']) {
|
|
288
|
+
const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
|
|
289
|
+
assert.ok(m, `${fn} not found`);
|
|
290
|
+
assert.doesNotMatch(m[0], /finally \{[\s\S]*?await loadGoalTasks\(/,
|
|
291
|
+
`${fn}: the shared action already refreshed — this reloads a second time`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await test('the action refreshes ONCE per call, not per outcome', async () => {
|
|
296
|
+
let refreshes = 0;
|
|
297
|
+
const mk = (postJSON) => makeClaimAction({
|
|
298
|
+
postJSON, toast: () => {}, refresh: async () => { refreshes += 1; },
|
|
299
|
+
describeBatchReason: (f) => f.reason,
|
|
300
|
+
});
|
|
301
|
+
await mk(async () => ({}))?.claimMany([7, 8]);
|
|
302
|
+
assert.equal(refreshes, 1, 'success');
|
|
303
|
+
refreshes = 0;
|
|
304
|
+
await mk(async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('batch_failed', { failures: [] }) }); }).claimMany([7, 8]);
|
|
305
|
+
assert.equal(refreshes, 1, 'batch refusal');
|
|
306
|
+
refreshes = 0;
|
|
307
|
+
await mk(async () => { throw Object.assign(new Error('x'), { status: 500, data: {} }); }).claimMany([7, 8]);
|
|
308
|
+
assert.equal(refreshes, 0, 'a 500 says nothing about the board being stale');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await test('the batch refusal still names the failing tasks', async () => {
|
|
312
|
+
// All-or-nothing: a batch_failed 409 means NOTHING was claimed, so a generic
|
|
313
|
+
// sentence leaves the builder with no idea what to deselect.
|
|
314
|
+
const toasts = [];
|
|
315
|
+
const action = makeClaimAction({
|
|
316
|
+
postJSON: async () => { throw Object.assign(new Error('x'), { status: 409, data: envelope('batch_failed', { failures: [{ task_id: 7, reason: 'ALREADY_CLAIMED' }] }) }); },
|
|
317
|
+
toast: (m) => toasts.push(m),
|
|
318
|
+
refresh: async () => {},
|
|
319
|
+
describeBatchReason: (f) => (f.reason === 'ALREADY_CLAIMED' ? 'already claimed' : f.reason),
|
|
320
|
+
});
|
|
321
|
+
await action.claimMany([7, 8]);
|
|
322
|
+
assert.match(toasts[0], /#7 \(already claimed\)/);
|
|
323
|
+
assert.doesNotMatch(toasts[0], GENERIC);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await test('a selection of ONE takes the single endpoint, not a batch of one', async () => {
|
|
327
|
+
const posts = [];
|
|
328
|
+
const action = makeClaimAction({
|
|
329
|
+
postJSON: async (path, body) => { posts.push(path); return {}; },
|
|
330
|
+
toast: () => {}, refresh: async () => {},
|
|
331
|
+
});
|
|
332
|
+
await action.claimMany([7]);
|
|
333
|
+
assert.equal(posts[0], '/api/bongos/claims', 'the batch route reports per-item reasons — wrong shape for one');
|
|
334
|
+
posts.length = 0;
|
|
335
|
+
await action.releaseMany([['c1', 7]]);
|
|
336
|
+
assert.equal(posts[0], '/api/bongos/claims/c1/resolve');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await test('a batch release counts rather than lists — it is NOT all-or-nothing', async () => {
|
|
340
|
+
const toasts = [];
|
|
341
|
+
const action = makeClaimAction({
|
|
342
|
+
postJSON: async () => ({ released: [1, 2], failures: [3] }),
|
|
343
|
+
toast: (m) => toasts.push(m), refresh: async () => {},
|
|
344
|
+
});
|
|
345
|
+
await action.releaseMany([['c1', 1], ['c2', 2], ['c3', 3]]);
|
|
346
|
+
assert.match(toasts[0], /Released 2 of 3 — 1 could not be released/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await test('the refusal text is PLAIN TEXT, for textContent — never markup', () => {
|
|
350
|
+
// It interpolates server-controlled strings (module_key, conflict titles, the
|
|
351
|
+
// REBASE_REQUIRED/GOAL_MEMBERSHIP_REQUIRED messages). Every consumer passes it
|
|
352
|
+
// to toast(), which sets textContent; a future caller using innerHTML would
|
|
353
|
+
// turn that into an injection. Pinned here so the contract is checkable.
|
|
354
|
+
const line = friendlyClaimError(409, envelope('MODULE_CONFLICT', { module_key: '<img src=x onerror=alert(1)>' }));
|
|
355
|
+
assert.match(line, /<img src=x onerror=alert\(1\)>/, 'it does not escape — it is text, not markup');
|
|
356
|
+
for (const f of ['work.js', 'goals-page.js']) {
|
|
357
|
+
const src = read('modules', 'hall-ui', 'public', f);
|
|
358
|
+
assert.doesNotMatch(src, /innerHTML\s*=\s*[^;]*friendlyClaimError/, `${f} must not render it as markup`);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('work.js no longer defines its own refusal switch', () => {
|
|
363
|
+
const src = read('modules', 'hall-ui', 'public', 'work.js');
|
|
364
|
+
assert.doesNotMatch(src, /function friendlyActionError/);
|
|
365
|
+
assert.match(src, /window\.OTBClaimAction/, 'it must consume the shared one');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await test('goals-render.js no longer defines its own', () => {
|
|
369
|
+
const src = read('modules', 'hall-ui', 'public', 'goals-render.js');
|
|
370
|
+
assert.doesNotMatch(src, /function friendlyClaimError/);
|
|
371
|
+
assert.doesNotMatch(src, /function errCode/, 'its envelope readers were a third copy');
|
|
372
|
+
assert.match(src, /window\.OTBClaimAction/);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await test('both boards load claim-action.js BEFORE the script that destructures it', () => {
|
|
376
|
+
for (const [page, consumer] of [['work.html', 'work.js'], ['goals.html', 'goals-render.js']]) {
|
|
377
|
+
const html = read('modules', 'hall-ui', 'public', page);
|
|
378
|
+
const shared = html.indexOf('claim-action.js');
|
|
379
|
+
const after = html.indexOf(consumer + '?v=');
|
|
380
|
+
assert.ok(shared > -1, `${page} must load claim-action.js`);
|
|
381
|
+
assert.ok(after > shared, `${page}: ${consumer} must load AFTER claim-action.js`);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await test('the cache-bust moved on every page whose script changed', () => {
|
|
386
|
+
// A fix that reaches nobody looks identical to a fix that does not work.
|
|
387
|
+
for (const [page, scripts] of [
|
|
388
|
+
['work.html', ['work.js']],
|
|
389
|
+
['goals.html', ['goals-render.js', 'goals-page.js']],
|
|
390
|
+
]) {
|
|
391
|
+
const html = read('modules', 'hall-ui', 'public', page);
|
|
392
|
+
for (const s of scripts) {
|
|
393
|
+
assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-26-claimaction1'), `${page}: ${s} not busted`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await test('task.js keeps its own LIFECYCLE mapper — a disjoint code set', () => {
|
|
399
|
+
// Deliberately NOT merged: it maps POST /tasks/:id/ship + /confirm refusals
|
|
400
|
+
// (task_not_confirmed, verify_attestation_required), which share no code with
|
|
401
|
+
// the claim gate. One switch over both would be wrong about half its cases.
|
|
402
|
+
const src = read('modules', 'hall-ui', 'public', 'task.js');
|
|
403
|
+
assert.match(src, /function friendlyActionError/);
|
|
404
|
+
assert.match(src, /task_not_confirmed/);
|
|
405
|
+
assert.doesNotMatch(src, /case 'ALREADY_CLAIMED'/);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await test('the module is dependency-free and node-require-safe', () => {
|
|
409
|
+
const src = read('modules', 'hall-ui', 'public', 'claim-action.js');
|
|
410
|
+
// It may PUBLISH window.OTBClaimAction; it must not READ any other toolkit
|
|
411
|
+
// global, or load order stops being "before the page script".
|
|
412
|
+
const others = [...src.matchAll(/window\.OTB([A-Za-z]+)/g)].map((m) => m[1]).filter((n) => n !== 'ClaimAction');
|
|
413
|
+
assert.deepEqual(others, [], `reaches for other globals: ${others.join(', ')}`);
|
|
414
|
+
assert.ok(typeof errCode === 'function' && typeof errDetails === 'function');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
summary();
|
|
@@ -28,7 +28,7 @@ const require = createRequire(import.meta.url);
|
|
|
28
28
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
29
|
|
|
30
30
|
// The session double must be installed BEFORE auth.js loads: auth.js destructures
|
|
31
|
-
// lookupSession at require time (the tests/
|
|
31
|
+
// lookupSession at require time (the tests/page_gate_second_door.mjs precedent).
|
|
32
32
|
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
33
33
|
let nextSession = null;
|
|
34
34
|
dbKernel.lookupSession = async () => nextSession;
|
package/tests/edit_hints.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const hints = require(HOOK);
|
|
|
21
21
|
|
|
22
22
|
test('each generator input maps to its generator; unrelated paths map to nothing', () => {
|
|
23
23
|
const cases = [
|
|
24
|
-
['modules/hall-ui/public/
|
|
24
|
+
['modules/hall-ui/public/roster.js', 'copy-inventory.js'],
|
|
25
25
|
['modules/status-ui/public/index.html', 'copy-inventory.js'],
|
|
26
26
|
['src/bongos/auth.js', 'gen-repo-map.js'],
|
|
27
27
|
['modules/lifecycle/routes/claims.js', 'gen-api-docs.js'],
|
|
@@ -39,12 +39,12 @@ test('each generator input maps to its generator; unrelated paths map to nothing
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test('rel() reconciles C:\\, C:/ and /c/ shapes between CLAUDE_PROJECT_DIR and file_path', () => {
|
|
42
|
-
const want = 'modules/hall-ui/public/
|
|
43
|
-
assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\
|
|
44
|
-
assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\
|
|
45
|
-
assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/
|
|
46
|
-
assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/
|
|
47
|
-
assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/
|
|
42
|
+
const want = 'modules/hall-ui/public/roster.js';
|
|
43
|
+
assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:\\r\\repo'), want);
|
|
44
|
+
assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:/r/repo'), want);
|
|
45
|
+
assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/roster.js', 'C:\\r\\repo'), want);
|
|
46
|
+
assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/roster.js', 'C:/r/repo/'), want);
|
|
47
|
+
assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/roster.js', '/home/u/repo'), want);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test('run as a hook: prints one hint for a generator input, nothing for an unrelated file, exit 0 both times', () => {
|
|
@@ -53,7 +53,7 @@ test('run as a hook: prints one hint for a generator input, nothing for an unrel
|
|
|
53
53
|
input: JSON.stringify({ tool_input: { file_path: path.join(projectDir, ...relPath.split('/')) } }),
|
|
54
54
|
encoding: 'utf8', timeout: 15000, env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
|
|
55
55
|
});
|
|
56
|
-
const a = run('modules/hall-ui/public/
|
|
56
|
+
const a = run('modules/hall-ui/public/roster.js');
|
|
57
57
|
assert.equal(a.status, 0, a.stderr);
|
|
58
58
|
assert.match(a.stdout, /copy-registry\.json is now stale/);
|
|
59
59
|
assert.match(a.stdout, /copy-inventory\.js/);
|
|
@@ -1122,7 +1122,7 @@ await ta('private accounts are absent from the leaderboard/directory rollup', as
|
|
|
1122
1122
|
const rows = await pi.listScoutingRollup({ limit: 50, pool });
|
|
1123
1123
|
const shown = rows.map((r) => r.github_login).sort();
|
|
1124
1124
|
assert.deepEqual(shown, ['octo', 'shifted'],
|
|
1125
|
-
'only active, publicly-visible, unhidden accounts reach /
|
|
1125
|
+
'only active, publicly-visible, unhidden accounts reach /scouting — '
|
|
1126
1126
|
+ `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private, legacy never accepted the terms (ADR 0213)`);
|
|
1127
1127
|
});
|
|
1128
1128
|
|
|
@@ -123,6 +123,9 @@ async function renderList() {
|
|
|
123
123
|
windowObj.OTB.$ = $;
|
|
124
124
|
windowObj.OTB.fmtDate = () => '';
|
|
125
125
|
windowObj.OTB.toast = () => {};
|
|
126
|
+
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
127
|
+
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
128
|
+
vm.runInContext(read('modules/hall-ui/public/claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
126
129
|
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
127
130
|
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
128
131
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
@@ -153,6 +153,9 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
153
153
|
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
|
|
154
154
|
sandbox.globalThis = sandbox;
|
|
155
155
|
vm.createContext(sandbox);
|
|
156
|
+
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
157
|
+
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
158
|
+
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
|
|
156
159
|
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
157
160
|
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
158
161
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
@@ -77,6 +77,9 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
77
77
|
vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
|
|
78
78
|
vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
|
|
79
79
|
vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
|
|
80
|
+
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
81
|
+
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
82
|
+
vm.runInContext(read('claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
80
83
|
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
81
84
|
|
|
82
85
|
const state = { current: 7, forest: null, forestError: false };
|
|
@@ -169,7 +169,9 @@ function harness({ pendingRequests = [] } = {}) {
|
|
|
169
169
|
};
|
|
170
170
|
sandbox.globalThis = sandbox;
|
|
171
171
|
vm.createContext(sandbox);
|
|
172
|
-
|
|
172
|
+
// task 1003458: claim-action.js sits between task-lib and goals-render on the
|
|
173
|
+
// real page (goals.html); the stub mirrors that order.
|
|
174
|
+
for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
|
|
173
175
|
vm.runInContext(read(P + f), sandbox, { filename: f });
|
|
174
176
|
}
|
|
175
177
|
|
package/tests/goal_mine_lens.mjs
CHANGED
|
@@ -147,6 +147,9 @@ async function boot({
|
|
|
147
147
|
vm.runInContext(GOAL_FOREST_LIB_SRC, sandbox, { filename: 'goal-forest-lib.js' });
|
|
148
148
|
windowObj.OTBGoalForestLib = sandbox.OTBGoalForestLib;
|
|
149
149
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
150
|
+
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
151
|
+
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
152
|
+
vm.runInContext(hall('claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
150
153
|
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
151
154
|
vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
|
|
152
155
|
for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
|
|
@@ -280,7 +280,7 @@ function parsePageGates(src) {
|
|
|
280
280
|
// Each entry's own object literal is bounded by [^}]* so the scan cannot run past a
|
|
281
281
|
// gate that lacks a `permission` key and silently adopt the NEXT gate's. `permission`
|
|
282
282
|
// is then found ANYWHERE inside that literal rather than assumed to come first —
|
|
283
|
-
//
|
|
283
|
+
// every gate carries a `deniedTo` beside it, and key order in an object
|
|
284
284
|
// literal is not a contract anyone maintains.
|
|
285
285
|
for (const m of block[1].matchAll(/(require[A-Za-z]*Page):\s*Object\.freeze\(\{([^}]*)\}\)/g)) {
|
|
286
286
|
const perm = m[2].match(/permission:\s*'([^']+)'/);
|
|
@@ -223,9 +223,9 @@ test('B: a payload with no id degrades to the place alone', async () => {
|
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
test('B: the id wears the same mono/faint voice as the login', () => {
|
|
226
|
-
const css = read('
|
|
226
|
+
const css = read('roster.css');
|
|
227
227
|
const i = css.indexOf('.ledger__place .ledger__id {');
|
|
228
|
-
assert.ok(i > 0, '
|
|
228
|
+
assert.ok(i > 0, 'roster.css styles the roster id');
|
|
229
229
|
const block = css.slice(i, css.indexOf('}', i));
|
|
230
230
|
for (const token of ['var(--font-mono)', 'var(--ink-faint)']) {
|
|
231
231
|
assert.ok(block.includes(token), `the id spends ${token}, the voice .dir-login already uses`);
|
package/tests/hall_mine_lens.mjs
CHANGED
|
@@ -201,6 +201,9 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
|
|
|
201
201
|
vm.runInContext(WORK_SUMMARY_JS, sandbox, { filename: 'work-summary.js' });
|
|
202
202
|
windowObj.OTBMineLens = sandbox.OTBMineLens;
|
|
203
203
|
windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
|
|
204
|
+
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
205
|
+
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
206
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
|
|
204
207
|
vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
|
|
205
208
|
(documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
|
|
206
209
|
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -266,10 +266,9 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
266
266
|
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
|
|
267
267
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
268
268
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// was stricter than the wall). requireProjectCuratePage is the wall server-side.
|
|
269
|
+
// task 1003444: 'people' LEFT the group — the owner deleted the hall's
|
|
270
|
+
// cross-project directory (ADR 0337 D5); its home is the hub, and /people
|
|
271
|
+
// redirects to /roster. Builders is the hall's directory now.
|
|
273
272
|
// task 1003172 (C02 of goal 1000075): 'collab' joins Community, FIRST. The
|
|
274
273
|
// group is picked by whose data it is, and a help request is another builder
|
|
275
274
|
// reaching you — not your own numbers (that is You), and not reference (Docs).
|
|
@@ -280,7 +279,8 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
280
279
|
// changed: it was a hash view on index.html (href /#leaderboard, view:
|
|
281
280
|
// 'leaderboard') and is now a real page at /roster labelled Builders. That is
|
|
282
281
|
// what made this group read as one real page plus a link into the home page.
|
|
283
|
-
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'
|
|
282
|
+
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
|
|
283
|
+
assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
|
|
284
284
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
285
285
|
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
286
286
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
@@ -343,11 +343,11 @@ test('THE CORRECTION: Community shows a xenos two items, Collab and Builders', (
|
|
|
343
343
|
test('neither Community page hides behind a rank the page itself does not enforce', () => {
|
|
344
344
|
// The invariant this task exists to restore: a nav flag may be narrower than
|
|
345
345
|
// the wall for cosmetic reasons (/profile does it), but it must never assert a
|
|
346
|
-
// RANK the page gate does not. /people
|
|
347
|
-
//
|
|
346
|
+
// RANK the page gate does not. /people was the one item with a legitimate rank
|
|
347
|
+
// flag; it left the hall in task 1003444, so the group now carries none.
|
|
348
348
|
const community = parseNav(boot().sidebarHtml).find((g) => g.label === 'Community');
|
|
349
349
|
const ranked = community.items.filter((i) => RANK_GATES.has(i.gate)).map((i) => i.id);
|
|
350
|
-
assert.deepEqual(ranked, [
|
|
350
|
+
assert.deepEqual(ranked, [], 'no Community item carries a rank flag');
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
test('no group head is gated wider than its own items', () => {
|