@bongos/core 1.21.66 → 1.21.68
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 +111 -41
- package/docs/copy-inventory.md +414 -386
- package/docs/copy-registry.json +815 -537
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +45 -12
- package/docs/page-readings.json +959 -907
- package/modules/platform-identity/routes/my-projects.js +1 -1
- package/modules/public-landing/public/account.html +1 -1
- package/modules/public-landing/public/assets/cosmos.css +12 -10
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/index-cli.states.json +20 -0
- package/modules/public-landing/public/index.html +68 -1
- package/modules/public-landing/public/index.states.json +4 -4
- package/modules/public-landing/public/invitations-empty.states.json +56 -0
- package/modules/public-landing/public/invitations-picker.states.json +84 -0
- package/modules/public-landing/public/invitations.html +965 -0
- package/modules/public-landing/public/invitations.probes.json +430 -0
- package/modules/public-landing/public/invitations.states.json +473 -0
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +18 -543
- package/modules/public-landing/public/projects.probes.json +0 -29
- package/modules/public-landing/public/projects.states.json +0 -6
- package/modules/public-landing/public/terms.html +1 -1
- package/modules/ui-design/kit/fixtures/bongos-cli-latest-sample.json +10 -0
- package/modules/ui-design/kit/fixtures/featured-invitations.json +183 -0
- package/modules/ui-design/kit/fixtures/guilds-empty.json +1 -0
- package/modules/ui-design/kit/fixtures/me-guild-engagements-empty.json +1 -0
- package/modules/ui-design/kit/fixtures/me-guild-requests-empty.json +1 -0
- package/modules/ui-design/kit/fixtures/my-projects-applied.json +29 -0
- package/modules/ui-design/kit/fixtures/my-projects-invites-empty.json +1 -0
- package/modules/ui-design/kit/serve.js +5 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +11 -0
- package/tests/hub_account_page.mjs +1 -1
- package/tests/hub_engagement_cards.mjs +44 -40
- package/tests/hub_guild_cards.mjs +24 -22
- package/tests/hub_invitations_page.mjs +429 -0
- package/tests/join_relay_outcome.mjs +1 -1
- package/tests/landing_legal_pages.mjs +8 -6
- package/tests/landing_page.mjs +125 -3
- package/tests/my_projects_surface.mjs +14 -6
- package/tests/public_landing_projects.mjs +12 -14
- package/tests/render_check.mjs +3 -2
- package/tests/ui_design_kit.mjs +3 -3
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// tests/hub_engagement_cards.mjs — the guild cards
|
|
2
|
-
//
|
|
1
|
+
// tests/hub_engagement_cards.mjs — the guild cards on the hub's /invitations page,
|
|
2
|
+
// EXECUTED (task 1002303, R27; ADR 0365 D2/D3). They lived under /projects?view=mine
|
|
3
|
+
// until task 1004276 gave the invitations a page of their own.
|
|
3
4
|
//
|
|
4
5
|
// GET /me/guild-engagements names what the guilds a builder is in have going with
|
|
5
6
|
// a project: "G is applying to P" (the guild's owner put it forward) and "P
|
|
6
7
|
// invited G". Each card's Apply is the hub's ONE join relay (postJoinRequest,
|
|
7
|
-
// tests/card_apply_ui.mjs pins
|
|
8
|
+
// tests/card_apply_ui.mjs pins /projects' copy for the map's card) with `guild` added — the
|
|
8
9
|
// builder applies as themselves, never as the guild. What these pin:
|
|
9
10
|
// • the two kinds draw as two sentences, with the project's host and a plain
|
|
10
11
|
// hint; a row the page cannot act on is dropped, never drawn with a dead button
|
|
11
|
-
// • the
|
|
12
|
-
//
|
|
12
|
+
// • the page's one empty line shows only once the reads have answered and NO list
|
|
13
|
+
// has a row (the projects' own invites and the guild cards are one section)
|
|
13
14
|
// • Apply sends exactly { origin, guild } — plus the builder's own note when they
|
|
14
15
|
// open "Add a note" and write one — on the cookie session, to /my-projects/join
|
|
15
16
|
// • `requested` is the only answer that reads as applied; the relay's other
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
// • an expired session and a dead network are said as such
|
|
20
21
|
// • textContent / createElement only — no markup built from strings
|
|
21
22
|
//
|
|
22
|
-
// The idiom is tests/project_invite_ui.mjs: lift the region out of
|
|
23
|
+
// The idiom is tests/project_invite_ui.mjs: lift the region out of invitations.html,
|
|
23
24
|
// run it in a node:vm under a DOM stub, drive real clicks, and assert the slice
|
|
24
25
|
// boundaries, so a rename can never leave this file executing an empty string.
|
|
25
26
|
//
|
|
@@ -34,7 +35,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
34
35
|
|
|
35
36
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
37
|
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
37
|
-
const HUB = fs.readFileSync(path.join(LANDING, '
|
|
38
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
39
|
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
40
|
|
|
40
41
|
// the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
|
|
@@ -79,12 +80,12 @@ function el(tag) {
|
|
|
79
80
|
|
|
80
81
|
const TIDEPOOL = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'apply', project: { name: 'Tidepool', origin: 'https://tidepool.example.com' }, created_at: '2026-10-03T12:00:00.000Z' };
|
|
81
82
|
const LANTERN = { guild: { slug: 'forge-crew', name: 'Forge Crew' }, kind: 'invited', project: { name: 'Lantern Works', origin: 'https://lantern.example.com' }, created_at: '2026-10-02T09:30:00.000Z' };
|
|
82
|
-
const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: '
|
|
83
|
+
const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Applied. The project’s owner admits new builders.' } };
|
|
83
84
|
|
|
84
|
-
function boot({ answers = [] } = {}) {
|
|
85
|
+
function boot({ answers = [], read = true } = {}) {
|
|
85
86
|
const ids = new Map();
|
|
86
|
-
for (const id of ['
|
|
87
|
-
ids.get('
|
|
87
|
+
for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
|
|
88
|
+
ids.get('invitesNone').hidden = true;
|
|
88
89
|
const requests = [];
|
|
89
90
|
const queued = answers.slice();
|
|
90
91
|
const fetch = (url, init = {}) => {
|
|
@@ -98,21 +99,22 @@ function boot({ answers = [] } = {}) {
|
|
|
98
99
|
document, fetch, console, Promise, JSON, String, Object, Array, URL,
|
|
99
100
|
$: (id) => ids.get(id) || null,
|
|
100
101
|
API: '/api/bongos',
|
|
101
|
-
appliedOrigins: {}, mineOrigins: {},
|
|
102
102
|
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
103
103
|
safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
|
|
104
104
|
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
105
105
|
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '' }; },
|
|
106
106
|
};
|
|
107
107
|
vm.createContext(sandbox);
|
|
108
|
-
vm.runInContext(RELAY, sandbox, { filename: '
|
|
109
|
-
vm.runInContext(REGION, sandbox, { filename: '
|
|
108
|
+
vm.runInContext(RELAY, sandbox, { filename: 'invitations.html#join-relay' });
|
|
109
|
+
vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
|
|
110
|
+
// the renders below run as if loadInvites had answered; its own test reads the flag flip
|
|
111
|
+
if (read) sandbox.invitesRead = true;
|
|
110
112
|
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
|
111
113
|
const cards = () => ids.get('engageList').children;
|
|
112
114
|
const part = (card, cls) => card.querySelector('.' + cls);
|
|
113
115
|
const button = (card, cls) => part(card, 'mineRow__actions').children.find((b) => String(b.className).split(' ').includes(cls));
|
|
114
116
|
const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
|
|
115
|
-
return { sandbox, ids, requests, settle, cards, part, button, click,
|
|
117
|
+
return { sandbox, ids, requests, settle, cards, part, button, click, none: () => ids.get('invitesNone') };
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// ── drawing the cards ────────────────────────────────────────────────────────
|
|
@@ -127,12 +129,12 @@ test('the two kinds draw as two plain sentences, with the project’s host, a hi
|
|
|
127
129
|
assert.equal(ui.part(a, 'mineRow__host').textContent, 'tidepool.example.com');
|
|
128
130
|
assert.equal(ui.part(a, 'mineRow__hint').textContent, 'You apply as yourself — add a note for the owner if you like.');
|
|
129
131
|
assert.equal(a.className, 'mineRow engageRow');
|
|
130
|
-
assert.equal(ui.button(a, 'btn').textContent, 'Apply');
|
|
132
|
+
assert.equal(ui.button(a, 'btn').textContent, 'Apply to join', 'the join field’s word for the same relay, on the same page');
|
|
131
133
|
assert.equal(ui.button(a, 'linklike').textContent, 'Add a note');
|
|
132
134
|
assert.equal(ui.part(a, 'mineJoinNote').hidden, true, 'the note waits behind "Add a note"');
|
|
133
135
|
assert.equal(ui.part(a, 'mineJoinNote').maxLength, 400, 'the join note’s own limit');
|
|
134
136
|
assert.equal(ui.part(a, 'projMsg').getAttribute('role'), 'status');
|
|
135
|
-
assert.equal(ui.
|
|
137
|
+
assert.equal(ui.none().hidden, true, 'guild cards alone: the page does not say nothing is waiting');
|
|
136
138
|
});
|
|
137
139
|
|
|
138
140
|
test('a row the page cannot act on is dropped, never drawn with a button that fails', () => {
|
|
@@ -146,49 +148,52 @@ test('a row the page cannot act on is dropped, never drawn with a button that fa
|
|
|
146
148
|
null,
|
|
147
149
|
]);
|
|
148
150
|
assert.equal(ui.cards().length, 0);
|
|
149
|
-
assert.equal(ui.
|
|
151
|
+
assert.equal(ui.none().hidden, false, 'nothing to show: the page says so in one line');
|
|
150
152
|
});
|
|
151
153
|
|
|
152
|
-
test('the
|
|
154
|
+
test('the empty line stays down while EITHER list has a row, and a decline that empties both puts it up', async () => {
|
|
153
155
|
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true } }] });
|
|
154
156
|
ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
|
|
155
157
|
ui.sandbox.renderEngagements([]);
|
|
156
|
-
assert.equal(ui.
|
|
158
|
+
assert.equal(ui.none().hidden, true, 'a project’s own invite alone');
|
|
157
159
|
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
158
160
|
ui.sandbox.renderInvitesList([]);
|
|
159
|
-
assert.equal(ui.
|
|
161
|
+
assert.equal(ui.none().hidden, true, 'guild cards alone');
|
|
160
162
|
ui.sandbox.renderEngagements([]);
|
|
161
|
-
assert.equal(ui.
|
|
163
|
+
assert.equal(ui.none().hidden, false, 'neither');
|
|
162
164
|
|
|
163
165
|
ui.sandbox.renderInvitesList([{ client_id: 'cb-grove', name: 'The Grove', origin: 'https://grove.example.com' }]);
|
|
164
166
|
const row = ui.ids.get('invitesList').children[0];
|
|
165
167
|
const decline = row.querySelector('.mineRow__actions').children.find((b) => b.textContent === 'Decline');
|
|
166
168
|
await ui.click(decline);
|
|
167
169
|
assert.equal(ui.ids.get('invitesList').children.length, 0);
|
|
168
|
-
assert.equal(ui.
|
|
170
|
+
assert.equal(ui.none().hidden, false, 'the last decline puts it up when there is no guild card either');
|
|
169
171
|
});
|
|
170
172
|
|
|
171
|
-
test('loadInvites reads both lists,
|
|
172
|
-
const ui = boot({ answers: [
|
|
173
|
+
test('loadInvites reads both lists, a guild read that fails is silent, and the empty line waits for every answer', async () => {
|
|
174
|
+
const ui = boot({ read: false, answers: [
|
|
173
175
|
(url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
|
|
174
176
|
(url) => (url.endsWith('/my-projects/invites') ? { ok: true, status: 200, body: { invites: [] } } : { ok: false, status: 500, body: {} }),
|
|
175
177
|
] });
|
|
178
|
+
ui.sandbox.renderInvitesList([]);
|
|
179
|
+
assert.equal(ui.none().hidden, true, 'no "nothing waiting" line before the reads have answered');
|
|
176
180
|
await ui.sandbox.loadInvites();
|
|
177
181
|
await ui.settle();
|
|
178
182
|
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
179
183
|
['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
|
|
180
184
|
for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
|
|
181
185
|
assert.equal(ui.cards().length, 0);
|
|
182
|
-
assert.equal(ui.
|
|
186
|
+
assert.equal(ui.sandbox.invitesRead, true);
|
|
187
|
+
assert.equal(ui.none().hidden, false, 'every read answered, nothing in any list');
|
|
183
188
|
|
|
184
|
-
const ok = boot({ answers: [
|
|
189
|
+
const ok = boot({ read: false, answers: [
|
|
185
190
|
(url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
|
|
186
191
|
(url) => (url.endsWith('/me/guild-engagements') ? { ok: true, status: 200, body: { engagements: [TIDEPOOL], count: 1 } } : { ok: true, status: 200, body: { invites: [] } }),
|
|
187
192
|
] });
|
|
188
193
|
await ok.sandbox.loadInvites();
|
|
189
194
|
await ok.settle();
|
|
190
195
|
assert.equal(ok.cards().length, 1);
|
|
191
|
-
assert.equal(ok.
|
|
196
|
+
assert.equal(ok.none().hidden, true);
|
|
192
197
|
});
|
|
193
198
|
|
|
194
199
|
// ── applying ─────────────────────────────────────────────────────────────────
|
|
@@ -219,7 +224,7 @@ test('"Add a note" opens the card’s own note, and the note rides along, trimme
|
|
|
219
224
|
assert.deepEqual(ui.requests[0].body, { origin: 'https://tidepool.example.com', note: 'I like the boring parts too.', guild: 'forge-crew' });
|
|
220
225
|
});
|
|
221
226
|
|
|
222
|
-
test('requested is the one answer that reads as applied: the button says so and stays down
|
|
227
|
+
test('requested is the one answer that reads as applied: the button says so and stays down', async () => {
|
|
223
228
|
const ui = boot({ answers: [REQUESTED] });
|
|
224
229
|
ui.sandbox.renderEngagements([TIDEPOOL]);
|
|
225
230
|
const card = ui.cards()[0];
|
|
@@ -231,8 +236,8 @@ test('requested is the one answer that reads as applied: the button says so and
|
|
|
231
236
|
assert.equal(ui.part(card, 'mineJoinNote').hidden, true, 'a sent note is not offered again');
|
|
232
237
|
assert.equal(ui.part(card, 'mineRow__hint').hidden, true, 'nor is the hint about one');
|
|
233
238
|
assert.equal(ui.part(card, 'projMsg').className, 'projMsg ok');
|
|
234
|
-
assert.equal(ui.part(card, 'projMsg').textContent, '
|
|
235
|
-
assert.
|
|
239
|
+
assert.equal(ui.part(card, 'projMsg').textContent, 'Applied. The project’s owner admits new builders.', 'the relay’s own words');
|
|
240
|
+
assert.doesNotMatch(RELAY, /appliedOrigins/, 'this page has no map, so its relay keeps no Applied echo');
|
|
236
241
|
});
|
|
237
242
|
|
|
238
243
|
test('409 guild_engagement_gone: the guild application has ended, the plain application is offered, and it sends no guild', async () => {
|
|
@@ -241,9 +246,8 @@ test('409 guild_engagement_gone: the guild application has ended, the plain appl
|
|
|
241
246
|
const card = ui.cards()[0];
|
|
242
247
|
await ui.click(ui.button(card, 'btn'));
|
|
243
248
|
assert.equal(ui.part(card, 'projMsg').textContent, 'That guild application has ended — you can still apply on your own.');
|
|
244
|
-
assert.equal(ui.button(card, 'btn').textContent, 'Apply on my own');
|
|
249
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Apply to join on my own');
|
|
245
250
|
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
246
|
-
assert.equal(ui.sandbox.appliedOrigins['lantern.example.com'], undefined, 'nothing was relayed, so nothing reads as applied');
|
|
247
251
|
|
|
248
252
|
await ui.click(ui.button(card, 'btn'));
|
|
249
253
|
assert.deepEqual(ui.requests[1].body, { origin: 'https://lantern.example.com' }, 'on your own means no guild');
|
|
@@ -261,7 +265,7 @@ test('the relay’s other answers are said in its own words, and the card stays
|
|
|
261
265
|
await ui.click(ui.button(card, 'btn'));
|
|
262
266
|
assert.equal(ui.part(card, 'projMsg').className, 'projMsg', 'the door’s answer, not an error');
|
|
263
267
|
assert.match(ui.part(card, 'projMsg').textContent, /invite-only/);
|
|
264
|
-
assert.equal(ui.button(card, 'btn').textContent, 'Apply');
|
|
268
|
+
assert.equal(ui.button(card, 'btn').textContent, 'Apply to join');
|
|
265
269
|
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
266
270
|
await ui.click(ui.button(card, 'btn'));
|
|
267
271
|
assert.equal(ui.part(card, 'projMsg').className, 'projMsg err');
|
|
@@ -279,7 +283,7 @@ test('an expired session offers a way back in, built from elements; a dead netwo
|
|
|
279
283
|
assert.equal(msg.className, 'projMsg err');
|
|
280
284
|
assert.equal(msg.textContent, 'Your session expired — sign in again.');
|
|
281
285
|
const link = msg.children.find((c) => c.tag === 'a');
|
|
282
|
-
assert.equal(link.href, '/api/bongos/auth/web/start?return=/
|
|
286
|
+
assert.equal(link.href, '/api/bongos/auth/web/start?return=/invitations&repos=1', 'back to this page, with the repo scope every hub sign-in carries');
|
|
283
287
|
assert.equal(ui.button(card, 'btn').disabled, false);
|
|
284
288
|
await ui.click(ui.button(card, 'btn'));
|
|
285
289
|
assert.equal(msg.textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
@@ -287,20 +291,20 @@ test('an expired session offers a way back in, built from elements; a dead netwo
|
|
|
287
291
|
|
|
288
292
|
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
289
293
|
|
|
290
|
-
test('the cards are a real list inside the
|
|
291
|
-
const section = (HUB.match(/<section id="mineInvites"
|
|
294
|
+
test('the cards are a real list inside the invitations section, built with createElement / textContent only', () => {
|
|
295
|
+
const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
292
296
|
assert.match(section, /<ul class="mineRows" id="invitesList"><\/ul>/);
|
|
293
297
|
assert.match(section, /<ul class="mineRows" id="engageList"><\/ul>/, 'ships as real markup the script only fills');
|
|
294
298
|
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
|
|
295
|
-
assert.match(SCRIPT, /
|
|
299
|
+
assert.match(SCRIPT, /bar\.ready\.then\(function \(me\) \{[\s\S]*?loadInvites\(\);/, 'signed in, the page reads every list through loadInvites');
|
|
296
300
|
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
297
301
|
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');
|
|
298
302
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
|
|
299
303
|
});
|
|
300
304
|
|
|
301
305
|
test('the kit pictures the cards and the stub answers the read and the guild on the join', () => {
|
|
302
|
-
const states = JSON.parse(fs.readFileSync(path.join(LANDING, '
|
|
303
|
-
assert.ok(states['in-
|
|
306
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
|
|
307
|
+
assert.ok(states['in-engagements'] && states['in-engagement-applied'], 'two kit states: the cards, and one applied');
|
|
304
308
|
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
305
309
|
assert.match(serve, /'\/me\/guild-engagements': 'me__guild-engagements'/);
|
|
306
310
|
assert.match(serve, /refuse\(res, 409, 'guild_engagement_gone'/, 'the stub refuses a guild with no engagement, as the route does');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
|
|
2
|
-
// you own,
|
|
3
|
-
// D3/D4 §3, ADR 0337 D4.4).
|
|
2
|
+
// you own, on the hub's /invitations page, EXECUTED (task 1004583; ADR 0336
|
|
3
|
+
// D3/D4 §3, ADR 0337 D4.4). Under /projects?view=mine until task 1004276 gave the
|
|
4
|
+
// invitations a page of their own.
|
|
4
5
|
//
|
|
5
6
|
// R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
|
|
6
7
|
// /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
|
|
@@ -16,8 +17,8 @@
|
|
|
16
17
|
// guild as it is now, and the next accept sends that
|
|
17
18
|
// • an ended invite or request leaves nothing pressable; an expired session and a
|
|
18
19
|
// 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
|
|
20
|
-
//
|
|
20
|
+
// • Decline is a body-less POST whose 204 drops the row (the page's empty line
|
|
21
|
+
// comes up with the last row); a failure keeps the row and says so
|
|
21
22
|
// • the owner's card names who asked, with the handle once, and accepts with no
|
|
22
23
|
// body — the requester's switch rode their request
|
|
23
24
|
// • ONE read feeds both lists — GET /me/guild-requests's incoming and
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
// • textContent / createElement only
|
|
27
28
|
//
|
|
28
29
|
// The idiom is tests/hub_engagement_cards.mjs: lift the region out of
|
|
29
|
-
//
|
|
30
|
+
// invitations.html, run it in a node:vm under a DOM stub, drive real clicks, and
|
|
30
31
|
// assert the slice boundaries so a rename can never leave this executing nothing.
|
|
31
32
|
//
|
|
32
33
|
// DB-free. Run: node --test tests/hub_guild_cards.mjs
|
|
@@ -40,7 +41,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
40
41
|
|
|
41
42
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
43
|
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
43
|
-
const HUB = fs.readFileSync(path.join(LANDING, '
|
|
44
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
45
|
const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
45
46
|
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
46
47
|
|
|
@@ -88,8 +89,8 @@ const GONE = { ok: false, status: 204, body: undefined };
|
|
|
88
89
|
|
|
89
90
|
function boot({ answers = [] } = {}) {
|
|
90
91
|
const ids = new Map();
|
|
91
|
-
for (const id of ['
|
|
92
|
-
ids.get('
|
|
92
|
+
for (const id of ['invitesNone', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'invitesNone' ? 'p' : 'ul'));
|
|
93
|
+
ids.get('invitesNone').hidden = true;
|
|
93
94
|
const requests = [];
|
|
94
95
|
const queued = answers.slice();
|
|
95
96
|
const fetch = (url, init = {}) => {
|
|
@@ -110,7 +111,8 @@ function boot({ answers = [] } = {}) {
|
|
|
110
111
|
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
|
|
111
112
|
};
|
|
112
113
|
vm.createContext(sandbox);
|
|
113
|
-
vm.runInContext(REGION, sandbox, { filename: '
|
|
114
|
+
vm.runInContext(REGION, sandbox, { filename: 'invitations.html#rows' });
|
|
115
|
+
sandbox.invitesRead = true; // as if loadInvites had answered: the empty line may show
|
|
114
116
|
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
115
117
|
const invites = () => ids.get('guildInviteList').children;
|
|
116
118
|
const asks = () => ids.get('guildRequestList').children;
|
|
@@ -125,7 +127,7 @@ function boot({ answers = [] } = {}) {
|
|
|
125
127
|
for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
|
|
126
128
|
await settle();
|
|
127
129
|
};
|
|
128
|
-
return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick,
|
|
130
|
+
return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, none: () => ids.get('invitesNone') };
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
// ── the D4.4 notice: one sentence on two pages ───────────────────────────────
|
|
@@ -151,7 +153,7 @@ test('an invite card says what joining shows, offers the switch set to count, an
|
|
|
151
153
|
const ui = boot();
|
|
152
154
|
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
153
155
|
assert.equal(ui.requests.length, 0, 'drawing sends nothing');
|
|
154
|
-
assert.equal(ui.
|
|
156
|
+
assert.equal(ui.none().hidden, true, 'something is waiting');
|
|
155
157
|
const [open, quiet] = ui.invites();
|
|
156
158
|
assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
|
|
157
159
|
assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
|
|
@@ -253,7 +255,7 @@ test('an ended invite leaves nothing pressable; an expired session offers a way
|
|
|
253
255
|
await out.click(out.button(card, 'Accept'));
|
|
254
256
|
const a = out.msg(card).children.find((c) => c.tag === 'a');
|
|
255
257
|
assert.ok(a, 'a sign-in link, built as an element');
|
|
256
|
-
assert.equal(a.href, '/api/bongos/auth/web/start?return=/
|
|
258
|
+
assert.equal(a.href, '/api/bongos/auth/web/start?return=/invitations&repos=1');
|
|
257
259
|
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
258
260
|
await out.click(out.button(card, 'Accept'));
|
|
259
261
|
assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
@@ -270,14 +272,14 @@ test('Decline is a body-less POST whose 204 drops the row, and the last one hide
|
|
|
270
272
|
assert.equal(ui.requests[0].method, 'POST');
|
|
271
273
|
assert.equal(ui.requests[0].body, undefined, 'nothing to send');
|
|
272
274
|
assert.equal(ui.invites().length, 0);
|
|
273
|
-
assert.equal(ui.
|
|
275
|
+
assert.equal(ui.none().hidden, true, 'the owner’s card still stands');
|
|
274
276
|
const ask = ui.asks()[0];
|
|
275
277
|
await ui.click(ui.button(ask, 'Decline'));
|
|
276
278
|
assert.equal(ui.asks().length, 1, 'kept on a failure');
|
|
277
279
|
assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
|
|
278
280
|
assert.equal(ui.button(ask, 'Decline').disabled, false);
|
|
279
281
|
ui.sandbox.renderGuildRequests([]);
|
|
280
|
-
assert.equal(ui.
|
|
282
|
+
assert.equal(ui.none().hidden, false, 'no row in any list: the page says nothing is waiting');
|
|
281
283
|
});
|
|
282
284
|
|
|
283
285
|
// ── the owner's card ─────────────────────────────────────────────────────────
|
|
@@ -333,13 +335,13 @@ test('ONE read feeds both lists — the invites to you and the requests to every
|
|
|
333
335
|
assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
|
|
334
336
|
});
|
|
335
337
|
|
|
336
|
-
test('a failed or empty read is silent: no cards,
|
|
338
|
+
test('a failed or empty read is silent: no cards, only the page’s own empty line', async () => {
|
|
337
339
|
for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
|
|
338
340
|
const ui = boot({ answers: [answer] });
|
|
339
341
|
await ui.sandbox.loadGuildQueue();
|
|
340
342
|
await ui.settle();
|
|
341
343
|
assert.equal(ui.invites().length + ui.asks().length, 0);
|
|
342
|
-
assert.equal(ui.
|
|
344
|
+
assert.equal(ui.none().hidden, false);
|
|
343
345
|
}
|
|
344
346
|
});
|
|
345
347
|
|
|
@@ -359,13 +361,13 @@ test('a row the page cannot answer is dropped, never drawn with a button that fa
|
|
|
359
361
|
|
|
360
362
|
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
361
363
|
|
|
362
|
-
test('the cards are real lists in the
|
|
363
|
-
const section = (HUB.match(/<section id="mineInvites"
|
|
364
|
+
test('the cards are real lists in the invitations section, built with createElement / textContent only', () => {
|
|
365
|
+
const section = (HUB.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
364
366
|
const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
|
|
365
367
|
assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
|
|
366
368
|
assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
|
|
367
369
|
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
368
|
-
assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\)
|
|
370
|
+
assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\)/, 'the page reads them with the other invitations');
|
|
369
371
|
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
370
372
|
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
373
|
assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
|
|
@@ -374,9 +376,9 @@ test('the cards are real lists in the Invitations section, built with createElem
|
|
|
374
376
|
});
|
|
375
377
|
|
|
376
378
|
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, '
|
|
378
|
-
for (const s of ['in-
|
|
379
|
-
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, '
|
|
379
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.states.json'), 'utf8')).states;
|
|
380
|
+
for (const s of ['in-guild-cards', 'in-guild-count-off', 'in-guild-joined']) assert.ok(states[s], `kit state ${s}`);
|
|
381
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'invitations.probes.json'), 'utf8')).probes.map((p) => p.name);
|
|
380
382
|
assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
|
|
381
383
|
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
382
384
|
assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);
|