@bongos/core 1.23.9 → 1.23.10
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 +92 -37
- package/docs/copy-inventory.md +858 -724
- package/docs/copy-registry.json +2538 -1155
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +41 -4
- package/docs/page-readings.json +1240 -1156
- package/modules/public-landing/public/account.html +11 -0
- package/modules/public-landing/public/assets/cosmos.css +9 -1
- package/modules/public-landing/public/assets/hub-bar.js +1 -1
- package/modules/public-landing/public/community-empty.states.json +15 -0
- package/modules/public-landing/public/community-recruiter.states.json +15 -0
- package/modules/public-landing/public/community.html +469 -0
- package/modules/public-landing/public/community.probes.json +92 -0
- package/modules/public-landing/public/community.states.json +23 -0
- package/modules/public-landing/public/connections.html +11 -0
- package/modules/public-landing/public/contact.html +11 -0
- package/modules/public-landing/public/guild.html +11 -0
- package/modules/public-landing/public/index.html +18 -1
- package/modules/public-landing/public/index.probes.json +1 -1
- package/modules/public-landing/public/invitations.html +11 -0
- package/modules/public-landing/public/privacy.html +11 -0
- package/modules/public-landing/public/projects.html +12 -1
- package/modules/public-landing/public/terms.html +11 -0
- package/modules/ui-design/kit/fixtures/community-leaderboard-empty.json +4 -0
- package/modules/ui-design/kit/fixtures/community-leaderboard.json +28 -0
- package/modules/ui-design/kit/fixtures/community-search.json +10 -0
- package/modules/ui-design/kit/fixtures/scouting-recruiter.json +11 -0
- package/modules/ui-design/kit/fixtures/scouting.json +4 -0
- package/modules/ui-design/kit/serve.js +55 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/serve-internal.js +4 -2
- package/src/module-api.js +1 -1
- package/src/platform-server.js +11 -0
- package/tests/hub_account_page.mjs +2 -0
- package/tests/hub_community_page.mjs +475 -0
- package/tests/landing_legal_pages.mjs +3 -0
- package/tests/landing_page.mjs +1 -1
- package/tests/render_check.mjs +2 -2
- package/tests/ui_design_kit.mjs +2 -2
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.10",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.10",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.10",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -10441,5 +10441,11 @@
|
|
|
10441
10441
|
"id": "1004897",
|
|
10442
10442
|
"text": "The record of this session's work on goal 1000110 is written down for the next person who picks it up."
|
|
10443
10443
|
}
|
|
10444
|
+
],
|
|
10445
|
+
"1.23.10": [
|
|
10446
|
+
{
|
|
10447
|
+
"id": "1003363",
|
|
10448
|
+
"text": "cloudbongos.com now has a Community menu: a leaderboard of builders across every project (by week, month, year or all time, and by craft), a search for builders by name, and, for project owners, a list of builders who keep the"
|
|
10449
|
+
}
|
|
10444
10450
|
]
|
|
10445
10451
|
}
|
|
@@ -1005,8 +1005,10 @@ function mountInternalSurfaces(app) {
|
|
|
1005
1005
|
// /people itself (owner call, 2026-09-25 — ADR 0337 D5): cross-project
|
|
1006
1006
|
// discovery belongs to the hub, and /roster is the hall's directory. Both URLs
|
|
1007
1007
|
// survive as redirects to /roster so a bookmark lands somewhere instead of a
|
|
1008
|
-
// 404.
|
|
1009
|
-
//
|
|
1008
|
+
// 404. They stay on /roster now the hub's community pages exist (task 1003363):
|
|
1009
|
+
// each hall is its own instance and only the hub serves /community, so a child
|
|
1010
|
+
// hall has nowhere else to send them. 302, not 301, so a re-point is never
|
|
1011
|
+
// stuck in a browser cache. No page gate:
|
|
1010
1012
|
// the redirect discloses nothing, and /roster is deliberately public.
|
|
1011
1013
|
// GET /scouting's own gate (platform-identity) is untouched by this.
|
|
1012
1014
|
const RETIRED_DIRECTORY_RE = /^\/builders\/(?:people|scouting)(?:\.html)?\/?$/;
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.10'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -337,6 +337,17 @@ function buildPlatformApp() {
|
|
|
337
337
|
if (!html) return next();
|
|
338
338
|
res.type('html').send(html);
|
|
339
339
|
});
|
|
340
|
+
// THE COMMUNITY PAGES (task 1003363, ADR 0217 D1, ADR 0337 D1/D3): who is building
|
|
341
|
+
// across every project — the leaderboard, the builder search (the same for every
|
|
342
|
+
// viewer) and the recruiter list GET /scouting's own door admits. One file serves all
|
|
343
|
+
// three; its script shows the one the path names. Hub-only for the /account reason:
|
|
344
|
+
// every read it makes is a platform-identity route.
|
|
345
|
+
app.get(['/community', '/community/find', '/community/recruiters'], (req, res, next) => {
|
|
346
|
+
if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
|
|
347
|
+
const html = landingPage('community.html');
|
|
348
|
+
if (!html) return next();
|
|
349
|
+
res.type('html').send(html);
|
|
350
|
+
});
|
|
340
351
|
// THE GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6): /g/<slug>, the namespace
|
|
341
352
|
// D6 reserved. Hub-only for the /account reason — every read it makes is a
|
|
342
353
|
// platform-identity route. ONE shell for every slug: the page reads the public
|
|
@@ -135,6 +135,8 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
|
|
|
135
135
|
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
|
|
136
136
|
['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
|
|
137
137
|
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
138
|
+
// the Community menu's three sub-pages, folded into the band under 480px (task 1003363)
|
|
139
|
+
['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
138
140
|
], 'this page has no views of its own, so every item names /projects in full');
|
|
139
141
|
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
|
|
140
142
|
['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
|
|
@@ -0,0 +1,475 @@
|
|
|
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+&/g, 'return=X&').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&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
|
+
|
|
334
|
+
test('every page that wears the hub’s bar carries the Community menu before Projects; apex-only where the page serves a child', () => {
|
|
335
|
+
const { stripApexOnly } = require('../src/platform-server.js');
|
|
336
|
+
const trig = '<button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity"';
|
|
337
|
+
const band = ['<div class="menuBand" id="menuCommunity" data-menu="community" hidden>', ' <div class="bandIn">', ' <ul class="bandRow">',
|
|
338
|
+
...ITEMS.map(([h, t, c]) => ` <li class="bandCell"><a class="bandT" href="${h}">${t}</a><span class="bandC">${c}</span></li>`), ' </ul>', ' </div>', '</div>'].join('\n');
|
|
339
|
+
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('');
|
|
340
|
+
for (const f of [...SERVES_A_CHILD, ...HUB_ONLY]) {
|
|
341
|
+
const html = read(f);
|
|
342
|
+
const apex = SERVES_A_CHILD.includes(f);
|
|
343
|
+
const at = html.indexOf(apex ? `<!--apex-only:start-->${trig}` : trig);
|
|
344
|
+
assert.ok(at > 0, `${f}: the Community trigger`);
|
|
345
|
+
assert.ok(html.indexOf('id="projTrig"') > at, `${f}: Community comes before Projects`);
|
|
346
|
+
assert.ok(html.includes(wrap(apex, band)), `${f}: the Community band, its three sub-pages`);
|
|
347
|
+
assert.ok(html.indexOf('id="menuCommunity"') < html.indexOf('id="menuProjects"'), `${f}: the bands in the bar’s order`);
|
|
348
|
+
assert.ok(html.includes(wrap(apex, fold)), `${f}: the three folded into the Projects band for phones`);
|
|
349
|
+
if (apex) assert.doesNotMatch(stripApexOnly(html, false).split('<body>')[1], /\/community|commTrig|menuCommunity/, `${f}: a child has no community pages, so no trace of the menu`);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
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', () => {
|
|
354
|
+
const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
|
|
355
|
+
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');
|
|
356
|
+
assert.match(css, /\n\.bandFold\{display:none;\}\n/, 'above 480 the Projects band does not repeat the menu');
|
|
357
|
+
assert.match(css.split('@media (max-width:480px){')[1].split('\n}')[0], /\.commTrig\{display:none;\}\s*\.bandFold\{display:block;\}/);
|
|
358
|
+
const index = read('index.html');
|
|
359
|
+
assert.match(index, /\n\.bandFold\{display:none\}\n/);
|
|
360
|
+
assert.match(index.split('@media (max-width:900px){')[1], /^[\s\S]*?\.commTrig\{display:none\}\s*\.bandFold\{display:block\}/);
|
|
361
|
+
for (const f of ['assets/hub-bar.js', 'projects.html', 'index.html']) {
|
|
362
|
+
assert.match(read(f), /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null; \}\);/, `${f}: the arrows skip a cell CSS does not draw`);
|
|
363
|
+
}
|
|
364
|
+
assert.doesNotMatch(css + index, /\.barLink/, 'the plain link this menu replaced is gone');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ── 4. the routes ────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
const { buildPlatformApp } = require('../src/platform-server.js');
|
|
370
|
+
const modules = require('../src/modules.js');
|
|
371
|
+
let server;
|
|
372
|
+
let port;
|
|
373
|
+
before(async () => {
|
|
374
|
+
server = http.createServer(buildPlatformApp());
|
|
375
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
376
|
+
port = server.address().port;
|
|
377
|
+
});
|
|
378
|
+
after(async () => {
|
|
379
|
+
modules.setModuleOverrides({});
|
|
380
|
+
await new Promise((resolve) => server.close(resolve));
|
|
381
|
+
});
|
|
382
|
+
function get(pathname, host) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
|
|
385
|
+
let body = '';
|
|
386
|
+
res.on('data', (c) => (body += c));
|
|
387
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
388
|
+
}).on('error', reject);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
test('GET /community, /community/find and /community/recruiters: one stamped page on the hub’s apex; never on a child or another host', async () => {
|
|
393
|
+
modules.setModuleOverrides({ 'platform-identity': false });
|
|
394
|
+
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`);
|
|
395
|
+
modules.setModuleOverrides({ 'platform-identity': true });
|
|
396
|
+
const bodies = [];
|
|
397
|
+
for (const [p] of SUBPAGES) {
|
|
398
|
+
const hub = await get(p, 'game.test.local');
|
|
399
|
+
assert.equal(hub.status, 200, p);
|
|
400
|
+
assert.match(hub.body, /— Community<\/title>/);
|
|
401
|
+
assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/, 'every token stamped, the unit word included');
|
|
402
|
+
bodies.push(hub.body);
|
|
403
|
+
}
|
|
404
|
+
assert.ok(bodies.every((b) => b === bodies[0]), 'byte-identical for every sub-page: the script picks the view');
|
|
405
|
+
assert.doesNotMatch((await get('/community/everyone', 'game.test.local')).body, /— Community<\/title>/, 'only the three sub-pages');
|
|
406
|
+
for (const host of ['builders.test.local', 'status.test.local']) {
|
|
407
|
+
assert.doesNotMatch((await get('/community', host)).body, /— Community<\/title>/, `${host} must not serve /community`);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ── 5. the kit ───────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
test('the kit renders each sub-page for everyone, the empty board, and a project owner’s recruiter list', () => {
|
|
414
|
+
const json = (f) => JSON.parse(read(f));
|
|
415
|
+
const states = json('community.states.json');
|
|
416
|
+
assert.equal(states.page, '/community');
|
|
417
|
+
assert.deepEqual(states.modes, ['dark']);
|
|
418
|
+
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}`);
|
|
419
|
+
assert.equal(states.states['out-find'].url, '/community/find');
|
|
420
|
+
assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
|
|
421
|
+
assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
|
|
422
|
+
for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
|
|
423
|
+
assert.equal(json(file).stub.fixtures[name], fixture);
|
|
424
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
|
|
425
|
+
}
|
|
426
|
+
assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
function stub(env = {}) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js')], {
|
|
432
|
+
env: { ...process.env, STUB_PORT: '0', ...env }, stdio: ['ignore', 'pipe', 'inherit'],
|
|
433
|
+
});
|
|
434
|
+
let out = '';
|
|
435
|
+
child.stdout.on('data', (c) => {
|
|
436
|
+
out += c;
|
|
437
|
+
const m = out.match(/stub on (http:\/\/127\.0\.0\.1:\d+)\//);
|
|
438
|
+
if (m) resolve({ origin: m[1], stop: () => child.kill() });
|
|
439
|
+
});
|
|
440
|
+
child.on('exit', (code) => reject(new Error(`stub exited ${code}: ${out}`)));
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async function call(origin, p) {
|
|
444
|
+
const res = await globalThis.fetch(origin + '/api/bongos' + p);
|
|
445
|
+
return { status: res.status, body: await res.json() };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
test('the kit stub answers the board, the search and the recruiting door the way the routes do', async () => {
|
|
449
|
+
const s = await stub();
|
|
450
|
+
try {
|
|
451
|
+
const all = await call(s.origin, '/community/leaderboard');
|
|
452
|
+
assert.equal(all.body.window, 'all');
|
|
453
|
+
assert.deepEqual(all.body.results.map((r) => r.place), all.body.results.map((_, i) => i + 1));
|
|
454
|
+
assert.equal(all.body.count, all.body.results.length);
|
|
455
|
+
const artists = await call(s.origin, '/community/leaderboard?window=all&discipline=artist');
|
|
456
|
+
assert.ok(artists.body.results.length && artists.body.results.every((r) => r.disciplines.includes('artist')));
|
|
457
|
+
assert.equal(artists.body.results[0].place, 1, 'a filtered board is numbered on its own');
|
|
458
|
+
assert.equal((await call(s.origin, '/community/leaderboard?window=1d')).body.error.code, 'bad_window');
|
|
459
|
+
assert.equal((await call(s.origin, '/community/search?q=p')).body.error.code, 'query_too_short');
|
|
460
|
+
const byName = await call(s.origin, '/community/search?q=sam');
|
|
461
|
+
assert.deepEqual(byName.body.results.map((r) => [r.handle, r.disciplines]), [['sam-rivera', null]], 'a hidden builder is found by name');
|
|
462
|
+
assert.equal((await call(s.origin, '/community/search?q=sam&discipline=engineer')).body.results.length, 0, 'and drops out of a craft search');
|
|
463
|
+
assert.equal((await call(s.origin, '/scouting')).status, 401, 'signed out, the door answers 401');
|
|
464
|
+
} finally { s.stop(); }
|
|
465
|
+
const member = await stub({ AUTH: '1' });
|
|
466
|
+
try {
|
|
467
|
+
assert.equal((await call(member.origin, '/scouting')).status, 403, 'signed in, most builders are refused');
|
|
468
|
+
} finally { member.stop(); }
|
|
469
|
+
const owner = await stub({ AUTH: '1', FIXTURE_ALIAS: JSON.stringify({ scouting: 'scouting-recruiter' }) });
|
|
470
|
+
try {
|
|
471
|
+
const r = await call(owner.origin, '/scouting');
|
|
472
|
+
assert.equal(r.status, 200);
|
|
473
|
+
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');
|
|
474
|
+
} finally { owner.stop(); }
|
|
475
|
+
});
|
|
@@ -193,7 +193,10 @@ for (const p of PAGES) {
|
|
|
193
193
|
assert.deepEqual(bandLinks(bandOf(html, 'menuProjects')), [
|
|
194
194
|
['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
|
|
195
195
|
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
196
|
+
// the Community menu's three sub-pages, folded into the band under 480px (task 1003363); apex-only
|
|
197
|
+
['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
|
|
196
198
|
], 'this page has no views of its own, so every item names /projects in full');
|
|
199
|
+
assert.deepEqual(bandLinks(bandOf(stripApexOnly(html, false), 'menuProjects')).map((l) => l[0]), ['Sky', 'Table', 'Your projects', 'New project'], 'a child has no /community');
|
|
197
200
|
assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
|
|
198
201
|
['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
|
|
199
202
|
]);
|
package/tests/landing_page.mjs
CHANGED
|
@@ -594,7 +594,7 @@ test('the bands are one disclosure: Escape returns focus, arrows cycle, Tab out
|
|
|
594
594
|
assert.match(CODE, /else openMenu\(t, ev\.detail === 0\);/, 'a keyboard-fired click is told apart from a pointer');
|
|
595
595
|
assert.match(CODE, /if \(ev\.key === 'Escape'\) \{ closeMenus\(\); return; \}/);
|
|
596
596
|
assert.match(CODE, /if \(ev\.key !== 'ArrowDown' && ev\.key !== 'ArrowUp'\) return;/);
|
|
597
|
-
assert.match(CODE, /return !a\.closest\('\[hidden\]'\);/, 'the arrows skip the hidden Your-projects cell');
|
|
597
|
+
assert.match(CODE, /return !a\.closest\('\[hidden\]'\) && a\.offsetParent !== null;/, 'the arrows skip the hidden Your-projects cell, and the Community cell above 900px, which CSS does not draw (task 1003363)');
|
|
598
598
|
assert.match(CODE, /b\.addEventListener\('focusout', function \(ev\) \{[\s\S]*?if \(!to \|\| b\.contains\(to\) \|\| \(to\.closest && to\.closest\('\.trig'\)\)\) return;[\s\S]*?closeMenus\(\);/, 'Tab out closes; a null relatedTarget is left alone');
|
|
599
599
|
assert.match(CODE, /if \(ev\.target\.closest && \(ev\.target\.closest\('\.menuBand'\) \|\| ev\.target\.closest\('\.trig'\)\)\) return;[\s\S]*?closeMenus\(\);/, 'a click outside closes');
|
|
600
600
|
assert.doesNotMatch(HTML, /role="menu"|aria-haspopup/, 'plain links in a disclosure — what is announced is what ships');
|
package/tests/render_check.mjs
CHANGED
|
@@ -25,8 +25,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
|
|
|
25
25
|
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
|
|
26
26
|
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
|
|
27
27
|
// the account page links the hub's sheet too since task 1004259, the guild page since task 1002301,
|
|
28
|
-
// the invitations page since task 1004276, and the
|
|
29
|
-
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'connections.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
|
|
28
|
+
// the invitations page since task 1004276, the connections page since task 1004278, and the community page since task 1003363
|
|
29
|
+
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'community.states.json', 'connections.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
|