@bongos/core 1.23.9 → 1.23.11

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 (48) hide show
  1. package/.bongos-core.json +106 -41
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +1 -0
  3. package/docs/copy-inventory.md +900 -725
  4. package/docs/copy-registry.json +3055 -1246
  5. package/docs/file-map.md +1 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-inventory.json +49 -6
  8. package/docs/page-readings.json +1247 -1157
  9. package/modules/public-landing/public/account.html +12 -0
  10. package/modules/public-landing/public/assets/cosmos.css +9 -1
  11. package/modules/public-landing/public/assets/hub-bar.js +5 -3
  12. package/modules/public-landing/public/community-empty.states.json +15 -0
  13. package/modules/public-landing/public/community-recruiter.states.json +15 -0
  14. package/modules/public-landing/public/community.html +470 -0
  15. package/modules/public-landing/public/community.probes.json +92 -0
  16. package/modules/public-landing/public/community.states.json +25 -0
  17. package/modules/public-landing/public/connections-nosuggest.states.json +15 -0
  18. package/modules/public-landing/public/connections.html +33 -2
  19. package/modules/public-landing/public/connections.states.json +1 -0
  20. package/modules/public-landing/public/contact.html +12 -0
  21. package/modules/public-landing/public/guild.html +12 -0
  22. package/modules/public-landing/public/index.html +23 -2
  23. package/modules/public-landing/public/index.probes.json +1 -1
  24. package/modules/public-landing/public/invitations.html +12 -0
  25. package/modules/public-landing/public/privacy.html +12 -0
  26. package/modules/public-landing/public/projects.html +15 -1
  27. package/modules/public-landing/public/terms.html +12 -0
  28. package/modules/ui-design/kit/fixtures/community-leaderboard-empty.json +4 -0
  29. package/modules/ui-design/kit/fixtures/community-leaderboard.json +28 -0
  30. package/modules/ui-design/kit/fixtures/community-search.json +10 -0
  31. package/modules/ui-design/kit/fixtures/connections-nosuggest.json +12 -0
  32. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +11 -0
  33. package/modules/ui-design/kit/fixtures/scouting.json +4 -0
  34. package/modules/ui-design/kit/serve.js +55 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +12 -0
  38. package/src/bongos/serve-internal.js +4 -2
  39. package/src/module-api.js +1 -1
  40. package/src/platform-server.js +11 -0
  41. package/tests/hub_account_page.mjs +9 -1
  42. package/tests/hub_community_page.mjs +493 -0
  43. package/tests/hub_connections_page.mjs +53 -4
  44. package/tests/hub_guild_page.mjs +1 -0
  45. package/tests/landing_legal_pages.mjs +10 -1
  46. package/tests/landing_page.mjs +1 -1
  47. package/tests/render_check.mjs +2 -2
  48. package/tests/ui_design_kit.mjs +2 -2
