@bongos/core 1.19.1045 → 1.19.1047

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.
Files changed (81) hide show
  1. package/.bongos-core.json +90 -115
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
  3. package/docs/adr/README.md +1 -1
  4. package/docs/copy-inventory.md +17 -24
  5. package/docs/copy-registry.json +24 -97
  6. package/docs/file-map.md +2 -3
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/core-release-pipeline.md +19 -0
  9. package/docs/recipes/windows-builders.md +1 -1
  10. package/modules/hall-ui/public/atlas.html +1 -1
  11. package/modules/hall-ui/public/blockers.html +1 -1
  12. package/modules/hall-ui/public/board-room.html +1 -1
  13. package/modules/hall-ui/public/collab.css +1 -1
  14. package/modules/hall-ui/public/collab.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea.html +1 -1
  24. package/modules/hall-ui/public/ideas.html +1 -1
  25. package/modules/hall-ui/public/index.html +1 -1
  26. package/modules/hall-ui/public/modules.html +1 -1
  27. package/modules/hall-ui/public/primer.html +1 -1
  28. package/modules/hall-ui/public/profile.html +1 -1
  29. package/modules/hall-ui/public/project-settings.html +1 -1
  30. package/modules/hall-ui/public/ranks.html +1 -1
  31. package/modules/hall-ui/public/roadmap.html +1 -1
  32. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  33. package/modules/hall-ui/public/roster.html +5 -5
  34. package/modules/hall-ui/public/roster.js +6 -7
  35. package/modules/hall-ui/public/roster.states.json +1 -1
  36. package/modules/hall-ui/public/sessions.html +1 -1
  37. package/modules/hall-ui/public/settings.html +2 -2
  38. package/modules/hall-ui/public/settings.js +2 -2
  39. package/modules/hall-ui/public/shell.js +3 -10
  40. package/modules/hall-ui/public/studio.html +1 -1
  41. package/modules/hall-ui/public/style.css +2 -9
  42. package/modules/hall-ui/public/task.html +1 -1
  43. package/modules/hall-ui/public/watch.html +1 -1
  44. package/modules/hall-ui/public/work.html +1 -1
  45. package/modules/hall-ui/records/builders-roster.md +1 -1
  46. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  47. package/modules/platform-identity/account-visibility.js +3 -3
  48. package/modules/platform-identity/platform-identity.js +2 -2
  49. package/modules/platform-identity/routes/profile.js +1 -1
  50. package/modules/platform-identity/routes/sso.js +4 -3
  51. package/modules/platform-identity/scouting-authz.js +2 -2
  52. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +12 -0
  56. package/scripts/gds/go-live.js +101 -5
  57. package/src/bongos/auth.js +9 -24
  58. package/src/bongos/serve-internal.js +13 -19
  59. package/src/module-api.js +1 -1
  60. package/tests/account_privacy_flags.mjs +1 -1
  61. package/tests/auth_resolve_failure.mjs +6 -6
  62. package/tests/core_upgrade_owner_door.mjs +1 -1
  63. package/tests/edit_hints.mjs +8 -8
  64. package/tests/effective_visibility_predicate.mjs +1 -1
  65. package/tests/go_live.mjs +143 -4
  66. package/tests/government_parity.mjs +1 -1
  67. package/tests/hall_builders_page_pass.mjs +2 -2
  68. package/tests/hall_nav.mjs +8 -8
  69. package/tests/hall_page_gate_map.mjs +14 -11
  70. package/tests/hall_roster_world.mjs +47 -78
  71. package/tests/module_contributions.mjs +0 -1
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  74. package/tests/profile_rollup_consent.mjs +4 -4
  75. package/tests/profile_route.mjs +3 -26
  76. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  77. package/modules/hall-ui/public/people.html +0 -79
  78. package/modules/hall-ui/public/people.js +0 -321
  79. package/modules/hall-ui/public/people.states.json +0 -89
  80. package/tests/people_directory_clip.mjs +0 -269
  81. package/tests/people_name_only.mjs +0 -263
