@bongos/core 1.21.62 → 1.21.63
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 +40 -30
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +7 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +301 -301
- package/docs/copy-registry.json +342 -342
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +687 -670
- package/modules/platform-identity/guilds.js +32 -0
- package/modules/platform-identity/routes/guilds.js +10 -3
- package/modules/public-landing/public/assets/cosmos.css +29 -3
- package/modules/public-landing/public/projects.html +294 -4
- package/modules/public-landing/public/projects.probes.json +18 -0
- package/modules/public-landing/public/projects.states.json +3 -0
- package/modules/ui-design/kit/fixtures/guilds.json +7 -0
- package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
- package/modules/ui-design/kit/serve.js +41 -0
- 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/tests/guilds_api.mjs +31 -0
- package/tests/guilds_db.mjs +7 -0
- package/tests/hub_engagement_cards.mjs +9 -6
- package/tests/hub_guild_cards.mjs +392 -0
package/tests/guilds_api.mjs
CHANGED
|
@@ -578,6 +578,37 @@ test('the owner\'s invite list names only invitees THIS owner could address (a t
|
|
|
578
578
|
'public, or connected to the viewing owner — the addressableAccount rule, reusing the one symmetric-edge fragment');
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
+
test('GET /me/guild-requests carries the requests to EVERY guild I own in one statement keyed on the session, each naming its guild (task 1004583)', async () => {
|
|
582
|
+
const OWNED = (t) => /WHERE r\.kind = 'request' AND r\.status = 'pending'/.test(t);
|
|
583
|
+
on(base((t) => (OWNED(t) && /^\s*SELECT/.test(t)
|
|
584
|
+
? result([{ id: '601', created_at: 'c', seen_at: null, slug: 'night-shift', name: 'Night Shift', handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null },
|
|
585
|
+
{ id: '602', created_at: 'c', seen_at: 's', slug: 'lamp-room', name: 'Lamp Room', handle: 'kai-lowe', display_name: null, avatar_url: null }])
|
|
586
|
+
: null)));
|
|
587
|
+
const res = await call('GET', '/api/bongos/me/guild-requests');
|
|
588
|
+
assert.equal(res.status, 200);
|
|
589
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
590
|
+
assert.deepEqual(res.json.owner_incoming[0], { id: '601', created_at: 'c', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } });
|
|
591
|
+
assert.equal(res.json.owner_incoming_count, res.json.owner_incoming.length);
|
|
592
|
+
assert.equal(res.json.incoming_count, res.json.incoming.length, 'the invite half is unchanged');
|
|
593
|
+
|
|
594
|
+
const reads = calls.filter((c) => OWNED(c.text) && /^\s*SELECT/.test(c.text));
|
|
595
|
+
assert.equal(reads.length, 1, 'one statement for every guild I own — never one per guild');
|
|
596
|
+
const [read] = reads;
|
|
597
|
+
assert.equal(read.params[0], ME, 'keyed on the SESSION’s github_id');
|
|
598
|
+
assert.equal(read.params[2], guilds.LIST_MAX, 'capped as every queue is');
|
|
599
|
+
assert.ok(read.text.includes(require('../modules/platform-identity/account-visibility.js').accountActiveSql('pa')),
|
|
600
|
+
'a requester floors on an active account, as the per-guild queue does');
|
|
601
|
+
assert.match(read.text, /o\.guild_id = r\.guild_id AND o\.github_id = \$1::bigint\s+AND o\.membership_kind = 'owner'/, 'the owner test, per row, in the statement');
|
|
602
|
+
assert.match(read.text, /JOIN platform_identity_accounts pa ON pa\.github_id = r\.github_id/);
|
|
603
|
+
assert.doesNotMatch(read.text, /github_login|pa\.github_id AS|r\.github_id AS/, 'no GitHub identity leaves the module');
|
|
604
|
+
assert.match(read.text, /pa\.handle, pa\.display_name, pa\.avatar_url/, 'the person columns a guild list carries, no more');
|
|
605
|
+
|
|
606
|
+
const stamp = calls.find((c) => /^\s*UPDATE platform_identity_guild_requests r SET seen_at = now\(\)/.test(c.text));
|
|
607
|
+
assert.ok(stamp, 'the unseen request is stamped, as the per-guild read stamps it');
|
|
608
|
+
assert.deepEqual(stamp.params, [['601'], ME], 'only the unseen row, by the same owner');
|
|
609
|
+
assert.match(stamp.text, /r\.kind = 'request'[\s\S]*o\.github_id = \$2::bigint\s+AND o\.membership_kind = 'owner'/, 'the stamp re-asserts the owner test');
|
|
610
|
+
});
|
|
611
|
+
|
|
581
612
|
// ===========================================================================
|
|
582
613
|
// 7. Nothing here is authority; counts are lengths
|
|
583
614
|
// ===========================================================================
|
package/tests/guilds_db.mjs
CHANGED
|
@@ -160,6 +160,13 @@ try {
|
|
|
160
160
|
'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
|
|
161
161
|
|
|
162
162
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
|
|
163
|
+
const ownerQ = await guilds.listOwnedRequests({ githubId: OWNER }, { pool });
|
|
164
|
+
const bobAsk = ownerQ.find((r) => r.handle === 'r24bob');
|
|
165
|
+
ok(bobAsk && bobAsk.guild.slug === 'r24-pub' && !('github_id' in bobAsk) && !('kind' in bobAsk),
|
|
166
|
+
'the owner\'s ONE read across every guild they own carries Bob\'s request, naming its guild, by handle never github_id (task 1004583)');
|
|
167
|
+
ok((await guilds.listOwnedRequests({ githubId: ALICE }, { pool })).every((r) => r.guild.slug !== 'r24-pub'),
|
|
168
|
+
'a member who is not the owner reads none of its requests (the owner gate is in the statement)');
|
|
169
|
+
ok((await openRows('r24-pub', BOB))[0].seen_at !== null, 'reading it stamps seen_at on the request, as the per-guild queue does');
|
|
163
170
|
ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
|
|
164
171
|
const before = (await openRows('r24-pub', BOB)).length;
|
|
165
172
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
|
|
@@ -37,11 +37,13 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
37
37
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
38
|
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
39
|
|
|
40
|
-
// the rows region: mineEl/mineRow, the
|
|
40
|
+
// the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
|
|
41
|
+
// through the guild invite and request cards (task 1004583), which loadInvites
|
|
42
|
+
// also reads; tests/hub_guild_cards.mjs drives those
|
|
41
43
|
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
42
|
-
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function
|
|
44
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
43
45
|
assert.ok(START > 0, 'the region starts at mineEl');
|
|
44
|
-
assert.ok(END > START, 'and ends with
|
|
46
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
45
47
|
const REGION = SCRIPT.slice(START, END);
|
|
46
48
|
for (const fn of ['function mineRow(', 'function renderInvitesList(', 'function paintInvitesSection(', 'function loadInvites(',
|
|
47
49
|
'function engageLine(', 'function engageRow(', 'function renderEngagements(', 'function loadEngagements(']) {
|
|
@@ -81,7 +83,7 @@ const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested'
|
|
|
81
83
|
|
|
82
84
|
function boot({ answers = [] } = {}) {
|
|
83
85
|
const ids = new Map();
|
|
84
|
-
for (const id of ['mineInvites', 'invitesList', 'engageList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
86
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
85
87
|
ids.get('mineInvites').hidden = true;
|
|
86
88
|
const requests = [];
|
|
87
89
|
const queued = answers.slice();
|
|
@@ -173,7 +175,8 @@ test('loadInvites reads both lists, and a guild read that fails is silent', asyn
|
|
|
173
175
|
] });
|
|
174
176
|
await ui.sandbox.loadInvites();
|
|
175
177
|
await ui.settle();
|
|
176
|
-
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
178
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
179
|
+
['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
|
|
177
180
|
for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
|
|
178
181
|
assert.equal(ui.cards().length, 0);
|
|
179
182
|
assert.equal(ui.section().hidden, true);
|
|
@@ -291,7 +294,7 @@ test('the cards are a real list inside the Invitations section, built with creat
|
|
|
291
294
|
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
|
|
292
295
|
assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
|
|
293
296
|
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
294
|
-
assert.match(COSMOS, /\.mineRows:not\(:empty\)
|
|
297
|
+
assert.match(COSMOS, /\.mineRows:not\(:empty\)~\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between any two lists that have rows');
|
|
295
298
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
|
|
296
299
|
});
|
|
297
300
|
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
|
|
2
|
+
// you own, in /projects?view=mine's Invitations, EXECUTED (task 1004583; ADR 0336
|
|
3
|
+
// D3/D4 §3, ADR 0337 D4.4).
|
|
4
|
+
//
|
|
5
|
+
// R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
|
|
6
|
+
// /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
|
|
7
|
+
// any UI called them. These cards do. What this pins:
|
|
8
|
+
// • an invite card tells the member, BEFORE they accept, that their numbers count
|
|
9
|
+
// toward the guild's rounded totals, in guild.html's own words (the two pages
|
|
10
|
+
// are held to one sentence here), and offers the counted switch right there;
|
|
11
|
+
// picking "Leave them out" repaints the notice and sends nothing
|
|
12
|
+
// • it says what joining shows (a public guild lists you; an unlisted one has no
|
|
13
|
+
// page) and links a public guild's page only
|
|
14
|
+
// • Accept sends exactly { visibility (the card's), counted_in_totals } on the
|
|
15
|
+
// cookie session; a 409 guild_visibility_changed re-draws the card for the
|
|
16
|
+
// guild as it is now, and the next accept sends that
|
|
17
|
+
// • an ended invite or request leaves nothing pressable; an expired session and a
|
|
18
|
+
// dead network are said as such, and the card stays re-tryable
|
|
19
|
+
// • Decline is a body-less POST whose 204 drops the row (the section hides with
|
|
20
|
+
// its last row); a failure keeps the row and says so
|
|
21
|
+
// • the owner's card names who asked, with the handle once, and accepts with no
|
|
22
|
+
// body — the requester's switch rode their request
|
|
23
|
+
// • ONE read feeds both lists — GET /me/guild-requests's incoming and
|
|
24
|
+
// owner_incoming — never one request per guild you own; a failed read is silent
|
|
25
|
+
// • a row the page cannot answer is dropped, never drawn with a button that fails
|
|
26
|
+
// • textContent / createElement only
|
|
27
|
+
//
|
|
28
|
+
// The idiom is tests/hub_engagement_cards.mjs: lift the region out of
|
|
29
|
+
// projects.html, run it in a node:vm under a DOM stub, drive real clicks, and
|
|
30
|
+
// assert the slice boundaries so a rename can never leave this executing nothing.
|
|
31
|
+
//
|
|
32
|
+
// DB-free. Run: node --test tests/hub_guild_cards.mjs
|
|
33
|
+
|
|
34
|
+
import test from 'node:test';
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import vm from 'node:vm';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
43
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
45
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
46
|
+
|
|
47
|
+
// the rows region, mineEl through the guild cards' loaders
|
|
48
|
+
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
49
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
50
|
+
assert.ok(START > 0, 'the region starts at mineEl');
|
|
51
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
52
|
+
const REGION = SCRIPT.slice(START, END);
|
|
53
|
+
for (const fn of ['function paintInvitesSection(', 'function sessionExpired(', 'function guildPost(', 'function guildDecline(',
|
|
54
|
+
'function guildInviteRow(', 'function renderGuildInvites(', 'function guildRequestRow(', 'function renderGuildRequests(',
|
|
55
|
+
'function loadGuildQueue(']) {
|
|
56
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
57
|
+
}
|
|
58
|
+
const CARDS = REGION.slice(REGION.indexOf('var GUILD_COUNTED_ON'));
|
|
59
|
+
assert.ok(CARDS.length > 1000, 'the guild cards are findable');
|
|
60
|
+
|
|
61
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
62
|
+
function el(tag) {
|
|
63
|
+
const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, checked: false, value: '', attrs: {}, _on: {}, parent: null };
|
|
64
|
+
Object.defineProperty(n, 'textContent', {
|
|
65
|
+
get() { return n._text + n.children.map((c) => c.textContent).join(''); },
|
|
66
|
+
set(v) { n._text = String(v); n.children = []; },
|
|
67
|
+
});
|
|
68
|
+
n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
|
|
69
|
+
n.remove = () => { if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); };
|
|
70
|
+
n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
|
|
71
|
+
n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
|
|
72
|
+
n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
|
|
73
|
+
n.querySelector = (sel) => {
|
|
74
|
+
const cls = sel.replace(/^\./, '');
|
|
75
|
+
const walk = (x) => { for (const c of x.children) { if (String(c.className).split(' ').includes(cls)) return c; const d = walk(c); if (d) return d; } return null; };
|
|
76
|
+
return walk(n);
|
|
77
|
+
};
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
const all = (n, pred, out = []) => { for (const c of n.children || []) { if (pred(c)) out.push(c); all(c, pred, out); } return out; };
|
|
81
|
+
|
|
82
|
+
const OPEN_HANDS = { id: '501', created_at: '2026-10-04T15:10:00.000Z', seen_at: null, slug: 'open-hands', name: 'Open Hands', description: 'A newer guild for first-time builders.', visibility: 'public' };
|
|
83
|
+
const QUIET_ROOM = { id: '502', created_at: '2026-10-03T08:45:00.000Z', seen_at: null, slug: 'quiet-room', name: 'Quiet Room', description: '', visibility: 'unlisted' };
|
|
84
|
+
const TOMAS = { id: '601', created_at: '2026-10-04T19:20:00.000Z', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
85
|
+
const KAI = { id: '602', created_at: '2026-10-02T11:05:00.000Z', seen_at: null, handle: 'kai-lowe', display_name: null, avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
86
|
+
const OK = { ok: true, status: 200, body: { status: 'accepted' } };
|
|
87
|
+
const GONE = { ok: false, status: 204, body: undefined };
|
|
88
|
+
|
|
89
|
+
function boot({ answers = [] } = {}) {
|
|
90
|
+
const ids = new Map();
|
|
91
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
92
|
+
ids.get('mineInvites').hidden = true;
|
|
93
|
+
const requests = [];
|
|
94
|
+
const queued = answers.slice();
|
|
95
|
+
const fetch = (url, init = {}) => {
|
|
96
|
+
requests.push({ url, method: init.method || 'GET', body: init.body, headers: init.headers, credentials: init.credentials });
|
|
97
|
+
const a = typeof queued[0] === 'function' ? queued.shift()(url) : (queued.shift() || OK);
|
|
98
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
99
|
+
const status = a === GONE ? 204 : a.status;
|
|
100
|
+
return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
|
|
101
|
+
};
|
|
102
|
+
const document = { createElement: el, createTextNode: (t) => ({ tag: '#text', textContent: String(t), children: [], className: '' }) };
|
|
103
|
+
const sandbox = {
|
|
104
|
+
document, fetch, console, Promise, JSON, String, Object, Array, URL,
|
|
105
|
+
$: (id) => ids.get(id) || null,
|
|
106
|
+
API: '/api/bongos',
|
|
107
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
108
|
+
safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
|
|
109
|
+
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
110
|
+
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
|
|
111
|
+
};
|
|
112
|
+
vm.createContext(sandbox);
|
|
113
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
|
|
114
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
115
|
+
const invites = () => ids.get('guildInviteList').children;
|
|
116
|
+
const asks = () => ids.get('guildRequestList').children;
|
|
117
|
+
const part = (card, cls) => card.querySelector('.' + cls);
|
|
118
|
+
const actions = (card) => part(card, 'mineRow__actions').children;
|
|
119
|
+
const button = (card, label) => actions(card).find((b) => b.tag === 'button' && b.textContent === label);
|
|
120
|
+
const radios = (card) => all(card, (n) => n.tag === 'input' && n.type === 'radio');
|
|
121
|
+
const msg = (card) => part(card, 'projMsg');
|
|
122
|
+
const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
|
|
123
|
+
const pick = async (card, value) => {
|
|
124
|
+
for (const r of radios(card)) r.checked = r.value === value;
|
|
125
|
+
for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
|
|
126
|
+
await settle();
|
|
127
|
+
};
|
|
128
|
+
return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, section: () => ids.get('mineInvites') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── the D4.4 notice: one sentence on two pages ───────────────────────────────
|
|
132
|
+
|
|
133
|
+
test('the notice is guild.html’s own words, both ways, byte for byte', () => {
|
|
134
|
+
const said = (src, re) => { const m = src.match(re); assert.ok(m, `found ${re}`); return vm.runInNewContext(m[1]); };
|
|
135
|
+
const onGuild = said(GUILD_PAGE, /\? 'Your numbers are left out[^']*'\s*:\s*('Your numbers count[\s\S]*?share them with\.');/);
|
|
136
|
+
const offGuild = said(GUILD_PAGE, /picked\('counted'\) === 'off'\s*\?\s*('[^']*')/);
|
|
137
|
+
const onHub = said(HUB, /var GUILD_COUNTED_ON = ('[\s\S]*?share them with\.');/);
|
|
138
|
+
const offHub = said(HUB, /var GUILD_COUNTED_OFF = ('[^']*');/);
|
|
139
|
+
assert.equal(onHub, onGuild);
|
|
140
|
+
assert.equal(offHub, offGuild);
|
|
141
|
+
assert.match(onHub, /count toward this guild’s rounded totals/, 'ADR 0337 D4.4’s words');
|
|
142
|
+
// the switch's own labels are the guild page's too
|
|
143
|
+
for (const words of ['Count my numbers', 'They add to this guild’s totals.', 'Leave them out', 'The totals add up everyone else.', 'In this guild’s totals']) {
|
|
144
|
+
assert.ok(GUILD_PAGE.includes(words) && CARDS.includes(words), `"${words}" on both pages`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── the invite card ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
test('an invite card says what joining shows, offers the switch set to count, and the notice BEFORE any accept', () => {
|
|
151
|
+
const ui = boot();
|
|
152
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
153
|
+
assert.equal(ui.requests.length, 0, 'drawing sends nothing');
|
|
154
|
+
assert.equal(ui.section().hidden, false);
|
|
155
|
+
const [open, quiet] = ui.invites();
|
|
156
|
+
assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
|
|
157
|
+
assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
|
|
158
|
+
assert.equal(ui.part(quiet, 'guildRow__desc'), null, 'an empty description draws nothing');
|
|
159
|
+
assert.match(ui.part(open, 'mineRow__hint').textContent, /^It’s public: joining lists your name and picture on its page/);
|
|
160
|
+
assert.match(ui.part(quiet, 'mineRow__hint').textContent, /^It’s unlisted: it has no public page/);
|
|
161
|
+
const link = ui.actions(open).find((a) => a.tag === 'a');
|
|
162
|
+
assert.equal(link.href, '/g/open-hands');
|
|
163
|
+
assert.equal(link.hidden, false);
|
|
164
|
+
assert.equal(ui.actions(quiet).find((a) => a.tag === 'a').hidden, true, 'an unlisted guild has no page to show an invitee');
|
|
165
|
+
for (const card of [open, quiet]) {
|
|
166
|
+
const r = ui.radios(card);
|
|
167
|
+
assert.deepEqual(r.map((x) => [x.value, x.checked]), [['on', true], ['off', false]], 'counted by default, as the membership is');
|
|
168
|
+
assert.equal(new Set(r.map((x) => x.name)).size, 1, 'one group per card');
|
|
169
|
+
const group = ui.part(card, 'guildCount__opts');
|
|
170
|
+
assert.equal(group.getAttribute('role'), 'radiogroup');
|
|
171
|
+
assert.equal(group.getAttribute('aria-labelledby'), ui.part(card, 'guildCount__label').id);
|
|
172
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward this guild’s rounded totals/);
|
|
173
|
+
assert.deepEqual(ui.actions(card).filter((b) => b.tag === 'button').map((b) => b.textContent), ['Accept', 'Decline']);
|
|
174
|
+
}
|
|
175
|
+
assert.notEqual(ui.radios(open)[0].name, ui.radios(quiet)[0].name, 'two cards never share a group');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('picking Leave them out says what that means before anything is sent; Count brings the notice back', async () => {
|
|
179
|
+
const ui = boot();
|
|
180
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
181
|
+
const card = ui.invites()[0];
|
|
182
|
+
await ui.pick(card, 'off');
|
|
183
|
+
assert.equal(ui.part(card, 'guildCount__what').textContent, 'Your numbers are left out of this guild’s totals, from its next weekly count.');
|
|
184
|
+
await ui.pick(card, 'on');
|
|
185
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward/);
|
|
186
|
+
assert.equal(ui.requests.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Accept sends exactly the card’s visibility and the switch, on the cookie session', async () => {
|
|
190
|
+
const ui = boot();
|
|
191
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
192
|
+
const [open, quiet] = ui.invites();
|
|
193
|
+
await ui.click(ui.button(open, 'Accept'));
|
|
194
|
+
await ui.pick(quiet, 'off');
|
|
195
|
+
await ui.click(ui.button(quiet, 'Accept'));
|
|
196
|
+
assert.equal(ui.requests.length, 2);
|
|
197
|
+
const [a, b] = ui.requests;
|
|
198
|
+
assert.equal(a.url, '/api/bongos/guild-requests/501/accept');
|
|
199
|
+
assert.equal(a.method, 'POST');
|
|
200
|
+
assert.equal(a.credentials, 'same-origin');
|
|
201
|
+
assert.equal(a.headers['Content-Type'], 'application/json');
|
|
202
|
+
assert.deepEqual(JSON.parse(a.body), { visibility: 'public', counted_in_totals: true });
|
|
203
|
+
assert.equal(b.url, '/api/bongos/guild-requests/502/accept');
|
|
204
|
+
assert.deepEqual(JSON.parse(b.body), { visibility: 'unlisted', counted_in_totals: false });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('accepted: Joined and no longer the accent, nothing left to press, and the line says where your numbers went', async () => {
|
|
208
|
+
const ui = boot();
|
|
209
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
210
|
+
const card = ui.invites()[0];
|
|
211
|
+
await ui.pick(card, 'off');
|
|
212
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
213
|
+
const joined = ui.button(card, 'Joined');
|
|
214
|
+
assert.ok(joined, 'the button says so');
|
|
215
|
+
assert.equal(joined.disabled, true);
|
|
216
|
+
assert.equal(joined.className, 'btn small ghost');
|
|
217
|
+
assert.equal(ui.button(card, 'Decline').hidden, true);
|
|
218
|
+
assert.ok(ui.radios(card).every((r) => r.disabled), 'the switch is set; it moves on the guild page now');
|
|
219
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false, 'a member reaches even an unlisted guild’s page');
|
|
220
|
+
assert.equal(ui.msg(card).className, 'projMsg ok');
|
|
221
|
+
assert.equal(ui.msg(card).textContent, 'You’ve joined Quiet Room, and your numbers are left out of its totals.');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('409 guild_visibility_changed: the card re-draws for the guild as it is now, and the next accept sends that', async () => {
|
|
225
|
+
const ui = boot({ answers: [
|
|
226
|
+
{ ok: false, status: 409, body: { error: { code: 'guild_visibility_changed', message: 'guild_visibility_changed', details: { visibility: 'public' } } } },
|
|
227
|
+
OK,
|
|
228
|
+
] });
|
|
229
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
230
|
+
const card = ui.invites()[0];
|
|
231
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
232
|
+
assert.match(ui.part(card, 'mineRow__hint').textContent, /^It’s public:/, 'the card now says what joining shows');
|
|
233
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false);
|
|
234
|
+
assert.equal(ui.msg(card).textContent, 'This guild changed since the card was drawn: it’s public now. Read it again, then accept.');
|
|
235
|
+
assert.equal(ui.button(card, 'Accept').disabled, false, 'a fresh decision, not a dead end');
|
|
236
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
237
|
+
assert.deepEqual(JSON.parse(ui.requests[1].body), { visibility: 'public', counted_in_totals: true });
|
|
238
|
+
assert.ok(ui.button(card, 'Joined'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('an ended invite leaves nothing pressable; an expired session offers a way back; a dead network is said, and both stay re-tryable', async () => {
|
|
242
|
+
const gone = boot({ answers: [{ ok: false, status: 404, body: { error: { code: 'guild_request_not_found' } } }] });
|
|
243
|
+
gone.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
244
|
+
let card = gone.invites()[0];
|
|
245
|
+
await gone.click(gone.button(card, 'Accept'));
|
|
246
|
+
assert.ok(gone.actions(card).filter((b) => b.tag === 'button').every((b) => b.hidden));
|
|
247
|
+
assert.ok(gone.radios(card).every((r) => r.disabled));
|
|
248
|
+
assert.equal(gone.msg(card).textContent, 'That invite has ended — it was withdrawn, or it ran out.');
|
|
249
|
+
|
|
250
|
+
const out = boot({ answers: [{ ok: false, status: 401, body: { error: { code: 'unauthenticated' } } }, { reject: true }] });
|
|
251
|
+
out.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
252
|
+
card = out.invites()[0];
|
|
253
|
+
await out.click(out.button(card, 'Accept'));
|
|
254
|
+
const a = out.msg(card).children.find((c) => c.tag === 'a');
|
|
255
|
+
assert.ok(a, 'a sign-in link, built as an element');
|
|
256
|
+
assert.equal(a.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1');
|
|
257
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
258
|
+
await out.click(out.button(card, 'Accept'));
|
|
259
|
+
assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
260
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
261
|
+
assert.equal(out.button(card, 'Decline').disabled, false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('Decline is a body-less POST whose 204 drops the row, and the last one hides the section; a failure keeps it', async () => {
|
|
265
|
+
const ui = boot({ answers: [GONE, { ok: false, status: 500, body: {} }] });
|
|
266
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
267
|
+
ui.sandbox.renderGuildRequests([TOMAS]);
|
|
268
|
+
await ui.click(ui.button(ui.invites()[0], 'Decline'));
|
|
269
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/501/decline');
|
|
270
|
+
assert.equal(ui.requests[0].method, 'POST');
|
|
271
|
+
assert.equal(ui.requests[0].body, undefined, 'nothing to send');
|
|
272
|
+
assert.equal(ui.invites().length, 0);
|
|
273
|
+
assert.equal(ui.section().hidden, false, 'the owner’s card still stands');
|
|
274
|
+
const ask = ui.asks()[0];
|
|
275
|
+
await ui.click(ui.button(ask, 'Decline'));
|
|
276
|
+
assert.equal(ui.asks().length, 1, 'kept on a failure');
|
|
277
|
+
assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
|
|
278
|
+
assert.equal(ui.button(ask, 'Decline').disabled, false);
|
|
279
|
+
ui.sandbox.renderGuildRequests([]);
|
|
280
|
+
assert.equal(ui.section().hidden, true, 'no row in any list hides the section');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── the owner's card ─────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
test('a join request names who asked, with the handle once, and accepts with no body', async () => {
|
|
286
|
+
const ui = boot();
|
|
287
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
288
|
+
const [tomas, kai] = ui.asks();
|
|
289
|
+
assert.equal(ui.part(tomas, 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift');
|
|
290
|
+
assert.equal(ui.part(tomas, 'mineRow__host').textContent, '@tomas-ek');
|
|
291
|
+
assert.equal(ui.part(kai, 'mineRow__name').textContent, '@kai-lowe asked to join Night Shift');
|
|
292
|
+
assert.equal(ui.part(kai, 'mineRow__host'), null, 'never the handle twice');
|
|
293
|
+
assert.equal(ui.part(tomas, 'guildCount'), null, 'the requester’s switch rode their request: none here');
|
|
294
|
+
assert.equal(ui.part(tomas, 'mineRow__hint').textContent, 'Accepting makes them a member. A decline is never told to them.');
|
|
295
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
296
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/601/accept');
|
|
297
|
+
assert.equal(ui.requests[0].body, undefined, 'an owner cannot set the requester’s switch');
|
|
298
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
299
|
+
assert.equal(ui.button(tomas, 'Accepted').className, 'btn small ghost');
|
|
300
|
+
assert.equal(ui.button(tomas, 'Decline').hidden, true);
|
|
301
|
+
assert.equal(ui.msg(tomas).textContent, 'Tomás Ek is in Night Shift now.');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('an ended request leaves nothing pressable; an expired session and a dead network keep the card re-tryable', async () => {
|
|
305
|
+
const ui = boot({ answers: [{ ok: false, status: 404, body: {} }, { ok: false, status: 401, body: {} }, { reject: true }] });
|
|
306
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
307
|
+
const [tomas, kai] = ui.asks();
|
|
308
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
309
|
+
assert.ok(ui.actions(tomas).every((b) => b.hidden));
|
|
310
|
+
assert.equal(ui.msg(tomas).textContent, 'That request has ended — it was withdrawn, it ran out, or their account is closed.');
|
|
311
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
312
|
+
assert.ok(ui.msg(kai).children.find((c) => c.tag === 'a'), 'sign in again');
|
|
313
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
314
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
315
|
+
assert.equal(ui.msg(kai).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
316
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── the reads ────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
test('ONE read feeds both lists — the invites to you and the requests to every guild you own — never one per guild', async () => {
|
|
322
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: {
|
|
323
|
+
incoming: [OPEN_HANDS], outgoing: [{ id: '9', slug: 'x' }], owner_incoming: [TOMAS, KAI],
|
|
324
|
+
incoming_count: 1, outgoing_count: 1, owner_incoming_count: 2,
|
|
325
|
+
} }] });
|
|
326
|
+
await ui.sandbox.loadGuildQueue();
|
|
327
|
+
await ui.settle();
|
|
328
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`), ['GET /api/bongos/me/guild-requests']);
|
|
329
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
330
|
+
assert.equal(ui.invites().length, 1, 'incoming only: the requests YOU sent are not cards here');
|
|
331
|
+
assert.equal(ui.asks().length, 2);
|
|
332
|
+
assert.equal(ui.part(ui.asks()[0], 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift', 'each row names its own guild');
|
|
333
|
+
assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('a failed or empty read is silent: no cards, and the section stays hidden', async () => {
|
|
337
|
+
for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
|
|
338
|
+
const ui = boot({ answers: [answer] });
|
|
339
|
+
await ui.sandbox.loadGuildQueue();
|
|
340
|
+
await ui.settle();
|
|
341
|
+
assert.equal(ui.invites().length + ui.asks().length, 0);
|
|
342
|
+
assert.equal(ui.section().hidden, true);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('a row the page cannot answer is dropped, never drawn with a button that fails', () => {
|
|
347
|
+
const ui = boot();
|
|
348
|
+
ui.sandbox.renderGuildInvites([
|
|
349
|
+
{ ...OPEN_HANDS, id: 'abc' }, { ...OPEN_HANDS, id: undefined }, { ...OPEN_HANDS, slug: '' },
|
|
350
|
+
{ ...OPEN_HANDS, visibility: 'secret' }, null, QUIET_ROOM,
|
|
351
|
+
]);
|
|
352
|
+
assert.deepEqual(ui.invites().map((c) => ui.part(c, 'mineRow__name').textContent), ['You’re invited to join Quiet Room']);
|
|
353
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '1x' }, { ...TOMAS, handle: '' }, { ...TOMAS, guild: null }, KAI]);
|
|
354
|
+
assert.equal(ui.asks().length, 1);
|
|
355
|
+
// within the bigint range the route checks (routes/guilds.js requestId), and no further
|
|
356
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '9223372036854775808' }, { ...KAI, id: '9223372036854775807' }]);
|
|
357
|
+
assert.deepEqual(ui.asks().map((c) => ui.part(c, 'mineRow__name').textContent), ['@kai-lowe asked to join Night Shift']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
361
|
+
|
|
362
|
+
test('the cards are real lists in the Invitations section, built with createElement / textContent only', () => {
|
|
363
|
+
const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
364
|
+
const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
|
|
365
|
+
assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
|
|
366
|
+
assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
|
|
367
|
+
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
368
|
+
assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\);/, 'the mine view reads them with the other invitations');
|
|
369
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
370
|
+
for (const rule of ['.guildCount__opts{', '.guildCount__opt:has(input:checked) b::before{', '.guildCount__opt:focus-within{', '.guildRow--invite .mineRow__actions{align-self:flex-end;}']) {
|
|
371
|
+
assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
|
|
372
|
+
}
|
|
373
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /guildCount|guildRow/, 'styled in cosmos.css, not in the page head');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('the kit pictures the cards, and the stub answers the four calls as the routes do', () => {
|
|
377
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
378
|
+
for (const s of ['in-mine-guild-cards', 'in-mine-guild-count-off', 'in-mine-guild-joined']) assert.ok(states[s], `kit state ${s}`);
|
|
379
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes.map((p) => p.name);
|
|
380
|
+
assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
|
|
381
|
+
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
382
|
+
assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);
|
|
383
|
+
assert.match(serve, /owner_incoming: owner, owner_incoming_count: owner\.length/, 'the route’s one-read shape, owner rows included');
|
|
384
|
+
assert.match(serve, /'guild_card_visibility_required'/, 'an invitee accept with no visibility is refused, as the route does');
|
|
385
|
+
assert.match(serve, /code: 'guild_visibility_changed'[^\n]*details: \{ visibility: now \}/, 'and one that differs is the 409 with the guild as it is now');
|
|
386
|
+
assert.match(serve, /res\.writeHead\(204/, 'a decline is a silent 204');
|
|
387
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'me__guild-requests.json'), 'utf8'));
|
|
388
|
+
for (const r of fixture.incoming) {
|
|
389
|
+
assert.deepEqual(Object.keys(r).sort(), ['created_at', 'description', 'id', 'name', 'seen_at', 'slug', 'visibility'], 'listMyRequests’s row shape');
|
|
390
|
+
}
|
|
391
|
+
assert.deepEqual(fixture.incoming.map((r) => r.visibility).sort(), ['public', 'unlisted']);
|
|
392
|
+
});
|