@@ -0,0 +1,493 @@
1
+ // tests/hub_community_page.mjs — the hub's community pages (task 1003363; ADR 0217 D1,
2
+ // ADR 0337 D1/D3/D4.1, docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8).
3
+ //
4
+ // One file served as three sub-pages of the bar's Community menu (owner, 2026-10-27):
5
+ // /community the leaderboard, /community/find the builder search, /community/recruiters
6
+ // the name-only list of hidden builders open to recruiters. They render three existing
7
+ // platform-identity reads and add no rule of their own. What this file pins:
8
+ // • the markup: the hub's bar with the Community menu as the current section, no top
9
+ // block, noindex, dark only, the routes' own windows and query limits
10
+ // • viewing is ungated: the leaderboard and the search read the same routes for every
11
+ // viewer, and nothing on them asks who is signed in
12
+ // • the page RUN in a vm, once per sub-page: the path picks the section and the menu
13
+ // item marked current; the board in the server's order with its `place`, the window
14
+ // dropdown and the crafts, the empty, failed and rate-limited board; the search and
15
+ // its refusals; the recruiter list as a door signed out, a refusal on a 403, and rows
16
+ // only from a 200 — name, face and login, never the ranked half's numbers
17
+ // • the Community menu on every page that wears the hub's bar (apex-only where the page
18
+ // also serves a child), folded into the Projects band on phones
19
+ // • the routes: the three paths served on the hub's apex only
20
+ // • the kit: the states files, and the stub answering as the three reads do
21
+ //
22
+ // DB-free. Run: node --test tests/hub_community_page.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test, before, after } from 'node:test';
26
+ import http from 'node:http';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import vm from 'node:vm';
30
+ import { spawn } from 'node:child_process';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
37
+ const PI = path.join(ROOT, 'modules', 'platform-identity');
38
+ const read = (f) => fs.readFileSync(path.join(LANDING, f), 'utf8').replace(/\r\n/g, '\n');
39
+ const PAGE = read('community.html');
40
+ const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
41
+ // the code alone: the comments name what they rule out
42
+ const CODE = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
43
+ const STYLE = PAGE.split('<style>')[1].split('</style>')[0].replace(/\/\*[\s\S]*?\*\//g, '');
44
+ const MAIN = PAGE.split('<main class="acct" id="comm">')[1].split('</main>')[0];
45
+ const BOARD_JS = fs.readFileSync(path.join(PI, 'community-leaderboard.js'), 'utf8');
46
+ const SEARCH_JS = fs.readFileSync(path.join(PI, 'builder-search.js'), 'utf8');
47
+ const SUBPAGES = [['/community', 'Leaderboard'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
48
+
49
+ // ── 1. the markup ────────────────────────────────────────────────────────────
50
+
51
+ test('the pages wear the hub’s bar with the Community menu as the current section; dark only, no page :root, not indexed', () => {
52
+ assert.match(PAGE, /<html lang="en" data-mode="dark">/);
53
+ assert.match(PAGE, /<meta name="robots" content="noindex">/, 'public, but kept out of search until the SEO goal decides (owner, 2026-10-27)');
54
+ assert.match(PAGE, /<title>\{\{productName\}\} — Community<\/title>/);
55
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/);
56
+ assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>/);
57
+ assert.match(PAGE, /<script src="\/assets\/invite-badge\.js"><\/script>/);
58
+ assert.doesNotMatch(STYLE, /:root/, 'the pack’s injected theme owns the tokens');
59
+ assert.doesNotMatch(STYLE, /#[0-9a-fA-F]{3,8}\b/, 'every colour is a token or a mix of one');
60
+ assert.match(PAGE, /<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity" aria-current="true">Community<svg class="cv"/);
61
+ assert.deepEqual(PAGE.match(/aria-current="page"/g), null, 'which sub-page is current is the script’s, from the path');
62
+ assert.match(PAGE, /<a class="skip vh" href="#comm">Skip to content<\/a>/);
63
+ assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, 1, 'hub-only: the footer’s platform sentence is the one apex-only region');
64
+ assert.ok(PAGE.indexOf('<!--apex-only:start-->') > PAGE.indexOf('<footer class="foot">'));
65
+ });
66
+
67
+ test('the bar and bands are /connections’ but for the sign-in return and the current section', () => {
68
+ const bar = (html) => html.slice(html.indexOf('<header class="shell bar">'), html.indexOf('<main '))
69
+ .replace(/return=\/\w+&amp;/g, 'return=X&amp;').replace(/ aria-current="(page|true)"/g, '');
70
+ assert.equal(bar(PAGE), bar(read('connections.html')));
71
+ });
72
+
73
+ test('no top block: one visually hidden h1, then one section per sub-page, only the leaderboard shown in the markup', () => {
74
+ assert.match(MAIN, /^\s*<h1 class="vh">Community<\/h1>\s*(<!--[\s\S]*?-->\s*)?<div class="mineExtra">/, 'nothing is drawn above the first section (the /design floor)');
75
+ const order = ['<section id="board" data-view="leaderboard" aria-labelledby="boardH">', '<section id="find" data-view="find" aria-labelledby="findH" hidden>', '<section id="recruit" data-view="recruiters" aria-labelledby="recruitH" hidden>'].map((s) => MAIN.indexOf(s));
76
+ assert.ok(order.every((i) => i > 0), 'every sub-page is real markup the script only shows');
77
+ assert.deepEqual([...order].sort((a, b) => a - b), order);
78
+ assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
79
+ assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
80
+ assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
81
+ assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
82
+ assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&amp;repos=1">/);
83
+ for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
84
+ });
85
+
86
+ test('the window dropdown and the query limits are the routes’ own (boundary-forced copies)', () => {
87
+ const days = BOARD_JS.match(/const WINDOW_DAYS = Object\.freeze\(\{([^}]*)\}\);/);
88
+ const windows = [...days[1].matchAll(/'?(\w+)'?:/g)].map((m) => m[1]);
89
+ assert.deepEqual(windows, ['7d', '30d', '365d', 'all']);
90
+ assert.match(SCRIPT, new RegExp(`var WINDOWS = \\[${windows.map((w) => `'${w}'`).join(', ')}\\];`));
91
+ const select = MAIN.split('<select id="boardWindow">')[1].split('</select>')[0];
92
+ assert.deepEqual([...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]), windows, 'one option per window, in the route’s order');
93
+ assert.match(BOARD_JS, /const DEFAULT_WINDOW = 'all';/);
94
+ assert.match(select, /<option value="all" selected>All time<\/option>/, 'the page opens on the route’s default window');
95
+ assert.match(MAIN, /<label class="vh" for="boardWindow">/, 'the dropdown is labelled');
96
+ const min = SEARCH_JS.match(/const MIN_QUERY_CHARS = (\d+);/)[1];
97
+ const max = SEARCH_JS.match(/const MAX_QUERY_CHARS = (\d+);/)[1];
98
+ assert.match(SCRIPT, new RegExp(`var QUERY_MIN = ${min};`));
99
+ assert.match(SCRIPT, new RegExp(`var QUERY_MAX = ${max};`));
100
+ assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
101
+ });
102
+
103
+ test('the script builds no markup from strings, reads only the three routes, and never re-ranks or reads the ranked tier', () => {
104
+ assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
105
+ // every path literal in the code: the API base, the three reads, the three sub-pages and the sign-in door
106
+ const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
107
+ assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/scouting']);
108
+ assert.doesNotMatch(CODE, /method:/, 'it only reads');
109
+ assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
110
+ assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
111
+ assert.doesNotMatch(CODE, /\.builders\b|total_credits|total_tasks_shipped|project_count/, 'the directory’s ranked half is never drawn here');
112
+ assert.doesNotMatch(CODE, /\brank\b/, '`place` is a position on the board, never a builder rank');
113
+ });
114
+
115
+ // ── 2. the pages, run ────────────────────────────────────────────────────────
116
+
117
+ function attrsOf(tag) {
118
+ const out = {};
119
+ for (const m of tag.matchAll(/ ([\w-]+)(?:="([^"]*)")?/g)) out[m[1]] = m[2] === undefined ? '' : m[2];
120
+ return out;
121
+ }
122
+ function el(tag, attrs = {}) {
123
+ const n = { tag, children: [], _text: '', className: attrs.class || '', hidden: 'hidden' in attrs, disabled: false, value: attrs.value || '', attrs: { ...attrs }, _on: {}, parent: null };
124
+ Object.defineProperty(n, 'textContent', {
125
+ get() { return n._text + n.children.map((c) => c.textContent).join(''); },
126
+ set(v) { n._text = String(v); n.children = []; },
127
+ });
128
+ n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
129
+ n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
130
+ n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
131
+ n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
132
+ n.querySelectorAll = (sel) => (sel === '.commTab' ? n.children.filter((c) => /\bcommTab\b/.test(c.className)) : []);
133
+ n.closest = (sel) => { const a = (sel.match(/^\[([\w-]+)\]$/) || [])[1]; return a && a in n.attrs ? n : null; };
134
+ return n;
135
+ }
136
+ const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
137
+ const kids = (n) => n.children;
138
+ const find = (n, cls) => {
139
+ for (const c of n.children) {
140
+ if (String(c.className).split(' ').includes(cls)) return c;
141
+ const d = find(c, cls);
142
+ if (d) return d;
143
+ }
144
+ return null;
145
+ };
146
+
147
+ const ROW = (place, handle, credits, extra = {}) => ({ place, handle, github_login: handle, display_name: handle.toUpperCase(), avatar_url: `https://avatars.example/${handle}`, credits, tasks_shipped: 2, karma: 1, disciplines: ['engineer'], ...extra });
148
+ const READS = () => ({
149
+ '/community/leaderboard?window=all': { status: 200, body: { window: 'all', count: 99, results: [ROW(26, 'ana', 1200), ROW(27, 'bo', 1500, { tasks_shipped: 1, karma: 0, avatar_url: 'javascript:alert(1)', display_name: null })] } },
150
+ '/community/leaderboard?window=30d': { status: 200, body: { window: '30d', results: [ROW(1, 'cy', 40)] } },
151
+ '/community/leaderboard?window=all&discipline=artist': { status: 200, body: { window: 'all', results: [] } },
152
+ '/community/search?q=pri': { status: 200, body: { results: [{ handle: 'priya', github_login: 'priyan', display_name: 'Priya', avatar_url: null, disciplines: ['engineer', 'ui'] }, { handle: 'pri-hidden', github_login: 'ph', display_name: 'Pri <b>H</b>', avatar_url: null, disciplines: null }] } },
153
+ '/community/search?q=pri&discipline=artist': { status: 200, body: { results: [] } },
154
+ '/scouting': { status: 403, body: { error: { code: 'forbidden' } } },
155
+ });
156
+
157
+ async function boot({ pathname = '/community', me = null, reads = READS() } = {}) {
158
+ const ids = new Map();
159
+ for (const m of PAGE.matchAll(/<(\w+)([^>]* id="([^"]+)"[^>]*)>/g)) {
160
+ const attrs = attrsOf(m[2]);
161
+ if (attrs['data-unit']) attrs['data-unit'] = 'credits';
162
+ const n = el(m[1], attrs);
163
+ n.id = m[3];
164
+ if (m[1] === 'select') n.value = (PAGE.split(m[0])[1].split('</select>')[0].match(/<option value="([^"]*)" selected>/) || [, ''])[1];
165
+ ids.set(m[3], n);
166
+ }
167
+ const body = PAGE.split('id="craftTabs"')[1].split('</div>')[0];
168
+ for (const b of body.matchAll(/<button([^>]*)>([^<]+)<\/button>/g)) { const n = el('button', attrsOf(b[1])); n.textContent = b[2]; ids.get('craftTabs').appendChild(n); }
169
+ // every menu link, so the script can mark the sub-page's items current
170
+ const anchors = [...PAGE.matchAll(/<a class="bandT" href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => { const n = el('a', { href: m[1] }); n.textContent = m[2]; return n; });
171
+ const requests = [];
172
+ const fetch = (url, init = {}) => {
173
+ requests.push({ url, credentials: init.credentials });
174
+ const a = reads[url.replace(/^\/api\/bongos/, '')];
175
+ if (!a || a.reject) return Promise.reject(new Error('offline'));
176
+ return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) });
177
+ };
178
+ const document = {
179
+ title: 'Cloud Bongos — Community',
180
+ getElementById: (id) => ids.get(id) || null,
181
+ createElement: (t) => el(t),
182
+ querySelectorAll: (sel) => { const h = (sel.match(/^a\[href="([^"]+)"\]$/) || [])[1]; return anchors.filter((a) => a.attrs.href === h); },
183
+ };
184
+ const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) }, location: { pathname } };
185
+ const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, Number, isFinite, encodeURIComponent };
186
+ vm.createContext(sandbox);
187
+ vm.runInContext(SCRIPT, sandbox, { filename: 'community.html' });
188
+ await settle();
189
+ const tab = (value) => kids(ids.get('craftTabs')).find((b) => b.attrs['data-craft'] === value);
190
+ const craft = async (value) => { for (const f of ids.get('craftTabs')._on.click || []) f({ target: tab(value) }); await settle(); };
191
+ const pick = async (value) => { ids.get('boardWindow').value = value; for (const f of ids.get('boardWindow')._on.change || []) f({}); await settle(); };
192
+ const submit = async () => { const e = { preventDefault() { e.prevented = true; } }; for (const f of ids.get('findForm')._on.submit || []) f(e); await settle(); return e; };
193
+ const current = () => anchors.filter((a) => a.attrs['aria-current'] === 'page').map((a) => a.attrs.href);
194
+ return { ids, requests, document, gets: () => requests.map((r) => r.url), tab, craft, pick, submit, current, $: (id) => ids.get(id) };
195
+ }
196
+
197
+ test('each path shows its one section, marks its menu items current and names itself; any other shape is the leaderboard', async () => {
198
+ for (const [pathname, title, section] of [['/community', 'Leaderboard', 'board'], ['/community/', 'Leaderboard', 'board'], ['/community/find', 'Find a builder', 'find'], ['/community/recruiters/', 'Open to recruiters', 'recruit']]) {
199
+ const ui = await boot({ pathname });
200
+ assert.deepEqual(['board', 'find', 'recruit'].filter((s) => !ui.$(s).hidden), [section], `${pathname}: one section`);
201
+ const href = SUBPAGES.find(([, t]) => t === title)[0];
202
+ assert.deepEqual(ui.current(), [href, href], `${pathname}: its Community item and its folded twin in the Projects band`);
203
+ assert.equal(ui.document.title, `Cloud Bongos — Community · ${title}`);
204
+ assert.equal(ui.$('signinTop').attrs.href, `/api/bongos/auth/web/start?return=${href}&repos=1`, 'signing in comes back to this sub-page');
205
+ }
206
+ });
207
+
208
+ test('the leaderboard, signed out: the all-time board in the server’s order with the server’s places, and nothing asked about the viewer', async () => {
209
+ const ui = await boot();
210
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all']);
211
+ assert.ok(ui.requests.every((r) => r.credentials === 'same-origin'));
212
+ assert.equal(ui.$('boardLoading').hidden, true);
213
+ assert.equal(ui.$('boardFailed').hidden, true);
214
+ assert.equal(ui.$('boardEmpty').hidden, true);
215
+ const [ana, bo] = kids(ui.$('boardList'));
216
+ assert.deepEqual([find(ana, 'commPlace').textContent, find(bo, 'commPlace').textContent], ['26', '27'], 'the place is the server’s, never renumbered');
217
+ assert.equal(find(ana, 'mineRow__name').textContent, 'ANA');
218
+ assert.equal(find(ana, 'mineRow__host').textContent, '@ana');
219
+ assert.equal(find(ana, 'commNum').textContent, (1200).toLocaleString());
220
+ assert.equal(find(ana, 'commUnit').textContent, 'credits');
221
+ assert.equal(find(ana, 'mineRow__hint').textContent, '2 works shipped · 1 karma');
222
+ assert.equal(find(bo, 'commNum').textContent, (1500).toLocaleString(), 'a higher number below stays below: the server ranked it');
223
+ assert.equal(find(bo, 'mineRow__name').textContent, 'bo', 'no display name: the handle stands in');
224
+ assert.equal(find(bo, 'connAv').tag, 'span', 'an avatar that is not http(s) is never loaded');
225
+ assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
226
+ assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
227
+ const signedIn = await boot({ me: { github_login: 'someone' } });
228
+ assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed in, the same one read: viewing is ungated');
229
+ });
230
+
231
+ test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
232
+ const ui = await boot();
233
+ await ui.pick('30d');
234
+ assert.equal(ui.gets().pop(), '/api/bongos/community/leaderboard?window=30d');
235
+ assert.equal(ui.$('boardWindow').value, '30d');
236
+ assert.equal(ui.$('boardNote').textContent, 'Credits earned in the last 30 days, across every project.');
237
+ assert.deepEqual(kids(ui.$('boardList')).map((r) => find(r, 'mineRow__host').textContent), ['@cy']);
238
+ const before = ui.requests.length;
239
+ await ui.pick('30d');
240
+ assert.equal(ui.requests.length, before, 'picking the window already shown reads nothing');
241
+ await ui.pick('1d');
242
+ assert.equal(ui.requests.length, before, 'a window the route does not know is never asked for');
243
+ await ui.pick('all');
244
+ await ui.craft('artist');
245
+ assert.equal(ui.gets().pop(), '/api/bongos/community/leaderboard?window=all&discipline=artist');
246
+ assert.equal(ui.tab('artist').attrs['aria-pressed'], 'true');
247
+ assert.equal(ui.tab('').attrs['aria-pressed'], 'false');
248
+ assert.equal(ui.$('boardEmpty').hidden, false);
249
+ assert.equal(ui.$('boardEmpty').textContent, 'No artists on the leaderboard yet.');
250
+ assert.equal(kids(ui.$('boardList')).length, 0);
251
+ });
252
+
253
+ test('a failed board is an alert, never an empty board; a rate limit says to wait', async () => {
254
+ const reads = READS();
255
+ reads['/community/leaderboard?window=all'] = { status: 500, body: {} };
256
+ const failed = await boot({ reads });
257
+ assert.equal(failed.$('boardFailed').hidden, false);
258
+ assert.equal(failed.$('boardFailed').textContent, 'The leaderboard could not be read. Reload the page to try again.');
259
+ assert.equal(failed.$('boardEmpty').hidden, true, 'a failure never reads as nobody on the board');
260
+ reads['/community/leaderboard?window=all'] = { status: 429, body: { error: { code: 'rate_limited' } } };
261
+ assert.match((await boot({ reads })).$('boardFailed').textContent, /^Too many requests from your network just now\./);
262
+ reads['/community/leaderboard?window=all'] = { reject: true };
263
+ assert.equal((await boot({ reads })).$('boardFailed').hidden, false, 'offline is a failure too');
264
+ });
265
+
266
+ test('find: nothing read on arrival; too short is refused before any read; a hidden builder is found by name with no craft line', async () => {
267
+ const ui = await boot({ pathname: '/community/find' });
268
+ assert.deepEqual(ui.requests, [], 'the search page reads nothing until asked');
269
+ ui.$('findQ').value = ' p ';
270
+ const e = await ui.submit();
271
+ assert.ok(e.prevented, 'the form never navigates');
272
+ assert.deepEqual(ui.requests, []);
273
+ assert.equal(ui.$('findStatus').textContent, 'Type at least 2 letters of a name.');
274
+ ui.$('findQ').value = 'pri';
275
+ await ui.submit();
276
+ assert.equal(ui.gets().pop(), '/api/bongos/community/search?q=pri');
277
+ assert.equal(ui.$('findStatus').textContent, '2 builders found.');
278
+ const [priya, hidden] = kids(ui.$('findList'));
279
+ assert.equal(find(priya, 'mineRow__hint').textContent, 'engineer · designer');
280
+ assert.equal(find(hidden, 'mineRow__name').textContent, 'Pri <b>H</b>', 'textContent: a name is never markup');
281
+ assert.equal(find(hidden, 'mineRow__hint'), null, 'disciplines null (hidden numbers): nothing said about their craft');
282
+ assert.equal(ui.$('findBtn').disabled, false);
283
+ ui.$('findCraft').value = 'artist';
284
+ await ui.submit();
285
+ assert.equal(ui.gets().pop(), '/api/bongos/community/search?q=pri&discipline=artist');
286
+ assert.equal(ui.$('findStatus').textContent, 'No builder by that name in that role.');
287
+ assert.equal(kids(ui.$('findList')).length, 0);
288
+ });
289
+
290
+ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as name, face and login', async () => {
291
+ const at = '/community/recruiters';
292
+ const shown = (ui) => ['recruitLoading', 'recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty'].filter((s) => !ui.$(s).hidden);
293
+ const out = await boot({ pathname: at });
294
+ assert.deepEqual(out.requests, [], 'signed out, the door is not even asked');
295
+ assert.deepEqual(shown(out), ['recruitDoor']);
296
+ const refused = await boot({ pathname: at, me: { github_login: 'someone' } });
297
+ assert.deepEqual(refused.gets(), ['/api/bongos/scouting'], 'signed in: the route’s door decides, and the board is not read here');
298
+ assert.deepEqual(shown(refused), ['recruitClosed'], 'a viewer the door does not admit is told it is for project owners');
299
+ assert.equal(kids(refused.$('recruitList')).length, 0);
300
+ const reads = READS();
301
+ reads['/scouting'] = { status: 200, body: {
302
+ builders: [{ github_id: '9', github_login: 'ranked', display_name: 'Ranked One', total_credits: 777777, total_tasks_shipped: 4242 }],
303
+ name_only: [{ github_login: 'kemi-a', display_name: 'Kemi', avatar_url: 'https://avatars.example/k' }, { github_login: 'yuki-t', display_name: null, avatar_url: null }],
304
+ } };
305
+ const admitted = await boot({ pathname: at, me: { github_login: 'owner' }, reads });
306
+ assert.deepEqual(shown(admitted), []);
307
+ const [kemi, yuki] = kids(admitted.$('recruitList'));
308
+ assert.equal(find(kemi, 'mineRow__name').textContent, 'Kemi');
309
+ assert.equal(find(kemi, 'mineRow__host').textContent, 'GitHub: kemi-a');
310
+ assert.equal(find(yuki, 'mineRow__name').textContent, 'yuki-t');
311
+ assert.equal(find(kemi, 'mineRow__hint'), null, 'no numbers, no crafts: the name-only tier is a name');
312
+ assert.doesNotMatch(admitted.$('recruitList').textContent, /Ranked One|777|4242/, 'the ranked half is never drawn');
313
+ reads['/scouting'] = { status: 200, body: { builders: [], name_only: [] } };
314
+ assert.deepEqual(shown(await boot({ pathname: at, me: { github_login: 'owner' }, reads })), ['recruitEmpty']);
315
+ reads['/scouting'] = { status: 401, body: {} };
316
+ assert.deepEqual(shown(await boot({ pathname: at, me: { github_login: 'stale' }, reads })), ['recruitDoor'], 'an expired session is a door');
317
+ reads['/scouting'] = { status: 500, body: {} };
318
+ const failed = await boot({ pathname: at, me: { github_login: 'owner' }, reads });
319
+ assert.deepEqual(shown(failed), ['recruitFailed']);
320
+ assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
321
+ });
322
+
323
+ // ── 3. every bar carries the Community menu ──────────────────────────────────
324
+
325
+ const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
326
+ const HUB_ONLY = ['account.html', 'connections.html', 'invitations.html', 'guild.html', 'community.html'];
327
+ const wrap = (apex, s) => (apex ? `<!--apex-only:start-->${s}<!--apex-only:end-->` : s);
328
+ const ITEMS = [
329
+ ['/community', 'Leaderboard', 'who earned the most, across every project'],
330
+ ['/community/find', 'Find a builder', 'anyone building here, by name or role'],
331
+ ['/community/recruiters', 'Open to recruiters', 'who is open to recruiting, for project owners'],
332
+ ];
333
+ // signed in only, and it opens /connections' own panel rather than a second one here (task 1004256)
334
+ const KNOW = '<a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>';
335
+
336
+ test('every page that wears the hub’s bar carries the Community menu before Projects; apex-only where the page serves a child', () => {
337
+ const { stripApexOnly } = require('../src/platform-server.js');
338
+ const trig = '<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity"';
339
+ const band = ['<div class="menuBand" id="menuCommunity" data-menu="community" hidden>', ' <div class="bandIn">', ' <ul class="bandRow">',
340
+ ...ITEMS.map(([h, t, c]) => ` <li class="bandCell"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`),
341
+ ` <li class="bandCell knowCell" hidden>${KNOW}`, ' </ul>', ' </div>', '</div>'].join('\n');
342
+ const fold = ITEMS.map(([h, t, c]) => `<li class="bandCell bandFold"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`).join('')
343
+ + `<li class="bandCell bandFold knowCell" hidden>${KNOW}`;
344
+ for (const f of [...SERVES_A_CHILD, ...HUB_ONLY]) {
345
+ const html = read(f);
346
+ const apex = SERVES_A_CHILD.includes(f);
347
+ const at = html.indexOf(apex ? `<!--apex-only:start-->${trig}` : trig);
348
+ assert.ok(at > 0, `${f}: the Community trigger`);
349
+ assert.ok(html.indexOf('id="projTrig"') > at, `${f}: Community comes before Projects`);
350
+ assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its three sub-pages`);
351
+ assert.ok(html.indexOf('id="menuCommunity"') < html.indexOf('id="menuProjects"'), `${f}: the bands in the bar’s order`);
352
+ assert.ok(html.includes(wrap(apex, fold)), `${f}: the three folded into the Projects band for phones`);
353
+ if (apex) assert.doesNotMatch(stripApexOnly(html, false).split('<body>')[1], /\/community|commTrig|menuCommunity|connSuggest/, `${f}: a child has no community pages, so no trace of the menu`);
354
+ }
355
+ });
356
+
357
+ test('Builders you may know: shown only once GET /me names someone, and drawn once, on /connections', () => {
358
+ // every bar script that paints the signed-in state shows or hides both cells with it
359
+ assert.match(read('assets/hub-bar.js'), /\$\('mineCell'\)\.hidden = !me;\n\s*Array\.prototype\.forEach\.call\(document\.querySelectorAll\('\.knowCell'\), function \(c\) \{ c\.hidden = !me; \}\);/);
360
+ assert.match(read('projects.html'), /function paintShellIdentity\(\) \{[\s\S]*?document\.querySelectorAll\('\.knowCell'\)\.forEach\(function \(c\) \{ c\.hidden = !signedIn; \}\);/);
361
+ assert.match(read('index.html'), /if \(mine\) mine\.hidden = false;\n\s*document\.querySelectorAll\('\.knowCell'\)\.forEach\(function \(c\) \{ c\.hidden = false; \}\);/);
362
+ // the panel it opens exists, and the community page reads no suggestions of its own
363
+ assert.match(read('connections.html'), /<section id="connSuggest" aria-labelledby="connSuggestH" hidden>/);
364
+ assert.doesNotMatch(read('community.html').split('<script>')[1], /connections\/suggestions/);
365
+ });
366
+
367
+ test('the menu folds into the Projects band under 480px on the hub and 900px on the landing; the arrows skip what CSS does not draw', () => {
368
+ const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
369
+ assert.match(css, /\.trig\[aria-expanded="true"\],\.trig\[aria-current="true"\]\{color:var\(--ink\);text-decoration:underline;/, 'on its own pages the menu reads as the current section');
370
+ assert.match(css, /\n\.bandFold\{display:none;\}\n/, 'above 480 the Projects band does not repeat the menu');
371
+ assert.match(css.split('@media (max-width:480px){')[1].split('\n}')[0], /\.commTrig\{display:none;\}\s*\.bandFold\{display:block;\}/);
372
+ const index = read('index.html');
373
+ assert.match(index, /\n\.bandFold\{display:none\}\n/);
374
+ // a hidden fold cell (signed in only, task 1004256) must stay down where the fold shows:
375
+ // cosmos.css's [hidden] rule is !important; the landing has none, so it carries its own
376
+ assert.match(css, /\n\[hidden\]\{display:none !important;\}\n/);
377
+ assert.match(index, /\n\.bandFold\[hidden\]\{display:none\}\n/);
378
+ assert.match(index.split('@media (max-width:900px){')[1], /^[\s\S]*?\.commTrig\{display:none\}\s*\.bandFold\{display:block\}/);
379
+ for (const f of ['assets/hub-bar.js', 'projects.html', 'index.html']) {
380
+ assert.match(read(f), /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null; \}\);/, `${f}: the arrows skip a cell CSS does not draw`);
381
+ }
382
+ assert.doesNotMatch(css + index, /\.barLink/, 'the plain link this menu replaced is gone');
383
+ });
384
+
385
+ // ── 4. the routes ────────────────────────────────────────────────────────────
386
+
387
+ const { buildPlatformApp } = require('../src/platform-server.js');
388
+ const modules = require('../src/modules.js');
389
+ let server;
390
+ let port;
391
+ before(async () => {
392
+ server = http.createServer(buildPlatformApp());
393
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
394
+ port = server.address().port;
395
+ });
396
+ after(async () => {
397
+ modules.setModuleOverrides({});
398
+ await new Promise((resolve) => server.close(resolve));
399
+ });
400
+ function get(pathname, host) {
401
+ return new Promise((resolve, reject) => {
402
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
403
+ let body = '';
404
+ res.on('data', (c) => (body += c));
405
+ res.on('end', () => resolve({ status: res.statusCode, body }));
406
+ }).on('error', reject);
407
+ });
408
+ }
409
+
410
+ test('GET /community, /community/find and /community/recruiters: one stamped page on the hub’s apex; never on a child or another host', async () => {
411
+ modules.setModuleOverrides({ 'platform-identity': false });
412
+ for (const [p] of SUBPAGES) assert.equal((await get(p, 'game.test.local')).status, 404, `${p}: a child instance has none of the routes these pages read`);
413
+ modules.setModuleOverrides({ 'platform-identity': true });
414
+ const bodies = [];
415
+ for (const [p] of SUBPAGES) {
416
+ const hub = await get(p, 'game.test.local');
417
+ assert.equal(hub.status, 200, p);
418
+ assert.match(hub.body, /— Community<\/title>/);
419
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/, 'every token stamped, the unit word included');
420
+ bodies.push(hub.body);
421
+ }
422
+ assert.ok(bodies.every((b) => b === bodies[0]), 'byte-identical for every sub-page: the script picks the view');
423
+ assert.doesNotMatch((await get('/community/everyone', 'game.test.local')).body, /— Community<\/title>/, 'only the three sub-pages');
424
+ for (const host of ['builders.test.local', 'status.test.local']) {
425
+ assert.doesNotMatch((await get('/community', host)).body, /— Community<\/title>/, `${host} must not serve /community`);
426
+ }
427
+ });
428
+
429
+ // ── 5. the kit ───────────────────────────────────────────────────────────────
430
+
431
+ test('the kit renders each sub-page for everyone, the empty board, and a project owner’s recruiter list', () => {
432
+ const json = (f) => JSON.parse(read(f));
433
+ const states = json('community.states.json');
434
+ assert.equal(states.page, '/community');
435
+ assert.deepEqual(states.modes, ['dark']);
436
+ for (const s of ['out', 'out-30d', 'out-artists', 'out-find', 'out-find-search', 'out-recruiters', 'in-recruiters', 'in']) assert.ok(states.states[s], `kit state ${s}`);
437
+ assert.equal(states.states['out-find'].url, '/community/find');
438
+ assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
439
+ assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
440
+ for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
441
+ assert.equal(json(file).stub.fixtures[name], fixture);
442
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
443
+ }
444
+ assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
445
+ });
446
+
447
+ function stub(env = {}) {
448
+ return new Promise((resolve, reject) => {
449
+ const child = spawn(process.execPath, [path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js')], {
450
+ env: { ...process.env, STUB_PORT: '0', ...env }, stdio: ['ignore', 'pipe', 'inherit'],
451
+ });
452
+ let out = '';
453
+ child.stdout.on('data', (c) => {
454
+ out += c;
455
+ const m = out.match(/stub on (http:\/\/127\.0\.0\.1:\d+)\//);
456
+ if (m) resolve({ origin: m[1], stop: () => child.kill() });
457
+ });
458
+ child.on('exit', (code) => reject(new Error(`stub exited ${code}: ${out}`)));
459
+ });
460
+ }
461
+ async function call(origin, p) {
462
+ const res = await globalThis.fetch(origin + '/api/bongos' + p);
463
+ return { status: res.status, body: await res.json() };
464
+ }
465
+
466
+ test('the kit stub answers the board, the search and the recruiting door the way the routes do', async () => {
467
+ const s = await stub();
468
+ try {
469
+ const all = await call(s.origin, '/community/leaderboard');
470
+ assert.equal(all.body.window, 'all');
471
+ assert.deepEqual(all.body.results.map((r) => r.place), all.body.results.map((_, i) => i + 1));
472
+ assert.equal(all.body.count, all.body.results.length);
473
+ const artists = await call(s.origin, '/community/leaderboard?window=all&discipline=artist');
474
+ assert.ok(artists.body.results.length && artists.body.results.every((r) => r.disciplines.includes('artist')));
475
+ assert.equal(artists.body.results[0].place, 1, 'a filtered board is numbered on its own');
476
+ assert.equal((await call(s.origin, '/community/leaderboard?window=1d')).body.error.code, 'bad_window');
477
+ assert.equal((await call(s.origin, '/community/search?q=p')).body.error.code, 'query_too_short');
478
+ const byName = await call(s.origin, '/community/search?q=sam');
479
+ assert.deepEqual(byName.body.results.map((r) => [r.handle, r.disciplines]), [['sam-rivera', null]], 'a hidden builder is found by name');
480
+ assert.equal((await call(s.origin, '/community/search?q=sam&discipline=engineer')).body.results.length, 0, 'and drops out of a craft search');
481
+ assert.equal((await call(s.origin, '/scouting')).status, 401, 'signed out, the door answers 401');
482
+ } finally { s.stop(); }
483
+ const member = await stub({ AUTH: '1' });
484
+ try {
485
+ assert.equal((await call(member.origin, '/scouting')).status, 403, 'signed in, most builders are refused');
486
+ } finally { member.stop(); }
487
+ const owner = await stub({ AUTH: '1', FIXTURE_ALIAS: JSON.stringify({ scouting: 'scouting-recruiter' }) });
488
+ try {
489
+ const r = await call(owner.origin, '/scouting');
490
+ assert.equal(r.status, 200);
491
+ assert.ok(r.body.name_only.every((n) => Object.keys(n).sort().join() === 'avatar_url,display_name,github_login'), 'the name-only tier is three columns');
492
+ } finally { owner.stop(); }
493
+ });
@@ -128,6 +128,7 @@ function el(tag) {
128
128
  n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
129
129
  n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
130
130
  n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
131
+ n.scrollIntoView = () => { n.scrolled = (n.scrolled || 0) + 1; };
131
132
  return n;
132
133
  }
133
134
  const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
@@ -161,7 +162,7 @@ const GRAPH = () => ({
161
162
  ], count: 2 } },
162
163
  });
163
164
 
164
- async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRAPH(), writes = [] } = {}) {
165
+ async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRAPH(), writes = [], hash = '' } = {}) {
165
166
  const ids = new Map();
166
167
  for (const id of [...PAGE.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])) {
167
168
  const n = el('x');
@@ -180,15 +181,18 @@ async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRA
180
181
  return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
181
182
  };
182
183
  const document = { getElementById: (id) => ids.get(id) || null, createElement: el };
183
- const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) } };
184
- const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, Date, isNaN, encodeURIComponent };
184
+ const winOn = {};
185
+ const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) }, addEventListener: (t, f) => { (winOn[t] ||= []).push(f); } };
186
+ const location = { hash };
187
+ const sandbox = { window, document, fetch, location, console, Promise, JSON, String, Object, Array, URL, Date, isNaN, encodeURIComponent };
185
188
  vm.createContext(sandbox);
186
189
  vm.runInContext(SCRIPT, sandbox, { filename: 'connections.html' });
187
190
  await settle();
188
191
  const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
189
192
  const key = async (n, k) => { const e = { key: k, preventDefault() { e.prevented = true; } }; for (const f of n._on.keydown || []) f(e); await settle(); return e; };
190
193
  const gets = () => requests.filter((r) => r.method === 'GET').map((r) => r.url);
191
- return { ids, requests, gets, click, key, $: (id) => ids.get(id) };
194
+ const hashTo = async (h) => { location.hash = h; for (const f of winOn.hashchange || []) f({}); await settle(); };
195
+ return { ids, requests, gets, click, key, hashTo, $: (id) => ids.get(id) };
192
196
  }
193
197
  const button = (row, label) => all(row, 'btn').find((b) => b.textContent === label);
194
198
 
@@ -283,6 +287,51 @@ test('a failed suggestions read drops only that panel', async () => {
283
287
  assert.equal(kids(ui.$('connList')).length, 2);
284
288
  });
285
289
 
290
+ test('opened by the Community menu’s item (#connSuggest): scrolled to once, and an empty panel says so', async () => {
291
+ // the panel draws after the browser's own jump to the fragment, so the page makes it, once
292
+ let ui = await boot({ hash: '#connSuggest', writes: [{ status: 201, body: { status: 'pending' } }] });
293
+ assert.equal(ui.$('connSuggest').hidden, false);
294
+ assert.equal(ui.$('suggestNone').hidden, true, 'suggestions need no explaining');
295
+ assert.equal(ui.$('connSuggest').scrolled, 1);
296
+ await ui.click(button(kids(ui.$('suggestList'))[0], 'Connect'));
297
+ assert.equal(ui.gets().length, 6);
298
+ assert.equal(ui.$('connSuggest').scrolled, 1, 'a re-read after Connect does not jump the page again');
299
+
300
+ const none = GRAPH();
301
+ none['/connections/suggestions'].body = { suggestions: [] };
302
+ ui = await boot({ hash: '#connSuggest', reads: none });
303
+ assert.equal(ui.$('connSuggest').hidden, false, 'asked for by name, the panel answers rather than vanishing');
304
+ assert.equal(ui.$('suggestNone').hidden, false);
305
+ assert.match(PAGE, /<p class="mineEmpty" id="suggestNone" hidden>No one to suggest yet\. Suggestions come from the connections and projects you have in common with other builders\.<\/p>/);
306
+
307
+ const failed = GRAPH();
308
+ failed['/connections/suggestions'] = { status: 500, body: {} };
309
+ ui = await boot({ hash: '#connSuggest', reads: failed });
310
+ assert.equal(ui.$('connSuggest').hidden, true, 'an unread list is not "no one to suggest"');
311
+ assert.equal(ui.$('connSuggest').scrolled, undefined);
312
+ });
313
+
314
+ test('the menu item pressed while already on the page: the hash change is answered as an arrival', async () => {
315
+ const none = GRAPH();
316
+ none['/connections/suggestions'].body = { suggestions: [] };
317
+ let ui = await boot({ reads: none });
318
+ assert.equal(ui.$('connSuggest').hidden, true, 'a plain visit: no suggestions, no empty shell');
319
+ await ui.hashTo('#connSuggest');
320
+ assert.equal(ui.gets().length, 6, 'one re-read');
321
+ assert.equal(ui.$('connSuggest').hidden, false);
322
+ assert.equal(ui.$('suggestNone').hidden, false);
323
+
324
+ ui = await boot();
325
+ await ui.hashTo('#connSuggest');
326
+ assert.equal(ui.$('connSuggest').scrolled, 1, 'scrolled to once the panel is drawn');
327
+ await ui.hashTo('#connList');
328
+ assert.equal(ui.gets().length, 6, 'any other hash reads nothing');
329
+
330
+ ui = await boot({ me: null });
331
+ await ui.hashTo('#connSuggest');
332
+ assert.deepEqual(ui.requests, [], 'signed out, the door stands and nothing is read');
333
+ });
334
+
286
335
  test('Accept, Decline, Remove and Withdraw: one call each, then the page re-reads', async () => {
287
336
  const cases = [
288
337
  ['incomingList', 'Accept', 'POST', '/api/bongos/connections/51/accept', { status: 200, body: { status: 'accepted' } }, 'Connected.'],
@@ -216,6 +216,7 @@ function boot({ pathname = '/g/forge-crew', me = null, answer = () => NOT_FOUND,
216
216
  querySelectorAll(sel) {
217
217
  if (sel === '.trig') return trigs;
218
218
  if (sel === '.menuBand') return bands;
219
+ if (sel === '.knowCell') return [];
219
220
  const m = sel.match(/^input\[name="(\w+)"\]$/);
220
221
  if (!m) throw new Error(`unstubbed selector: ${sel}`);
221
222
  return radios.filter((r) => r.name === m[1]);