@@ -1,269 +0,0 @@
1
- // tests/people_directory_clip.mjs — the People directory says so when it is
2
- // clipped (task 1003110, goal 1000095).
3
- //
4
- // WHY THIS FILE EXISTS. `people.js` asks `GET /scouting` for 500 rows, which is
5
- // that route's HARD CEILING (parsePagination maxLimit: 500). The rollup returns
6
- // no count of the whole set and the route takes no offset, so a response that
7
- // comes back FULL is the only truncation signal the page will ever get — and
8
- // until this task the page simply printed "500 builders listed" and said nothing
9
- // else. A directory that lies by omission is worse than one that admits it is
10
- // incomplete.
11
- //
12
- // It runs the REAL people.js in a vm against a stub DOM and a fake fetch, so
13
- // what is asserted is the sentence a reader actually sees, not a regex agreeing
14
- // with a regex. A source-text assertion would have passed just as happily with
15
- // the comparison written the wrong way round.
16
- //
17
- // The four behaviours:
18
- // 1. the page asks for exactly the number its copy then names (no drift)
19
- // 2. a SHORT response is not hedged — the exact count is the honest one
20
- // 3. a FULL response hedges every sentence that states a total: the pill, the
21
- // subtitle, and a footnote about the lenses
22
- // 4. one row short of the ceiling is NOT clipped (the boundary, in the
23
- // direction that would otherwise cry wolf on every load)
24
- //
25
- // Run: node tests/people_directory_clip.mjs
26
- import { strict as assert } from 'node:assert';
27
- import fs from 'node:fs';
28
- import path from 'node:path';
29
- import vm from 'node:vm';
30
- import { fileURLToPath } from 'node:url';
31
- import { makeRunner } from './helpers.mjs';
32
-
33
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
35
- const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
36
- const PEOPLE_JS = read('modules/hall-ui/public/people.js');
37
- const PEOPLE_HTML = read('modules/hall-ui/public/people.html');
38
-
39
- // The route's ceiling, spelled here as the CONTRACT rather than read out of the
40
- // page — a test that imports the page's own constant cannot notice it changing.
41
- const CEILING = 500;
42
-
43
- const { test, summary } = makeRunner();
44
- const tick = () => new Promise((r) => setTimeout(r, 0));
45
-
46
- function makeEl(id) {
47
- return {
48
- id,
49
- innerHTML: '', textContent: '', value: '',
50
- hidden: false, disabled: false, className: '',
51
- options: [], style: {}, dataset: {},
52
- classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
53
- appendChild() {}, setAttribute() {}, removeAttribute() {},
54
- addEventListener() {},
55
- querySelector() { return makeEl(); },
56
- querySelectorAll() { return []; },
57
- };
58
- }
59
-
60
- // `count` builders, each distinct enough that a filter lens has something to bite.
61
- // The REAL role vocabulary, for the same reason this file runs the real kit rather than a
62
- // stub: a stub of roleLabel() would agree with a roleLabel() that was wrong. dom-utils.js
63
- // is pure enough to run against two small objects (tests/collab_page.mjs does the same).
64
- function realRoleLabel() {
65
- const src = read('modules/hall-ui/public/dom-utils.js');
66
- const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
67
- const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
68
- new Function('window', 'document', src)(win, doc);
69
- return win.OTB.roleLabel;
70
- }
71
-
72
- function buildersFor(count) {
73
- return Array.from({ length: count }, (_, i) => ({
74
- github_id: 1000 + i,
75
- github_login: `builder${i}`,
76
- display_name: `Builder ${i}`,
77
- avatar_url: null,
78
- project_count: 1,
79
- total_credits: count - i,
80
- total_tasks_shipped: 1,
81
- total_karma: 1,
82
- last_active: '2026-09-01T00:00:00Z',
83
- disciplines: ['engineer'],
84
- }));
85
- }
86
-
87
- function harness({ count }) {
88
- const els = new Map();
89
- const getEl = (id) => {
90
- if (!els.has(id)) els.set(id, makeEl(id));
91
- return els.get(id);
92
- };
93
- const documentObj = {
94
- documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
95
- getElementById: getEl,
96
- querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
97
- querySelectorAll() { return []; },
98
- createElement() { return makeEl(); },
99
- addEventListener() {},
100
- };
101
-
102
- const requests = [];
103
- const store = new Map();
104
- const windowObj = {
105
- location: { assign() {}, href: '/people' },
106
- localStorage: {
107
- getItem: (k) => (store.has(k) ? store.get(k) : null),
108
- setItem: (k, v) => store.set(k, String(v)),
109
- removeItem: (k) => store.delete(k),
110
- },
111
- scrollTo() {},
112
- addEventListener() {},
113
- matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
114
- OTBBranding: { currencyLabel: () => 'Credits' },
115
- OTB: {
116
- // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
117
- // the same reason categoryChip is: an undefined helper throws mid-render.
118
- roleAxisLabel: () => 'Role',
119
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
120
- roleIsUnset: (d) => !d || d === 'unclassified',
121
- ROLE_UNSET: 'unclassified',
122
- escapeHtml: (s) => String(s == null ? '' : s),
123
- $: (sel) => documentObj.querySelector(sel),
124
- num: (n) => Number(n) || 0,
125
- fmtInt: (n) => String(Number(n) || 0),
126
- fmtCompact: (n) => String(Number(n) || 0),
127
- fmtDate: (iso) => String(iso ?? '').slice(0, 10),
128
- rankLabel: (r) => String(r ?? ''),
129
- // The REAL one, replacing a hand-written reimplementation that used to sit here.
130
- // Two reasons it had to go, and the second is the point of this file: it was a
131
- // duplicate object key once the real helper arrived (no-dupe-keys, the later
132
- // definition silently winning), and a stub that restates roleLabel's own logic
133
- // can drift from it and the test would never notice — which is the argument this
134
- // file already makes for running the real kit.
135
- roleLabel: realRoleLabel(),
136
- toast() {},
137
- },
138
- };
139
-
140
- const sandbox = {
141
- window: windowObj, document: documentObj, location: windowObj.location,
142
- console: { error() {}, warn() {}, log() {} },
143
- setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON,
144
- fetch: (url) => {
145
- requests.push(String(url));
146
- return Promise.resolve({
147
- ok: true,
148
- status: 200,
149
- json: () => Promise.resolve({ builders: buildersFor(count) }),
150
- });
151
- },
152
- };
153
- sandbox.globalThis = sandbox;
154
- vm.createContext(sandbox);
155
- // The real kit, for the same reason sessions_kit_pager.mjs runs it: the page is
156
- // held to the shipped contract, not to a stub that agrees with it.
157
- vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
158
- vm.runInContext(PEOPLE_JS, sandbox, { filename: 'people.js' });
159
-
160
- return { getEl, requests };
161
- }
162
-
163
- // ---------------------------------------------------------------------------
164
-
165
- await test('the page markup carries the footnote slot, hidden until the page writes it', () => {
166
- assert.match(PEOPLE_HTML, /id="people-foot"/, 'the clipped-directory footnote has a mount');
167
- const tag = PEOPLE_HTML.match(/<p[^>]*id="people-foot"[^>]*>/);
168
- assert.ok(tag, 'the footnote is a <p>');
169
- assert.match(tag[0], /\bhidden\b/, 'it starts hidden — an empty note is still a visible gap');
170
- assert.match(tag[0], /class="ledger__note"/, "the shell's ledger footnote class, not a bespoke one");
171
- });
172
-
173
- await test('the page asks for exactly the ceiling, and asks for it ONCE', async () => {
174
- const h = harness({ count: 3 });
175
- await tick(); await tick();
176
- assert.equal(h.requests.length, 1, 'one directory read');
177
- assert.match(h.requests[0], /\/api\/bongos\/scouting\?limit=/);
178
- const limit = Number(new URLSearchParams(h.requests[0].split('?')[1]).get('limit'));
179
- assert.equal(limit, CEILING, `the page must ask for the route ceiling (${CEILING})`);
180
- });
181
-
182
- // --- task 1004040 (artist review of 1003936): the Directory speaks the role --------
183
-
184
- await test('the role vocabulary a Directory chip renders through says the word, not the column', () => {
185
- // The 1003936 grade named this as a nit — craftsHtml() rendering the raw discipline
186
- // instead of routing it through roleLabel() — and a nit is advisory, so it survived two
187
- // more rounds. What a reader saw: lowercase 'engineer', 'ui' where the house writes
188
- // 'UI', and, for a builder who has set no role, the database's own 'unclassified' —
189
- // the example V2 names, in the exact place it names it ("a filter option and a row tag").
190
- //
191
- // The BEHAVIOUR is executed: this is the shipped roleLabel, not a stub of it.
192
- const roleLabel = realRoleLabel();
193
- assert.equal(roleLabel('engineer'), 'Engineer');
194
- assert.equal(roleLabel('ui'), 'UI', 'the acronym keeps its case');
195
- assert.equal(roleLabel('unclassified'), 'No role yet', 'a sentence a person would say');
196
-
197
- // The CALL SITE is read, because it cannot be executed here: the harness's document
198
- // fake returns a fresh detached node from querySelector(), so the kit fills a <tbody>
199
- // that never reaches the markup this file can see. Observing a rendered row would mean
200
- // deepening that fake, which is a change to a shared harness and not an artist review's
201
- // business. Named in the session log rather than left as a silent gap.
202
- const src = read('modules/hall-ui/public/people.js');
203
- assert.match(src, /esc\(roleLabel\(d\)\)/, 'the chip routes through the role vocabulary');
204
- assert.doesNotMatch(src, /'<span class="fact-chip">' \+ esc\(String\(d\)\)/,
205
- 'the raw column must not be rendered as a chip');
206
- });
207
-
208
- await test('a SHORT directory is not hedged — the exact count is the honest one', async () => {
209
- const h = harness({ count: 3 });
210
- await tick(); await tick();
211
- assert.equal(h.getEl('people-count').textContent, '3', 'the pill states the real count');
212
- assert.equal(h.getEl('people-count').hidden, false);
213
- assert.match(h.getEl('people-sub').textContent, /^3 builders listed/, 'the subtitle states the real count');
214
- assert.doesNotMatch(h.getEl('people-sub').textContent, /first|more than/i,
215
- 'nothing is clipped, so nothing may suggest it is');
216
- assert.equal(h.getEl('people-foot').hidden, true, 'no footnote on a complete directory');
217
- assert.equal(h.getEl('people-foot').textContent, '', 'and no stale text left behind it');
218
- });
219
-
220
- await test('a FULL response hedges the pill, the subtitle AND the lenses', async () => {
221
- const h = harness({ count: CEILING });
222
- await tick(); await tick();
223
-
224
- const pill = h.getEl('people-count').textContent;
225
- assert.equal(pill, `${CEILING}+`, 'the pill may not state a total the page cannot know');
226
-
227
- const sub = h.getEl('people-sub').textContent;
228
- assert.match(sub, new RegExp(`first ${CEILING}\\b`), 'the subtitle says which slice this is');
229
- assert.match(sub, /more than this page can load/, 'and says the directory is larger');
230
- assert.doesNotMatch(sub, new RegExp(`^${CEILING} builders listed`),
231
- 'the old sentence claimed the slice was the whole set');
232
-
233
- const foot = h.getEl('people-foot');
234
- assert.equal(foot.hidden, false, 'the footnote is shown');
235
- // THE LENSES ARE THE SHARPER LIE. Search/sort/craft re-order only the loaded
236
- // set, so on a clipped directory a builder outside the top 500 cannot be found
237
- // by any of them — a reader told only "the list is long" would still trust the
238
- // search box.
239
- assert.match(foot.textContent, /search/i, 'the footnote names the search');
240
- assert.match(foot.textContent, /sort/i, 'and the sort lenses');
241
- assert.match(foot.textContent, new RegExp(`will not appear`),
242
- 'and says plainly that a builder outside the slice is unreachable through them');
243
- });
244
-
245
- await test('one row short of the ceiling is NOT clipped', async () => {
246
- const h = harness({ count: CEILING - 1 });
247
- await tick(); await tick();
248
- assert.equal(h.getEl('people-count').textContent, String(CEILING - 1),
249
- 'a nearly-full page is still a complete one');
250
- assert.equal(h.getEl('people-foot').hidden, true, 'crying wolf here would train the notice away');
251
- });
252
-
253
- await test('every number in the clipped copy is the number that was requested', async () => {
254
- const h = harness({ count: CEILING });
255
- await tick(); await tick();
256
- const limit = new URLSearchParams(h.requests[0].split('?')[1]).get('limit');
257
- // The drift this guards: someone raises the request to 1000 and leaves "500"
258
- // written in three sentences. Every integer the clipped copy prints must be
259
- // the one the request carried.
260
- const copy = [h.getEl('people-count').textContent, h.getEl('people-sub').textContent,
261
- h.getEl('people-foot').textContent].join(' ');
262
- const printed = copy.match(/\d+/g) || [];
263
- assert.ok(printed.length >= 3, 'the clipped copy states the number more than once');
264
- for (const n of printed) {
265
- assert.equal(n, limit, `copy prints ${n} but the page requested ${limit}`);
266
- }
267
- });
268
-
269
- summary();
@@ -1,263 +0,0 @@
1
- // tests/people_name_only.mjs — the People directory lists a hidden builder by
2
- // name alone (task 1002291 / R15, ADR 0334; privacy spec D2's recruiter column:
3
- // "listed name-only (if not opted out)").
4
- //
5
- // GET /scouting now answers two tiers: `builders`, ranked on the cross-project
6
- // record, and `name_only`, the builders who hid that record but are still open
7
- // to recruiting. The server decides who is in each (tests/scouting_owner_access_-
8
- // and_reach.mjs); this file holds the PAGE to the rules that keep the second
9
- // tier honest on screen:
10
- // - it is its own panel, revealed only when the server sends a row
11
- // - a row is the identity cell and nothing else — no figure, no role, no date
12
- // - it is never ranked: alphabetical whatever the sort lens says
13
- // - search reads a name; a role filter never matches (the role is part of the
14
- // record the builder hid)
15
- // - the page still makes ONE read
16
- //
17
- // It runs the REAL people.js and the REAL kit in a vm, over a DOM fake one step
18
- // deeper than tests/people_directory_clip.mjs's: an element keeps the node it
19
- // hands out for a selector, so the <tbody> the kit fills is the one read back.
20
- //
21
- // Run: node tests/people_name_only.mjs
22
- import { strict as assert } from 'node:assert';
23
- import fs from 'node:fs';
24
- import path from 'node:path';
25
- import vm from 'node:vm';
26
- import { fileURLToPath } from 'node:url';
27
- import { makeRunner } from './helpers.mjs';
28
-
29
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
31
- const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
32
- const PEOPLE_JS = read('modules/hall-ui/public/people.js');
33
- const PEOPLE_HTML = read('modules/hall-ui/public/people.html');
34
-
35
- const { test, summary } = makeRunner();
36
- const tick = () => new Promise((r) => setTimeout(r, 0));
37
-
38
- function makeEl(id) {
39
- const kids = new Map();
40
- const listeners = {};
41
- return {
42
- id,
43
- innerHTML: '', textContent: '', value: '',
44
- hidden: false, disabled: false, className: '',
45
- options: [], style: {}, dataset: {},
46
- classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
47
- appendChild() {}, setAttribute() {}, removeAttribute() {},
48
- addEventListener(type, fn) { (listeners[type] ||= []).push(fn); },
49
- fire(type) { (listeners[type] || []).forEach((fn) => fn({ target: { matches: () => true } })); },
50
- querySelector(sel) {
51
- if (!kids.has(sel)) kids.set(sel, makeEl(sel));
52
- return kids.get(sel);
53
- },
54
- querySelectorAll() { return []; },
55
- };
56
- }
57
-
58
- function realRoleLabel() {
59
- const src = read('modules/hall-ui/public/dom-utils.js');
60
- const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
61
- const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
62
- new Function('window', 'document', src)(win, doc);
63
- return win.OTB.roleLabel;
64
- }
65
-
66
- // Ranked rows carry a record; the sentinels are what a name-only row must never show.
67
- const RANKED = [
68
- { github_id: 1, github_login: 'zed-ranked', display_name: 'Zed', avatar_url: null, project_count: 2,
69
- total_credits: 900, total_tasks_shipped: 9, total_karma: 4, last_active: '2026-09-01T00:00:00Z', disciplines: ['engineer'] },
70
- { github_id: 2, github_login: 'amy-ranked', display_name: 'Amy', avatar_url: null, project_count: 1,
71
- total_credits: 100, total_tasks_shipped: 1, total_karma: 1, last_active: '2026-09-02T00:00:00Z', disciplines: ['artist'] },
72
- ];
73
- // Alphabetical by login, as the server sends them.
74
- const NAMED = [
75
- { github_login: 'bea-hidden', display_name: 'Bea', avatar_url: null },
76
- { github_login: 'cal-hidden', display_name: null, avatar_url: 'https://avatars.githubusercontent.com/u/7?v=4' },
77
- ];
78
-
79
- function harness(payload) {
80
- const els = new Map();
81
- const getEl = (id) => {
82
- if (!els.has(id)) els.set(id, makeEl(id));
83
- return els.get(id);
84
- };
85
- const documentObj = {
86
- documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
87
- getElementById: getEl,
88
- querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
89
- querySelectorAll() { return []; },
90
- createElement() { return makeEl(); },
91
- addEventListener() {},
92
- };
93
- const requests = [];
94
- const store = new Map();
95
- const windowObj = {
96
- location: { assign() {}, href: '/people', search: '' },
97
- localStorage: {
98
- getItem: (k) => (store.has(k) ? store.get(k) : null),
99
- setItem: (k, v) => store.set(k, String(v)),
100
- removeItem: (k) => store.delete(k),
101
- },
102
- addEventListener() {},
103
- OTBBranding: { currencyLabel: () => 'Credits' },
104
- OTB: {
105
- roleAxisLabel: () => 'Role',
106
- roleLabel: realRoleLabel(),
107
- fmtInt: (n) => String(Number(n) || 0),
108
- rankLabel: (r) => String(r ?? ''),
109
- },
110
- };
111
- const sandbox = {
112
- window: windowObj, document: documentObj, location: windowObj.location,
113
- console: { error() {}, warn() {}, log() {} },
114
- CSS: { escape: (s) => String(s) },
115
- setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON,
116
- fetch: (url) => {
117
- requests.push(String(url));
118
- return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) });
119
- },
120
- };
121
- sandbox.globalThis = sandbox;
122
- vm.createContext(sandbox);
123
- vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
124
- vm.runInContext(PEOPLE_JS, sandbox, { filename: 'people.js' });
125
-
126
- const filters = () => getEl('people-filters');
127
- return {
128
- getEl,
129
- requests,
130
- rankedRows: () => getEl('people-body').querySelector('tbody').innerHTML,
131
- namedRows: () => getEl('people-named-body').querySelector('tbody').innerHTML,
132
- // Drive the kit's filter bar the way a reader does: set the control, fire.
133
- setFilter(id, value) {
134
- filters().querySelector(`#${id}`).value = value;
135
- filters().fire(id === 'people-q' ? 'input' : 'change');
136
- },
137
- };
138
- }
139
-
140
- const loginsIn = (html) => [...html.matchAll(/class="dir-login">@([^<]+)</g)].map((m) => m[1]);
141
-
142
- // ---------------------------------------------------------------------------
143
-
144
- await test('the page carries the name-only panel, hidden until the server sends a row', () => {
145
- const tag = PEOPLE_HTML.match(/<section[^>]*id="people-named-scroll"[^>]*>/);
146
- assert.ok(tag, 'a panel of its own — a hidden builder is not a row in the ranked ledger');
147
- assert.match(tag[0], /class="scroll"/, "the shell's panel, not a fourth card level");
148
- assert.match(tag[0], /\bhidden\b/, 'it ships hidden: an instance with no such builder shows nothing');
149
- assert.match(PEOPLE_HTML, /id="people-named-h"[^>]*>[^<]+<span class="count-pill" id="people-named-count" hidden>/,
150
- 'a heading with its own count');
151
- const lede = (PEOPLE_HTML.match(/<p class="scroll__lede" id="people-named-lede">([^<]+)<\/p>/) || [])[1] || '';
152
- assert.match(lede, /hidden/i, 'the lede says why these builders show no record, in the same word Settings uses (hide_stats)');
153
- assert.doesNotMatch(lede, /private/i, '"private" is account_visibility\'s word, not hide_stats\'');
154
- assert.match(lede, /ranked/i, 'and that they are never ranked');
155
- assert.match(lede, /filter/i, 'and that a role filter never finds them');
156
- assert.match(PEOPLE_HTML, /id="people-named-body"/, 'and a mount for the ledger');
157
- });
158
-
159
- await test('name-only rows render the identity cell and nothing else', async () => {
160
- const h = harness({ builders: RANKED, name_only: NAMED });
161
- await tick(); await tick();
162
- assert.equal(h.getEl('people-named-scroll').hidden, false, 'revealed once a row arrives');
163
- assert.equal(h.getEl('people-named-count').textContent, '2');
164
- assert.equal(h.getEl('people-named-count').hidden, false);
165
- const rows = h.namedRows();
166
- assert.deepEqual(loginsIn(rows), ['bea-hidden', 'cal-hidden'], 'each builder, by login');
167
- assert.match(rows, /<a class="lb-name dir-name" href="https:\/\/github\.com\/bea-hidden"/,
168
- 'the same door the ranked row has — the one page every listed builder has');
169
- assert.match(rows, /<img class="dir-avatar" src="https:\/\/avatars\.githubusercontent\.com\/u\/7\?v=4"/, 'and the picture');
170
- assert.match(rows, />cal-hidden</, 'a builder with no display name reads as their login');
171
- assert.doesNotMatch(rows, /ledger__num|ledger__date|fact-chip/,
172
- 'no figure, no date and no role cell: every one of those is the record the builder hid');
173
- });
174
-
175
- await test('the ranked ledger is untouched by the second tier', async () => {
176
- const h = harness({ builders: RANKED, name_only: NAMED });
177
- await tick(); await tick();
178
- assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'],
179
- 'ranked in the server order, as before — and no hidden builder among them');
180
- assert.equal(h.getEl('people-count').textContent, '2', 'its count is the ranked count');
181
- assert.match(h.getEl('people-sub').textContent, /2 more .*by name only/,
182
- 'the subtitle counts both tiers, so it does not claim the ranked list is everyone');
183
- });
184
-
185
- await test('a sort lens never re-orders the name-only list — it is not ranked', async () => {
186
- const h = harness({ builders: RANKED, name_only: [...NAMED].reverse() });
187
- await tick(); await tick();
188
- const before = loginsIn(h.namedRows());
189
- for (const lens of ['ships', 'credits', 'karma', 'projects', 'recent']) {
190
- h.setFilter('people-sort', lens);
191
- assert.deepEqual(loginsIn(h.namedRows()), before, `the ${lens} lens has nothing to sort a hidden record by`);
192
- }
193
- assert.deepEqual(loginsIn(h.rankedRows()), ['amy-ranked', 'zed-ranked'],
194
- 'CONTROL: the ranked list DID re-order — the last lens, most recently active, puts Amy first');
195
- });
196
-
197
- await test('search reads a name; a role filter never matches a hidden builder', async () => {
198
- const h = harness({ builders: RANKED, name_only: NAMED });
199
- await tick(); await tick();
200
- h.setFilter('people-q', 'bea');
201
- assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden'], 'a hidden builder is findable by name');
202
- assert.deepEqual(loginsIn(h.rankedRows()), [], 'and the ranked list filters the same search');
203
- h.setFilter('people-q', '');
204
- h.setFilter('people-craft', 'engineer');
205
- assert.deepEqual(loginsIn(h.namedRows()), [], 'the role is part of the hidden record, so a role filter finds no one here');
206
- assert.match(h.namedRows(), /ledger__empty/, 'and says so in the panel rather than leaving it blank');
207
- assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked'], 'CONTROL: the role filter does apply to the ranked list');
208
- });
209
-
210
- await test('no name-only rows — or an older server that sends no such key — leaves the panel hidden', async () => {
211
- for (const payload of [{ builders: RANKED, name_only: [] }, { builders: RANKED }]) {
212
- const h = harness(payload);
213
- await tick(); await tick();
214
- assert.equal(h.getEl('people-named-scroll').hidden, true, 'never revealed');
215
- assert.deepEqual(loginsIn(h.rankedRows()), ['zed-ranked', 'amy-ranked'], 'and the directory renders as before');
216
- }
217
- });
218
-
219
- await test('only hidden builders listed: the page renders them rather than the empty-directory notice', async () => {
220
- const h = harness({ builders: [], name_only: NAMED });
221
- await tick(); await tick();
222
- assert.doesNotMatch(h.getEl('people-body').textContent, /Nobody is publicly listed/,
223
- 'the directory is not empty — two builders are listed by name');
224
- assert.deepEqual(loginsIn(h.namedRows()), ['bea-hidden', 'cal-hidden']);
225
- assert.match(h.rankedRows(), /ledger__empty/, 'the ranked ledger states its own empty case');
226
- const sub = h.getEl('people-sub').textContent;
227
- assert.match(sub, /^2 builders listed across hosted projects by name only\./,
228
- 'the subtitle counts who IS listed');
229
- assert.doesNotMatch(sub, /\b0 builders|more by name only/, 'not "0 builders … 2 more" — a count of nothing');
230
- });
231
-
232
- await test('a FULL name-only response is hedged, and says a search cannot reach past it', async () => {
233
- const CEILING = 500;
234
- const full = Array.from({ length: CEILING }, (_, i) => ({ github_login: `h${String(i).padStart(3, '0')}`, display_name: null, avatar_url: null }));
235
- let h = harness({ builders: RANKED, name_only: full });
236
- await tick(); await tick();
237
- assert.equal(h.getEl('people-named-count').textContent, `${CEILING}+`, 'the pill may not state a total the page cannot know');
238
- const foot = h.getEl('people-named-foot');
239
- assert.equal(foot.hidden, false, 'the footnote shows on a clipped list');
240
- assert.match(foot.textContent, /clipped at the first 500 by login/);
241
- assert.match(foot.textContent, /Search only reads what this page loaded/, 'naming the one lens that reads this list');
242
- assert.match(PEOPLE_HTML, /<p class="ledger__note" id="people-named-foot" hidden><\/p>/, 'the slot ships hidden');
243
- h = harness({ builders: RANKED, name_only: full.slice(0, CEILING - 1) });
244
- await tick(); await tick();
245
- assert.equal(h.getEl('people-named-foot').hidden, true, 'one short of the ceiling is a complete list');
246
- assert.equal(h.getEl('people-named-count').textContent, String(CEILING - 1));
247
- });
248
-
249
- await test('both tiers empty: the consent-boundary notice still explains the empty page', async () => {
250
- const h = harness({ builders: [], name_only: [] });
251
- await tick(); await tick();
252
- assert.match(h.getEl('people-body').textContent, /Nobody is publicly listed/);
253
- assert.equal(h.getEl('people-named-scroll').hidden, true, 'and the second panel stays hidden');
254
- });
255
-
256
- await test('the page still makes ONE read for both tiers', async () => {
257
- const h = harness({ builders: RANKED, name_only: NAMED });
258
- await tick(); await tick();
259
- assert.equal(h.requests.length, 1);
260
- assert.match(h.requests[0], /^\/api\/bongos\/scouting\?limit=/);
261
- });
262
-
263
- summary();