@bongos/core 1.21.56 → 1.21.57
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 +47 -22
- package/docs/copy-inventory.md +57 -19
- package/docs/copy-registry.json +450 -83
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +32 -4
- package/docs/page-readings.json +69 -1
- package/modules/public-landing/public/guild.html +571 -0
- package/modules/public-landing/public/guild.probes.json +53 -0
- package/modules/public-landing/public/guild.states.json +38 -0
- package/modules/ui-design/kit/fixtures/guilds.json +73 -0
- package/modules/ui-design/kit/serve.js +46 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +13 -0
- package/tests/hub_guild_page.mjs +537 -0
- package/tests/privacy_matrix.mjs +126 -14
- package/tests/render_check.mjs +2 -2
- package/tests/ui_design_kit.mjs +3 -3
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// tests/hub_guild_page.mjs — the hub's guild page at /g/<slug> (task 1002301, R25;
|
|
2
|
+
// ADR 0336 D4/D6, ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// A guild spans projects, so its page is the hub's. One shell serves every slug;
|
|
5
|
+
// the page asks the public read (GET /guilds/:slug) and, for someone signed in,
|
|
6
|
+
// the members' own-scope read (GET /guilds/:slug/members), and writes one thing:
|
|
7
|
+
// the member's own two switches (PATCH /guilds/:slug/membership). These pin:
|
|
8
|
+
// 1. the page as a file: portable, dark only, noindex, no page :root, no
|
|
9
|
+
// innerHTML, no GitHub id or login read, and only the three guild routes;
|
|
10
|
+
// 2. its slug rule is the module's (a boundary-forced copy);
|
|
11
|
+
// 3. the bar is the hub's — the account page's chrome, with no current place
|
|
12
|
+
// and the sign-in doors pointed back at the guild by the script;
|
|
13
|
+
// 4. its script, EXECUTED in a node:vm under a DOM stub together with
|
|
14
|
+
// assets/hub-bar.js: the public view (the roster, the band floors, "under
|
|
15
|
+
// 10"), "no guild here" as ONE answer for an unknown slug, an unlisted guild
|
|
16
|
+
// and a malformed path, the members' view, the prompt after a guild goes
|
|
17
|
+
// public (asked, never assumed; "not now" remembered per guild), the
|
|
18
|
+
// counted switch and its notice, and each save sending only what moved;
|
|
19
|
+
// 5. the route: /g/<slug> on the hub's apex only, never on a child or the
|
|
20
|
+
// builders/status hosts, and a segment that cannot be a slug falls through.
|
|
21
|
+
// What the public roster may hold is the server's, and tests/privacy_matrix.mjs
|
|
22
|
+
// carries its cells; this file proves the page shows what it is given and asks
|
|
23
|
+
// for nothing more.
|
|
24
|
+
//
|
|
25
|
+
// DB-free. Run: node --test tests/hub_guild_page.mjs
|
|
26
|
+
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import { test, before, after } from 'node:test';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import vm from 'node:vm';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
39
|
+
const read = (f) => fs.readFileSync(path.join(LANDING, f), 'utf8').replace(/\r\n/g, '\n');
|
|
40
|
+
const PAGE = read('guild.html');
|
|
41
|
+
const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
|
|
42
|
+
const BAR = read('assets/hub-bar.js');
|
|
43
|
+
const HUB = read('projects.html');
|
|
44
|
+
const ACCOUNT = read('account.html');
|
|
45
|
+
|
|
46
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
47
|
+
const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
|
|
48
|
+
|
|
49
|
+
// ── 1. the page as a file ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
test('portable and dark only: stamped title, noindex, balanced sentinels, no :root, no literal colour, no baked apex', () => {
|
|
52
|
+
assert.match(PAGE, /^<html lang="en" data-mode="dark">/m, 'dark is fixed on <html> (ADR 0204)');
|
|
53
|
+
assert.match(PAGE, /<title>\{\{productName\}\} — Guild<\/title>/);
|
|
54
|
+
assert.match(PAGE, /<meta name="robots" content="noindex">/, 'one shell answers every slug, so a guild that does not exist must not index as a page');
|
|
55
|
+
const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
56
|
+
assert.doesNotMatch(styles, /:root\s*\{/, 'a page-local :root outranks the injected pack');
|
|
57
|
+
assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i, 'every colour is a token, never a literal');
|
|
58
|
+
assert.doesNotMatch(PAGE, /cloudbongos\.com/, 'no hardcoded apex — the file ships to every instance');
|
|
59
|
+
assert.doesNotMatch(PAGE, /gtag|plausible|googletagmanager/);
|
|
60
|
+
assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, (PAGE.match(/<!--apex-only:end-->/g) || []).length, 'balanced sentinels');
|
|
61
|
+
const stamped = applyBrandTokens(PAGE, { identity: { productName: 'Spike Harbor' } }, 'https://spike.example', 'https://builders.spike.example', '');
|
|
62
|
+
assert.doesNotMatch(stamped, /\{\{[a-zA-Z]+\}\}/, 'every token stamps');
|
|
63
|
+
assert.match(stamped, /<title>Spike Harbor — Guild<\/title>/);
|
|
64
|
+
assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/, 'the hub’s sheet, which carries [hidden]{display:none !important} over the page’s grid rules');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the script builds no markup from strings, never reads a GitHub id or login, and the regions ship hidden', () => {
|
|
68
|
+
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
|
|
69
|
+
assert.doesNotMatch(SCRIPT, /github_(id|login)/, 'a roster shows handle, display name, avatar and kind — never a GitHub id or login (ADR 0336 D4)');
|
|
70
|
+
for (const id of ['guildBody', 'missing', 'missingSignin', 'gYou', 'showAsk', 'shownUnlisted', 'gRosterEmpty']) {
|
|
71
|
+
assert.match(PAGE, new RegExp(`id="${id}"[^>]*\\bhidden\\b`), `#${id} ships hidden`);
|
|
72
|
+
}
|
|
73
|
+
for (const [name, values] of [['shown', ['on', 'off']], ['counted', ['on', 'off']]]) {
|
|
74
|
+
for (const v of values) assert.match(PAGE, new RegExp(`<input type="radio" name="${name}" value="${v}">`));
|
|
75
|
+
}
|
|
76
|
+
for (const id of ['askYes', 'askNo', 'youSave', 'gRoster', 'tShipped', 'tCredits', 'tKarma', 'tProjects']) assert.match(PAGE, new RegExp(`id="${id}"`));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the page calls only the three guild routes; the bar’s two calls stay in the shared asset', () => {
|
|
80
|
+
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
81
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /guilds/', 'PATCH /guilds/'].sort(), 'every call is built on /guilds/<slug>');
|
|
82
|
+
assert.deepEqual([...SCRIPT.matchAll(/request\('[A-Z]+', '\/guilds\/' \+ slug( \+ '[^']*')?/g)].map((m) => m[0]).sort(), [
|
|
83
|
+
"request('GET', '/guilds/' + slug",
|
|
84
|
+
"request('GET', '/guilds/' + slug",
|
|
85
|
+
"request('GET', '/guilds/' + slug + '/members'",
|
|
86
|
+
"request('GET', '/guilds/' + slug + '/members'",
|
|
87
|
+
"request('PATCH', '/guilds/' + slug + '/membership'",
|
|
88
|
+
].sort());
|
|
89
|
+
assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>\n<script>\n\(function \(\) \{/, 'hub-bar.js runs first, so window.HubBar exists when the page script asks for it');
|
|
90
|
+
assert.doesNotMatch(SCRIPT, /function (openBand|closeMenus|openMenu)\b|\/auth\/logout/, 'no second copy of the bands or sign out in the page');
|
|
91
|
+
assert.match(SCRIPT, /var bar = window\.HubBar \|\| \{ paint: function \(\) \{\}, ready: Promise\.resolve\(null\) \};/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── 2. the slug rule is the module's ─────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test('the page’s slug rule is guilds.js’s own (normalizeHandle’s charset, a two-character floor)', () => {
|
|
97
|
+
const fn = SCRIPT.match(/ function slugFromPath\(pathname\) \{[\s\S]*?\n \}\n/);
|
|
98
|
+
const re = SCRIPT.match(/ var SLUG_RE = (\/[^\n]+\/);\n/);
|
|
99
|
+
assert.ok(fn && re, 'slugFromPath and SLUG_RE are still whole declarations');
|
|
100
|
+
const slugFromPath = vm.runInNewContext(`var SLUG_RE = ${re[1]};\n${fn[0]}; slugFromPath`);
|
|
101
|
+
for (const s of ['forge-crew', 'ab', 'a--b', 'x1', '9lives', 'a'.repeat(39), 'a', 'a'.repeat(40), '-ab', 'ab-', 'a_b', 'a.b', 'café', 'me', 'api', 'Forge-Crew']) {
|
|
102
|
+
const lower = s.toLowerCase();
|
|
103
|
+
const moduleSays = guilds.lookupSlug(s) === lower && guilds.normalizeGuildSlug(s).error !== 'slug_invalid';
|
|
104
|
+
assert.equal(slugFromPath(`/g/${encodeURIComponent(s)}`) === lower, moduleSays, `"${s}": the page and the module disagree`);
|
|
105
|
+
}
|
|
106
|
+
assert.equal(slugFromPath('/g/forge-crew/'), 'forge-crew', 'a trailing slash is the same guild');
|
|
107
|
+
assert.equal(slugFromPath('/g/forge-crew/members'), null, 'one segment only');
|
|
108
|
+
assert.equal(slugFromPath('/g/%E0%A4%A'), null, 'a malformed escape is no guild, not a thrown page');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── 3. the bar is the hub's ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const chrome = (html) => {
|
|
114
|
+
const m = html.match(/<a class="skip vh"[\s\S]*?<div class="menuBand" id="menuProfile"[\s\S]*?\n<\/div>\n/);
|
|
115
|
+
assert.ok(m, 'the skip link, the bar and both bands are one block');
|
|
116
|
+
return m[0];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
test('the bar is the account page’s: the same chrome, minus the current place, with the sign-in doors left for the script to point', () => {
|
|
120
|
+
for (const piece of [
|
|
121
|
+
'<header class="shell bar">',
|
|
122
|
+
'<a class="barMark" id="barMark" href="/">',
|
|
123
|
+
'<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>',
|
|
124
|
+
'<button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden>',
|
|
125
|
+
'<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
|
|
126
|
+
'<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
|
|
127
|
+
'<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
|
|
128
|
+
]) {
|
|
129
|
+
assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
|
|
130
|
+
assert.ok(PAGE.includes(piece), `guild.html does not carry the hub's: ${piece}`);
|
|
131
|
+
}
|
|
132
|
+
const norm = (html) => chrome(html)
|
|
133
|
+
.replace(/start\?return=\/account&repos=1/, 'start?repos=1')
|
|
134
|
+
.replace(/href="#\w+">Skip to content/, 'href="#MAIN">Skip to content')
|
|
135
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
136
|
+
.replace(/^[ \t]*\n/gm, '')
|
|
137
|
+
.replace(' aria-current="page"', '');
|
|
138
|
+
assert.equal(norm(PAGE), norm(ACCOUNT), 'one bar with /account, so a change to it is a change to both');
|
|
139
|
+
assert.doesNotMatch(chrome(PAGE), /aria-current/, 'no band item is this page');
|
|
140
|
+
assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?repos=1">Sign in<\/a>/,
|
|
141
|
+
'no return in the markup — one shell serves every slug; without script the route falls back to the Referer, which is this page');
|
|
142
|
+
assert.match(PAGE, /<a class="skip vh" href="#guild">/);
|
|
143
|
+
assert.match(PAGE, /<main class="gld" id="guild">/, 'the skip link has its landmark');
|
|
144
|
+
const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
145
|
+
assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── 4. the script, executed ──────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const PUBLIC = () => ({
|
|
151
|
+
guild: { slug: 'forge-crew', name: 'Forge Crew', description: 'We ship the boring parts.', created_at: '2026-09-12T10:20:00.000Z' },
|
|
152
|
+
members: [
|
|
153
|
+
{ handle: 'mara', display_name: 'Mara Quinn', avatar_url: 'https://avatars.example/u/1', membership_kind: 'owner' },
|
|
154
|
+
{ handle: 'ines', display_name: 'Ines Duarte', avatar_url: null, membership_kind: 'member' },
|
|
155
|
+
{ handle: 'kip', display_name: null, avatar_url: 'javascript:alert(1)', membership_kind: 'member' },
|
|
156
|
+
],
|
|
157
|
+
size: 3,
|
|
158
|
+
totals: { credits: 2000, works_shipped: 100, karma: 0, projects: 10, as_of: '2026-10-01T03:00:00.000Z' },
|
|
159
|
+
});
|
|
160
|
+
const MEMBERS = (you = {}, visibility = 'public') => ({
|
|
161
|
+
guild: { slug: 'forge-crew', name: 'Forge Crew', description: 'We ship the boring parts.', visibility },
|
|
162
|
+
you: { membership_kind: 'member', shown_publicly: false, counted_in_totals: true, ...you },
|
|
163
|
+
members: [...PUBLIC().members, { handle: 'octo', display_name: 'Octo', avatar_url: null, membership_kind: 'member' }, { handle: 'sol', display_name: 'Sol', avatar_url: null, membership_kind: 'member' }],
|
|
164
|
+
count: 5,
|
|
165
|
+
});
|
|
166
|
+
const NOT_FOUND = { status: 404, data: { error: { code: 'guild_not_found' } } };
|
|
167
|
+
|
|
168
|
+
// A DOM stub just wide enough for this page and hub-bar.js: every id in the
|
|
169
|
+
// markup is an element (hidden where the markup says so); created elements keep
|
|
170
|
+
// their children, and setting textContent clears them, as in a browser.
|
|
171
|
+
function node(tag, attrs = {}) {
|
|
172
|
+
const n = { tag, attrs, children: [], _text: '', className: '', disabled: false, hidden: false, style: {}, _on: {} };
|
|
173
|
+
Object.defineProperty(n, 'textContent', {
|
|
174
|
+
get() { return n._text + n.children.map((c) => c.textContent).join(''); },
|
|
175
|
+
set(v) { n._text = String(v); n.children = []; },
|
|
176
|
+
});
|
|
177
|
+
n.appendChild = (c) => { n.children.push(c); return c; };
|
|
178
|
+
n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
|
|
179
|
+
n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
|
|
180
|
+
n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
|
|
181
|
+
n.closest = () => null;
|
|
182
|
+
n.contains = () => false;
|
|
183
|
+
n.focus = () => {};
|
|
184
|
+
return n;
|
|
185
|
+
}
|
|
186
|
+
function boot({ pathname = '/g/forge-crew', me = null, answer = () => NOT_FOUND, storage = {}, storageThrows = false } = {}) {
|
|
187
|
+
const els = new Map();
|
|
188
|
+
for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
|
|
189
|
+
const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
190
|
+
const e = node(m[1], attrs);
|
|
191
|
+
e.id = m[3];
|
|
192
|
+
e.hidden = /\shidden\b/.test(m[2] + m[4]);
|
|
193
|
+
els.set(m[3], e);
|
|
194
|
+
}
|
|
195
|
+
const radios = [...PAGE.matchAll(/<input type="radio" name="(\w+)" value="(\w+)">/g)].map((m) => Object.assign(node('input'), { name: m[1], value: m[2], checked: false }));
|
|
196
|
+
const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
|
|
197
|
+
const bands = ['menuProjects', 'menuProfile'].map((id) => els.get(id));
|
|
198
|
+
const calls = [];
|
|
199
|
+
const document = {
|
|
200
|
+
title: 'Spike Harbor — Guild', cookie: '', activeElement: null,
|
|
201
|
+
addEventListener() {},
|
|
202
|
+
getElementById: (id) => els.get(id) || null,
|
|
203
|
+
createElement: (tag) => node(tag),
|
|
204
|
+
querySelector(sel) {
|
|
205
|
+
if (sel === '.menuBand:not([hidden])') return bands.find((b) => !b.hidden) || null;
|
|
206
|
+
if (sel === '.trig[aria-expanded="true"]') return null;
|
|
207
|
+
const m = sel.match(/^input\[name="(\w+)"\]:checked$/);
|
|
208
|
+
if (!m) throw new Error(`unstubbed selector: ${sel}`);
|
|
209
|
+
return radios.find((r) => r.name === m[1] && r.checked) || null;
|
|
210
|
+
},
|
|
211
|
+
querySelectorAll(sel) {
|
|
212
|
+
if (sel === '.trig') return trigs;
|
|
213
|
+
if (sel === '.menuBand') return bands;
|
|
214
|
+
const m = sel.match(/^input\[name="(\w+)"\]$/);
|
|
215
|
+
if (!m) throw new Error(`unstubbed selector: ${sel}`);
|
|
216
|
+
return radios.filter((r) => r.name === m[1]);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const fetch = (url, init = {}) => {
|
|
220
|
+
const method = init.method || 'GET';
|
|
221
|
+
const body = init.body ? JSON.parse(init.body) : undefined;
|
|
222
|
+
const p = url.replace(/^\/api\/bongos/, '');
|
|
223
|
+
calls.push({ method, path: p, body });
|
|
224
|
+
const out = p === '/me' ? (me ? { status: 200, data: { builder: me } } : { status: 401, data: null }) : answer(method, p, body);
|
|
225
|
+
return Promise.resolve({ status: out.status, ok: out.status >= 200 && out.status < 300, json: () => Promise.resolve(out.data) });
|
|
226
|
+
};
|
|
227
|
+
const localStorage = {
|
|
228
|
+
getItem(k) { if (storageThrows) throw new Error('denied'); return k in storage ? storage[k] : null; },
|
|
229
|
+
setItem(k, v) { if (storageThrows) throw new Error('denied'); storage[k] = String(v); },
|
|
230
|
+
};
|
|
231
|
+
const window = { localStorage, location: { assign() {} } };
|
|
232
|
+
const ctx = vm.createContext({ document, fetch, location: { hostname: 'cloudbongos.com', pathname }, window });
|
|
233
|
+
vm.runInContext(BAR, ctx);
|
|
234
|
+
vm.runInContext(SCRIPT, ctx);
|
|
235
|
+
const dom = {
|
|
236
|
+
els, radios, calls, storage, document,
|
|
237
|
+
el: (id) => els.get(id),
|
|
238
|
+
text: (id) => els.get(id).textContent,
|
|
239
|
+
checked: (name) => (radios.find((r) => r.name === name && r.checked) || {}).value,
|
|
240
|
+
pick(name, value) {
|
|
241
|
+
for (const r of radios.filter((x) => x.name === name)) r.checked = r.value === value;
|
|
242
|
+
for (const f of radios.find((x) => x.name === name && x.value === value)._on.change || []) f({});
|
|
243
|
+
},
|
|
244
|
+
click(id) { for (const f of els.get(id)._on.click || []) f({ preventDefault() {} }); },
|
|
245
|
+
submit() { for (const f of els.get('youForm')._on.submit || []) f({ preventDefault() {} }); },
|
|
246
|
+
rows: () => els.get('gRoster').children,
|
|
247
|
+
guildCalls: () => calls.filter((c) => c.path.startsWith('/guilds/')),
|
|
248
|
+
};
|
|
249
|
+
return dom;
|
|
250
|
+
}
|
|
251
|
+
const settle = async () => { for (let i = 0; i < 24; i++) await new Promise((r) => setImmediate(r)); };
|
|
252
|
+
const ME = { github_login: 'octo', avatar_url: null };
|
|
253
|
+
|
|
254
|
+
test('signed out, a public guild: the identity, the band floors, the public roster — and one call, never the members’ read', async () => {
|
|
255
|
+
const dom = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
|
|
256
|
+
await settle();
|
|
257
|
+
assert.equal(dom.el('guildBody').hidden, false);
|
|
258
|
+
assert.equal(dom.el('loading').hidden, true);
|
|
259
|
+
assert.equal(dom.el('missing').hidden, true);
|
|
260
|
+
assert.equal(dom.el('gYou').hidden, true, 'nothing of yours on a page you are not signed in to');
|
|
261
|
+
assert.equal(dom.text('gName'), 'Forge Crew');
|
|
262
|
+
assert.equal(dom.document.title, 'Forge Crew — Spike Harbor');
|
|
263
|
+
assert.equal(dom.text('gKicker'), 'Guild', 'the public sees only public guilds, so it is not told which kind');
|
|
264
|
+
assert.equal(dom.text('gMeta'), '3 members · since Sep 2026', 'size is the public roster’s length (ADR 0336 D4)');
|
|
265
|
+
assert.equal(dom.text('gDesc'), 'We ship the boring parts.');
|
|
266
|
+
assert.deepEqual(['tShipped', 'tCredits', 'tKarma', 'tProjects'].map((id) => dom.text(id)), ['100+', '2,000+', 'under 10', '10+'],
|
|
267
|
+
'band floors as published, never re-derived; 0 is below the first band');
|
|
268
|
+
assert.equal(dom.el('tKarma').className, 'under');
|
|
269
|
+
assert.equal(dom.text('gRecordNote'), 'Counted on 1 Oct 2026. Projects are the public projects its listed members build on.');
|
|
270
|
+
assert.deepEqual(dom.guildCalls().map((c) => `${c.method} ${c.path}`), ['GET /guilds/forge-crew'], 'a stranger’s page makes one guild call');
|
|
271
|
+
assert.equal(dom.el('signinTop').getAttribute('href'), '/api/bongos/auth/web/start?return=/g/forge-crew&repos=1', 'sign-in returns to this guild');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('the roster: name, handle, the owner marked; a missing name falls back to the handle; only an http(s) avatar is drawn', async () => {
|
|
275
|
+
const dom = boot({ answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
|
|
276
|
+
await settle();
|
|
277
|
+
const rows = dom.rows();
|
|
278
|
+
assert.equal(rows.length, 3);
|
|
279
|
+
const [mara, ines, kip] = rows.map((li) => li.children);
|
|
280
|
+
assert.equal(mara[0].tag, 'img');
|
|
281
|
+
assert.equal(mara[0].src, 'https://avatars.example/u/1');
|
|
282
|
+
assert.equal(mara[0].alt, '', 'decorative: the name is beside it');
|
|
283
|
+
assert.equal(mara[1].textContent, 'Mara Quinnmara');
|
|
284
|
+
assert.equal(mara[2].textContent, 'Owner');
|
|
285
|
+
assert.equal(ines[0].tag, 'span', 'no avatar → the initial, not a broken image');
|
|
286
|
+
assert.equal(ines[0].textContent, 'I');
|
|
287
|
+
assert.equal(ines[2].textContent, '', 'a member carries no tag');
|
|
288
|
+
assert.equal(kip[0].tag, 'span', 'a javascript: avatar is never an img src');
|
|
289
|
+
assert.equal(kip[1].children[0].textContent, 'kip', 'no display name → the handle');
|
|
290
|
+
assert.equal(dom.el('gRosterEmpty').hidden, true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('not counted yet, and an empty roster, say so', async () => {
|
|
294
|
+
const data = { ...PUBLIC(), members: [], size: 0, totals: null };
|
|
295
|
+
const dom = boot({ answer: () => ({ status: 200, data }) });
|
|
296
|
+
await settle();
|
|
297
|
+
assert.deepEqual(['tShipped', 'tCredits', 'tKarma', 'tProjects'].map((id) => dom.text(id)), ['—', '—', '—', '—']);
|
|
298
|
+
assert.equal(dom.text('gRecordNote'), 'Not counted yet. A guild’s record is counted once a week.');
|
|
299
|
+
assert.equal(dom.el('gRosterEmpty').hidden, false);
|
|
300
|
+
assert.equal(dom.text('gMeta'), '0 members · since Sep 2026');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('"no guild here" is ONE answer: an unknown slug, an unlisted guild from outside, a malformed path — signed out it offers sign-in', async () => {
|
|
304
|
+
const out = boot({ pathname: '/g/night-shift' });
|
|
305
|
+
await settle();
|
|
306
|
+
assert.equal(out.el('missing').hidden, false);
|
|
307
|
+
assert.equal(out.el('guildBody').hidden, true);
|
|
308
|
+
assert.equal(out.el('missingSignin').hidden, false);
|
|
309
|
+
assert.equal(out.el('missingSignin').getAttribute('href'), '/api/bongos/auth/web/start?return=/g/night-shift&repos=1');
|
|
310
|
+
assert.equal(out.text('missingWhy'), 'There is no public guild at this address. If it is an unlisted guild you belong to, sign in to see it.');
|
|
311
|
+
|
|
312
|
+
const stranger = boot({ pathname: '/g/night-shift', me: ME });
|
|
313
|
+
await settle();
|
|
314
|
+
assert.deepEqual(stranger.guildCalls().map((c) => `${c.method} ${c.path}`), ['GET /guilds/night-shift', 'GET /guilds/night-shift/members']);
|
|
315
|
+
assert.equal(stranger.el('missing').hidden, false);
|
|
316
|
+
assert.equal(stranger.el('missingSignin').hidden, true, 'signed in already');
|
|
317
|
+
assert.equal(stranger.text('missingWhy'), 'There is no guild at this address that you can see. A guild that is unlisted is shown only to its members.');
|
|
318
|
+
|
|
319
|
+
const unknown = boot({ pathname: '/g/no-such-guild', me: ME });
|
|
320
|
+
await settle();
|
|
321
|
+
assert.equal(unknown.text('missingWhy'), stranger.text('missingWhy'), 'an unknown slug and someone else’s unlisted guild read the same');
|
|
322
|
+
|
|
323
|
+
for (const bad of ['/g/-x-', '/g/a', '/g/a_b', '/g/forge-crew/members']) {
|
|
324
|
+
const dom = boot({ pathname: bad, me: ME });
|
|
325
|
+
await settle();
|
|
326
|
+
assert.equal(dom.el('missing').hidden, false, `${bad} is no guild`);
|
|
327
|
+
assert.deepEqual(dom.guildCalls(), [], `${bad}: no guild read for a path that cannot be a slug`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a read that fails is not "no guild here": the alert says to try again', async () => {
|
|
332
|
+
const dom = boot({ answer: () => ({ status: 500, data: null }) });
|
|
333
|
+
await settle();
|
|
334
|
+
assert.equal(dom.el('missing').hidden, true);
|
|
335
|
+
assert.equal(dom.el('pageAlert').className, 'alert on');
|
|
336
|
+
assert.equal(dom.text('pageAlert'), 'Couldn’t read this guild right now — refresh in a moment.');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('signed in, not a member: the public page and nothing of yours', async () => {
|
|
340
|
+
const dom = boot({ me: ME, answer: (m, p) => (p === '/guilds/forge-crew' ? { status: 200, data: PUBLIC() } : NOT_FOUND) });
|
|
341
|
+
await settle();
|
|
342
|
+
assert.equal(dom.el('guildBody').hidden, false);
|
|
343
|
+
assert.equal(dom.el('gYou').hidden, true);
|
|
344
|
+
assert.equal(dom.rows().length, 3);
|
|
345
|
+
assert.equal(dom.text('gMembersSide'), 'The members who chose to be listed on this page.');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
function memberAnswer(state) {
|
|
349
|
+
return (m, p, body) => {
|
|
350
|
+
if (p === '/guilds/forge-crew' && m === 'GET') return { status: 200, data: PUBLIC() };
|
|
351
|
+
if (p === '/guilds/forge-crew/members' && m === 'GET') return { status: 200, data: MEMBERS(state.you, state.visibility) };
|
|
352
|
+
if (p === '/guilds/forge-crew/membership' && m === 'PATCH') {
|
|
353
|
+
if (state.patch) return state.patch(body);
|
|
354
|
+
state.you = { ...state.you, ...body };
|
|
355
|
+
const you = MEMBERS(state.you).you;
|
|
356
|
+
return { status: 200, data: { shown_publicly: you.shown_publicly, counted_in_totals: you.counted_in_totals } };
|
|
357
|
+
}
|
|
358
|
+
return NOT_FOUND;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
test('a member of a public guild who is not on its page: the members’ roster, and the prompt stands in for the pair', async () => {
|
|
363
|
+
const dom = boot({ me: ME, answer: memberAnswer({}) });
|
|
364
|
+
await settle();
|
|
365
|
+
assert.equal(dom.el('gYou').hidden, false);
|
|
366
|
+
assert.equal(dom.text('gKicker'), 'Guild · public');
|
|
367
|
+
assert.equal(dom.text('gMeta'), '5 members · 3 on the public page · since Sep 2026', 'both lists the member can see, each its own length');
|
|
368
|
+
assert.equal(dom.rows().length, 5, 'the members’ roster: everyone, private members included (joining is consent to be seen by the guild)');
|
|
369
|
+
assert.equal(dom.text('gYouSide'), 'You’re a member of this guild. Only you see this, and only you can change it.');
|
|
370
|
+
assert.equal(dom.el('showAsk').hidden, false, 'asked, never assumed (ADR 0336 D4 §3)');
|
|
371
|
+
assert.equal(dom.el('shownBand').hidden, true, 'while the prompt stands it is the control');
|
|
372
|
+
assert.equal(dom.checked('shown'), 'off');
|
|
373
|
+
assert.equal(dom.checked('counted'), 'on');
|
|
374
|
+
assert.equal(dom.text('countWhat'), 'Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. Leave them out of any guild you’d rather not share them with.',
|
|
375
|
+
'the ADR 0337 D4.4 notice, said before the switch is touched');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('the prompt: "show me" sends shown_publicly alone, then re-reads both; the pair takes its place', async () => {
|
|
379
|
+
const state = {};
|
|
380
|
+
const dom = boot({ me: ME, answer: memberAnswer(state) });
|
|
381
|
+
await settle();
|
|
382
|
+
dom.click('askYes');
|
|
383
|
+
await settle();
|
|
384
|
+
const patches = dom.calls.filter((c) => c.method === 'PATCH');
|
|
385
|
+
assert.deepEqual(patches, [{ method: 'PATCH', path: '/guilds/forge-crew/membership', body: { shown_publicly: true } }]);
|
|
386
|
+
const after = dom.guildCalls().slice(dom.guildCalls().indexOf(patches[0]) + 1).map((c) => `${c.method} ${c.path}`);
|
|
387
|
+
assert.deepEqual(after.sort(), ['GET /guilds/forge-crew', 'GET /guilds/forge-crew/members'], 'the public roster and size move with the switch, so both are re-read');
|
|
388
|
+
assert.equal(dom.text('youMsg'), 'Saved — you’re listed on its public page.');
|
|
389
|
+
assert.equal(dom.el('youMsg').className, 'projMsg ok');
|
|
390
|
+
assert.equal(dom.el('showAsk').hidden, true);
|
|
391
|
+
assert.equal(dom.el('shownBand').hidden, false);
|
|
392
|
+
assert.equal(dom.checked('shown'), 'on');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('the prompt: "not now" writes nothing, keeps you off, and is remembered for this guild only', async () => {
|
|
396
|
+
const storage = {};
|
|
397
|
+
const dom = boot({ me: ME, answer: memberAnswer({}), storage });
|
|
398
|
+
await settle();
|
|
399
|
+
dom.click('askNo');
|
|
400
|
+
assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0, 'staying off needs no write');
|
|
401
|
+
assert.equal(dom.el('showAsk').hidden, true);
|
|
402
|
+
assert.equal(dom.el('shownBand').hidden, false);
|
|
403
|
+
assert.equal(dom.text('youMsg'), 'You’re staying off its public page. You can change that here at any time.');
|
|
404
|
+
assert.deepEqual(storage, { 'cb-guild-ask-forge-crew': 'no' });
|
|
405
|
+
|
|
406
|
+
const again = boot({ me: ME, answer: memberAnswer({}), storage });
|
|
407
|
+
await settle();
|
|
408
|
+
assert.equal(again.el('showAsk').hidden, true, 'not asked twice on this browser');
|
|
409
|
+
assert.equal(again.el('shownBand').hidden, false);
|
|
410
|
+
|
|
411
|
+
const blocked = boot({ me: ME, answer: memberAnswer({}), storageThrows: true });
|
|
412
|
+
await settle();
|
|
413
|
+
assert.equal(blocked.el('showAsk').hidden, false, 'storage that throws only means being asked again');
|
|
414
|
+
blocked.click('askNo');
|
|
415
|
+
assert.equal(blocked.el('showAsk').hidden, true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('the switches: nothing changed sends nothing; each save sends only what moved and says it plainly', async () => {
|
|
419
|
+
const state = {};
|
|
420
|
+
const storage = { 'cb-guild-ask-forge-crew': 'no' };
|
|
421
|
+
const dom = boot({ me: ME, answer: memberAnswer(state), storage });
|
|
422
|
+
await settle();
|
|
423
|
+
dom.submit();
|
|
424
|
+
assert.equal(dom.text('youMsg'), 'Nothing has changed.');
|
|
425
|
+
assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0);
|
|
426
|
+
|
|
427
|
+
dom.pick('counted', 'off');
|
|
428
|
+
assert.equal(dom.text('countWhat'), 'Your numbers are left out of this guild’s totals, from its next weekly count.', 'the notice follows the choice before it is saved');
|
|
429
|
+
dom.submit();
|
|
430
|
+
await settle();
|
|
431
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').at(-1).body, { counted_in_totals: false });
|
|
432
|
+
assert.equal(dom.text('youMsg'), 'Saved — your numbers are left out of its totals.');
|
|
433
|
+
|
|
434
|
+
dom.pick('shown', 'on');
|
|
435
|
+
dom.pick('counted', 'on');
|
|
436
|
+
dom.submit();
|
|
437
|
+
await settle();
|
|
438
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').at(-1).body, { shown_publicly: true, counted_in_totals: true });
|
|
439
|
+
assert.equal(dom.text('youMsg'), 'Saved — you’re listed on its public page, and your numbers count toward its totals.');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('a refused or expired save is said, and the buttons come back', async () => {
|
|
443
|
+
const state = { patch: () => ({ status: 400, data: { error: { code: 'validation_failed', message: 'counted_in_totals must be a boolean' } } }) };
|
|
444
|
+
const dom = boot({ me: ME, answer: memberAnswer(state), storage: { 'cb-guild-ask-forge-crew': 'no' } });
|
|
445
|
+
await settle();
|
|
446
|
+
dom.pick('counted', 'off');
|
|
447
|
+
dom.submit();
|
|
448
|
+
await settle();
|
|
449
|
+
assert.equal(dom.text('youMsg'), 'Counted_in_totals must be a boolean.');
|
|
450
|
+
assert.equal(dom.el('youMsg').className, 'projMsg err');
|
|
451
|
+
assert.equal(dom.el('youSave').disabled, false);
|
|
452
|
+
state.patch = () => ({ status: 401, data: null });
|
|
453
|
+
dom.submit();
|
|
454
|
+
await settle();
|
|
455
|
+
assert.equal(dom.text('youMsg'), 'Your session expired — sign in again to save.');
|
|
456
|
+
assert.equal(dom.el('who').hidden, true, 'the chip is repainted signed out');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('an unlisted guild, to its members: no public page, so no pair, no prompt, no shown_publicly sent, and no published record', async () => {
|
|
460
|
+
const answer = (owner) => (m, p, body) => {
|
|
461
|
+
if (p === '/guilds/night-shift' && m === 'GET') return NOT_FOUND;
|
|
462
|
+
if (p === '/guilds/night-shift/members') return { status: 200, data: { ...MEMBERS({ membership_kind: owner ? 'owner' : 'member' }, 'unlisted'), guild: { slug: 'night-shift', name: 'Night Shift', description: '', visibility: 'unlisted' } } };
|
|
463
|
+
if (m === 'PATCH') return { status: 200, data: { shown_publicly: false, counted_in_totals: body.counted_in_totals } };
|
|
464
|
+
return NOT_FOUND;
|
|
465
|
+
};
|
|
466
|
+
const dom = boot({ pathname: '/g/night-shift', me: ME, answer: answer(false) });
|
|
467
|
+
await settle();
|
|
468
|
+
assert.equal(dom.el('guildBody').hidden, false, 'a member reaches their unlisted guild through the own-scope read');
|
|
469
|
+
assert.equal(dom.text('gKicker'), 'Guild · unlisted');
|
|
470
|
+
assert.equal(dom.text('gMeta'), '5 members', 'no public page to count, and no founding date in the members’ read');
|
|
471
|
+
assert.equal(dom.el('gDesc').hidden, true);
|
|
472
|
+
assert.equal(dom.el('showAsk').hidden, true);
|
|
473
|
+
assert.equal(dom.el('shownBand').hidden, true);
|
|
474
|
+
assert.equal(dom.text('shownUnlisted'), 'This guild is unlisted, so it has no public page. If it is made public, you’ll be asked here before you’re listed on it.');
|
|
475
|
+
assert.equal(dom.el('gFigs').hidden, true);
|
|
476
|
+
assert.equal(dom.text('gRecordNote'), 'This guild is unlisted, so its record isn’t published anywhere.');
|
|
477
|
+
dom.pick('shown', 'on');
|
|
478
|
+
dom.pick('counted', 'off');
|
|
479
|
+
dom.submit();
|
|
480
|
+
await settle();
|
|
481
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'PATCH').map((c) => c.body), [{ counted_in_totals: false }],
|
|
482
|
+
'consent to a page that does not exist is never sent (ADR 0336 D4 §3: consent is to what the card showed)');
|
|
483
|
+
|
|
484
|
+
const owner = boot({ pathname: '/g/night-shift', me: ME, answer: answer(true) });
|
|
485
|
+
await settle();
|
|
486
|
+
assert.equal(owner.text('gYouSide'), 'You own this guild. Only you see this, and only you can change it.');
|
|
487
|
+
assert.equal(owner.text('shownUnlisted'), 'This guild is unlisted, so it has no public page. If you make it public, you’ll be listed on it as its owner, and each member will be asked before they are.',
|
|
488
|
+
'a visibility change flips only the owner’s own flag');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ── 5. the route ─────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
const { buildPlatformApp } = require('../src/platform-server.js');
|
|
494
|
+
const modules = require('../src/modules.js');
|
|
495
|
+
let server;
|
|
496
|
+
let port;
|
|
497
|
+
before(async () => {
|
|
498
|
+
server = http.createServer(buildPlatformApp());
|
|
499
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
500
|
+
port = server.address().port;
|
|
501
|
+
});
|
|
502
|
+
after(async () => {
|
|
503
|
+
modules.setModuleOverrides({});
|
|
504
|
+
await new Promise((resolve) => server.close(resolve));
|
|
505
|
+
});
|
|
506
|
+
function get(pathname, host) {
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
|
|
509
|
+
let body = '';
|
|
510
|
+
res.on('data', (c) => (body += c));
|
|
511
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
512
|
+
}).on('error', reject);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
test('GET /g/<slug>: served on the hub’s apex, stamped; never on a child, the builders host or the status host', async () => {
|
|
517
|
+
modules.setModuleOverrides({ 'platform-identity': false });
|
|
518
|
+
const child = await get('/g/forge-crew', 'game.test.local');
|
|
519
|
+
assert.equal(child.status, 404, 'a child instance has no guilds, so no page');
|
|
520
|
+
modules.setModuleOverrides({ 'platform-identity': true });
|
|
521
|
+
for (const p of ['/g/forge-crew', '/g/Forge-Crew', '/g/forge-crew/', '/g/no-such-guild']) {
|
|
522
|
+
const hub = await get(p, 'game.test.local');
|
|
523
|
+
assert.equal(hub.status, 200, `${p}: one shell for every well-formed slug — the guild routes decide what exists`);
|
|
524
|
+
assert.match(hub.body, /— Guild<\/title>/);
|
|
525
|
+
assert.match(hub.body, /window\.__BRANDING__=/);
|
|
526
|
+
assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/);
|
|
527
|
+
assert.match(hub.body, /<script src="\/assets\/hub-bar\.js\?v=[0-9a-f]{8}"><\/script>/, 'the shared bar script is content-stamped');
|
|
528
|
+
}
|
|
529
|
+
for (const p of ['/g/a', '/g/a_b', '/g/forge-crew/members', '/g/' + 'a'.repeat(40)]) {
|
|
530
|
+
const bad = await get(p, 'game.test.local');
|
|
531
|
+
assert.equal(bad.status, 404, `${p} cannot be a slug, so it is the 404 tail`);
|
|
532
|
+
}
|
|
533
|
+
for (const host of ['builders.test.local', 'status.test.local']) {
|
|
534
|
+
const other = await get('/g/forge-crew', host);
|
|
535
|
+
assert.doesNotMatch(other.body, /— Guild<\/title>/, `${host} must not serve the guild page`);
|
|
536
|
+
}
|
|
537
|
+
});
|