@bongos/core 1.21.56 → 1.21.58
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 +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +13 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +2 -0
- package/docs/adr/0365-a-guild-engages-a-project-through-each-members-own-door.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +233 -6
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +361 -315
- package/docs/copy-registry.json +868 -419
- package/docs/file-map.md +4 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +856 -771
- package/modules/platform-identity/application-echo.js +11 -5
- package/modules/platform-identity/guild-engagements.js +255 -0
- package/modules/platform-identity/guilds.js +3 -1
- package/modules/platform-identity/migrations/platform_identity_030_guild_engagements.sql +64 -0
- package/modules/platform-identity/routes/guilds.js +35 -1
- package/modules/platform-identity/routes/my-projects.js +27 -3
- package/modules/platform-identity/routes/projects.js +44 -0
- package/modules/platform-identity/routes/sso.js +21 -3
- package/modules/public-landing/public/assets/cosmos.css +7 -0
- package/modules/public-landing/public/guild.html +633 -0
- package/modules/public-landing/public/guild.probes.json +63 -0
- package/modules/public-landing/public/guild.states.json +43 -0
- package/modules/public-landing/public/projects.html +252 -16
- package/modules/public-landing/public/projects.probes.json +26 -1
- package/modules/public-landing/public/projects.states.json +4 -0
- package/modules/ui-design/kit/fixtures/guilds.json +73 -0
- package/modules/ui-design/kit/fixtures/me__guild-engagements.json +7 -0
- package/modules/ui-design/kit/serve.js +99 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +13 -0
- package/tests/applicant_profile_boundary.mjs +104 -1
- package/tests/application_echo.mjs +91 -5
- package/tests/guild_engagement.mjs +881 -0
- package/tests/guild_engagement_db.mjs +240 -0
- package/tests/hub_engagement_cards.mjs +307 -0
- package/tests/hub_guild_page.mjs +624 -0
- package/tests/privacy_matrix.mjs +216 -14
- package/tests/project_invite_ui.mjs +187 -3
- package/tests/render_check.mjs +2 -2
- package/tests/ui_design_kit.mjs +3 -3
package/tests/privacy_matrix.mjs
CHANGED
|
@@ -153,14 +153,26 @@ function identitySentinels(acct) {
|
|
|
153
153
|
// directory GET /scouting Recruiter — 'full' | 'name-only' | 'absent'
|
|
154
154
|
// sliver GET /scouting/:handle Recruiter — 'full' (D3) | 'name-only' (D3, hidden) | 404
|
|
155
155
|
// self GET /me/cross-project Self — 'own' (own numbers, any flags) | 'n/a' (no session exists)
|
|
156
|
+
// guild GET /guilds/:slug Anon · Builder · Recruiter · Self — the guild page's PUBLIC
|
|
157
|
+
// roster (task 1002301; ADR 0336 D4), viewer-independent: 'listed' | 'absent'.
|
|
158
|
+
// Every fixture account is a member with shown_publicly ON, so the account
|
|
159
|
+
// predicates alone decide; hide_stats never hides membership (D4 §2)
|
|
160
|
+
// members GET /guilds/:slug/members a fellow Member — the own-scope roster: 'listed' |
|
|
161
|
+
// 'absent'. Joining is consent to be seen BY THE GUILD (D4 §1), so a private
|
|
162
|
+
// member is listed here and nowhere public; a provisional account is never
|
|
163
|
+
// invite POST /projects/invite-guild a project's Inviter (task 1002303; ADR 0365 D3) — one
|
|
164
|
+
// hall link per member of a PUBLIC guild's public roster: 'listed' | 'absent'.
|
|
165
|
+
// It reads accounts across projects, so it may hand the inviter exactly the
|
|
166
|
+
// people the guild page already shows them — shown_publicly AND existence;
|
|
167
|
+
// hide_stats never hides membership, and private or provisional is never listed
|
|
156
168
|
const MATRIX = {
|
|
157
|
-
PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a' },
|
|
158
|
-
PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own' },
|
|
159
|
-
SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own' },
|
|
160
|
-
PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own' },
|
|
161
|
-
PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own' },
|
|
162
|
-
PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own' },
|
|
163
|
-
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own' },
|
|
169
|
+
PROV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
|
|
170
|
+
PUB: { profile: 'full', search: 'listed', board: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
171
|
+
SHY: { profile: 'identity', search: 'name-only', board: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
172
|
+
PRIV: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
173
|
+
PRIVD: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
174
|
+
PRIVDH: { profile: 404, search: 'absent', board: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
|
|
175
|
+
OPTOUT: { profile: 'full', search: 'listed', board: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
|
|
164
176
|
};
|
|
165
177
|
|
|
166
178
|
// ===========================================================================
|
|
@@ -243,10 +255,89 @@ function activityScope(sql, params) {
|
|
|
243
255
|
return String(params[Number(m[1]) - 1]);
|
|
244
256
|
}
|
|
245
257
|
|
|
258
|
+
// THE GUILDS (task 1002301): one public and one unlisted guild, each with every
|
|
259
|
+
// fixture account as a member and shown_publicly ON — the most permissive guild
|
|
260
|
+
// flag, so the account predicates alone decide each cell. PUB owns both.
|
|
261
|
+
const GUILDS = [
|
|
262
|
+
{ id: '880001', slug: 'mx-guild', name: 'Matrix Guild', description: 'mx-guild-desc', visibility: 'public', created_at: 'T0' },
|
|
263
|
+
{ id: '880002', slug: 'mx-unlisted', name: 'Matrix Unlisted', description: 'mx-unlisted-desc', visibility: 'unlisted', created_at: 'T0' },
|
|
264
|
+
];
|
|
265
|
+
const GUILD_MEMBERS = GUILDS.flatMap((g) => ACCOUNTS.map((a) => ({
|
|
266
|
+
guild_id: g.id, github_id: a.github_id, membership_kind: a.key === 'PUB' ? 'owner' : 'member', shown_publicly: true, counted_in_totals: true,
|
|
267
|
+
})));
|
|
268
|
+
/** The members-of-a-guild statements: the guild and shown terms consumed here, the rest read as an account filter. */
|
|
269
|
+
function guildRosterFilter(sql, params) {
|
|
270
|
+
let where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = m\.github_id WHERE (.+?) ORDER BY/, 'guild roster');
|
|
271
|
+
if (!/\bm\.guild_id = \$1\b/.test(where)) throw new Error(`fake pool: a guild roster with no guild scope — ${where}`);
|
|
272
|
+
where = where.replace(/\bm\.guild_id = \$1\b/, '');
|
|
273
|
+
const shownOnly = /\bm\.shown_publicly\b/.test(where);
|
|
274
|
+
where = where.replace(/\bm\.shown_publicly\b/, '');
|
|
275
|
+
const byAccount = accountFilter(where, []);
|
|
276
|
+
return (m) => m.guild_id === String(params[0]) && (!shownOnly || m.shown_publicly) && byAccount(ACCOUNTS.find((a) => a.github_id === m.github_id));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// THE PROJECT a guild is invited to (task 1002303): one active client, whose
|
|
280
|
+
// origin the hall links point at.
|
|
281
|
+
const MX_CLIENT = { client_id: 'mx-proj', secret_hash: '', name: 'MX Project', redirect_uris: [], origin: 'https://mx-proj.example', owner_github_id: null, status: 'active' };
|
|
282
|
+
|
|
246
283
|
const pool = {
|
|
247
284
|
query: async (text, params = []) => {
|
|
248
285
|
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
249
286
|
|
|
287
|
+
// POST /projects/invite-guild's reads (task 1002303). The client the gate resolves.
|
|
288
|
+
if (/FROM platform_identity_sso_clients WHERE client_id = \$1$/.test(sql)) {
|
|
289
|
+
const rows = params[0] === MX_CLIENT.client_id ? [{ ...MX_CLIENT }] : [];
|
|
290
|
+
return { rows, rowCount: rows.length };
|
|
291
|
+
}
|
|
292
|
+
// ... the guild, by slug — its WHERE is read, not assumed: PUBLIC only.
|
|
293
|
+
if (/^SELECT id, slug, name FROM platform_identity_guilds WHERE /.test(sql)) {
|
|
294
|
+
if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the invite-guild lookup moved — ${sql}`);
|
|
295
|
+
const rows = GUILDS.filter((g) => g.slug === String(params[0]) && g.visibility === 'public').map(({ id, slug, name }) => ({ id, slug, name }));
|
|
296
|
+
return { rows, rowCount: rows.length };
|
|
297
|
+
}
|
|
298
|
+
// ... the `invited` engagement it records (no account in it, so nothing to model here).
|
|
299
|
+
if (/^INSERT INTO platform_identity_guild_engagements /.test(sql)) return { rows: [], rowCount: 1 };
|
|
300
|
+
// ... and the PUBLIC roster the hall links go to, read through the same roster
|
|
301
|
+
// interpreter as the guild page — its projection pinned, since the login it
|
|
302
|
+
// reads becomes part of each link.
|
|
303
|
+
if (/^SELECT pa\.handle, pa\.display_name, pa\.github_login FROM platform_identity_guild_members m JOIN platform_identity_accounts pa/.test(sql)) {
|
|
304
|
+
const keep = guildRosterFilter(sql, params);
|
|
305
|
+
const rows = GUILD_MEMBERS.filter(keep).map((m) => {
|
|
306
|
+
const a = ACCOUNTS.find((x) => x.github_id === m.github_id);
|
|
307
|
+
// github_id and the bio ride along so a projection that spreads the row leaks them
|
|
308
|
+
return { handle: a.handle, display_name: a.display_name, github_login: a.github_login, github_id: a.github_id, bio: a.bio };
|
|
309
|
+
});
|
|
310
|
+
return { rows, rowCount: rows.length };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// THE GUILD PAGE's reads. The public guild: its WHERE is read, not assumed.
|
|
314
|
+
if (/^SELECT id, slug, name, description, created_at FROM platform_identity_guilds WHERE /.test(sql)) {
|
|
315
|
+
if (!/WHERE lower\(slug\) = \$1 AND visibility = 'public'$/.test(sql)) throw new Error(`fake pool: the public guild read moved — ${sql}`);
|
|
316
|
+
const rows = GUILDS.filter((g) => g.slug === String(params[0]) && g.visibility === 'public').map(({ visibility, ...g }) => g);
|
|
317
|
+
return { rows, rowCount: rows.length };
|
|
318
|
+
}
|
|
319
|
+
// ... a member's own row in a guild (the own-scope door).
|
|
320
|
+
if (/FROM platform_identity_guilds g JOIN platform_identity_guild_members m ON m\.guild_id = g\.id AND m\.github_id = \$2::bigint WHERE lower\(g\.slug\) = \$1$/.test(sql)) {
|
|
321
|
+
const g = GUILDS.find((x) => x.slug === String(params[0]));
|
|
322
|
+
const m = g && GUILD_MEMBERS.find((x) => x.guild_id === g.id && x.github_id === String(params[1]));
|
|
323
|
+
const rows = m ? [{ ...g, ...m, joined_at: 'T2' }] : [];
|
|
324
|
+
return { rows, rowCount: rows.length };
|
|
325
|
+
}
|
|
326
|
+
// ... both rosters: public (shown AND visible) and members' (active).
|
|
327
|
+
if (/FROM platform_identity_guild_members m JOIN platform_identity_accounts pa/.test(sql)) {
|
|
328
|
+
// the projection is pinned: a new column here is a new disclosure, and this fails first
|
|
329
|
+
const cols = (/^SELECT (.+?) FROM /.exec(sql) || [, ''])[1];
|
|
330
|
+
if (cols !== 'pa.handle, pa.display_name, pa.avatar_url, m.membership_kind') throw new Error(`fake pool: the guild roster selects something new — ${cols}`);
|
|
331
|
+
const keep = guildRosterFilter(sql, params);
|
|
332
|
+
const rows = GUILD_MEMBERS.filter(keep).map((m) => {
|
|
333
|
+
const a = ACCOUNTS.find((x) => x.github_id === m.github_id);
|
|
334
|
+
return { handle: a.handle, display_name: a.display_name, avatar_url: a.avatar_url, membership_kind: m.membership_kind };
|
|
335
|
+
});
|
|
336
|
+
return { rows, rowCount: rows.length };
|
|
337
|
+
}
|
|
338
|
+
// ... and the weekly totals: never computed on a read (guild-totals.js); none stored here.
|
|
339
|
+
if (/FROM platform_identity_guild_totals WHERE guild_id = \$1$/.test(sql)) return { rows: [], rowCount: 0 };
|
|
340
|
+
|
|
250
341
|
// THE LEADERBOARD's totals: the rollup floor sits on the act CTE's join.
|
|
251
342
|
if (/platform_identity_activity_snapshots/.test(sql)) {
|
|
252
343
|
const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'leaderboard totals');
|
|
@@ -346,6 +437,8 @@ const ANON = null;
|
|
|
346
437
|
const STRANGER = { name: 'Builder', github_id: '7100001', rank: 'xenos', curate: false };
|
|
347
438
|
const RECRUITER = { name: 'Recruiter', github_id: '7100002', rank: 'archon', curate: true };
|
|
348
439
|
const selfOf = (k) => ({ name: 'Self', github_id: ACCT[k].github_id, rank: 'xenos', curate: false });
|
|
440
|
+
// a fellow guild member: PUB's session, which owns both fixture guilds
|
|
441
|
+
const MEMBER = { name: 'Member', github_id: ACCT.PUB.github_id, rank: 'xenos', curate: false };
|
|
349
442
|
|
|
350
443
|
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
351
444
|
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
@@ -366,6 +459,13 @@ require.cache[moduleApiPath] = {
|
|
|
366
459
|
},
|
|
367
460
|
requirePermission: () => (req, res, next) => (viewer && viewer.curate ? next() : res.status(403).json({ ok: false, error: 'forbidden' })),
|
|
368
461
|
requireRank: (...allowed) => (req, res, next) => (viewer && allowed.includes(viewer.rank) ? next() : res.fail('rank_forbidden', 403)),
|
|
462
|
+
// routes/projects.js (POST /projects/invite-guild): its seams are already
|
|
463
|
+
// registered as far as this file is concerned, and no provisioning module
|
|
464
|
+
// answers the inviter gate's ownership arm, so the rank door decides.
|
|
465
|
+
hasProvider: () => true,
|
|
466
|
+
registerProvider: () => {},
|
|
467
|
+
resolveOptional: () => null,
|
|
468
|
+
publicProjectFeedRateLimit: (_req, _res, next) => next(),
|
|
369
469
|
},
|
|
370
470
|
};
|
|
371
471
|
|
|
@@ -413,7 +513,7 @@ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
|
413
513
|
const app = express();
|
|
414
514
|
app.use(express.json());
|
|
415
515
|
app.use(attachFail);
|
|
416
|
-
for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting']) {
|
|
516
|
+
for (const file of ['public-profile', 'profile', 'community-search', 'community-leaderboard', 'scouting', 'guilds-public', 'guilds', 'projects']) {
|
|
417
517
|
app.use('/api/bongos', require(`../modules/platform-identity/routes/${file}.js`)());
|
|
418
518
|
}
|
|
419
519
|
const server = app.listen(0, '127.0.0.1');
|
|
@@ -445,6 +545,28 @@ function get(who, path_) {
|
|
|
445
545
|
});
|
|
446
546
|
}
|
|
447
547
|
|
|
548
|
+
/** A POST through the same recorder, so the sweeps below judge its body too. */
|
|
549
|
+
function post(who, path_, body) {
|
|
550
|
+
viewer = who;
|
|
551
|
+
const payload = JSON.stringify(body);
|
|
552
|
+
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
|
|
553
|
+
return new Promise((resolve, reject) => {
|
|
554
|
+
const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: path_, headers }, (res) => {
|
|
555
|
+
const chunks = [];
|
|
556
|
+
res.on('data', (c) => chunks.push(c));
|
|
557
|
+
res.on('end', () => {
|
|
558
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
559
|
+
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
560
|
+
const out = { who: who ? who.name : 'Anon', whoId: who ? who.github_id : null, path: path_, status: res.statusCode, text, json };
|
|
561
|
+
SEEN.push(out);
|
|
562
|
+
resolve(out);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
req.on('error', reject);
|
|
566
|
+
req.end(payload);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
448
570
|
// ===========================================================================
|
|
449
571
|
// 0. The instrument is calibrated before it is trusted
|
|
450
572
|
// ===========================================================================
|
|
@@ -535,6 +657,78 @@ for (const k of KEYS) {
|
|
|
535
657
|
});
|
|
536
658
|
}
|
|
537
659
|
|
|
660
|
+
// The guild page's two rosters (task 1002301; ADR 0336 D4). One read per viewer
|
|
661
|
+
// serves every row, so each row's cell is checked against the same bodies.
|
|
662
|
+
const GUILD_PATH = '/api/bongos/guilds/mx-guild';
|
|
663
|
+
const ROSTER_KEYS = ['avatar_url', 'display_name', 'handle', 'membership_kind'];
|
|
664
|
+
const ALL_VIEWERS = () => [ANON, STRANGER, RECRUITER, MEMBER, ...KEYS.map(selfOf)];
|
|
665
|
+
|
|
666
|
+
test('the guild page’s public roster — the same bytes to Anon, Builder, Recruiter, a Member and every Self; listed or absent per the matrix', async () => {
|
|
667
|
+
const seen = [];
|
|
668
|
+
for (const who of ALL_VIEWERS()) seen.push(await get(who, GUILD_PATH));
|
|
669
|
+
for (const r of seen.slice(1)) {
|
|
670
|
+
assert.equal(r.status, seen[0].status, `${r.who} got a different status — the public guild read must not vary by viewer (ADR 0336 D4)`);
|
|
671
|
+
assert.equal(r.text, seen[0].text, `${r.who} got a different body — the public guild read must not vary by viewer (ADR 0336 D4)`);
|
|
672
|
+
}
|
|
673
|
+
const [r] = seen;
|
|
674
|
+
assert.equal(r.status, 200);
|
|
675
|
+
const listed = new Set(r.json.members.map((m) => m.handle));
|
|
676
|
+
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].guild === 'listed', `${k} on the public roster: expected ${MATRIX[k].guild}`);
|
|
677
|
+
assert.equal(r.json.size, r.json.members.length, 'size is the roster’s own length — never a "+N more" (ADR 0336 D4)');
|
|
678
|
+
for (const m of r.json.members) assert.deepEqual(Object.keys(m).sort(), ROSTER_KEYS, 'handle, name, avatar, kind — never a GitHub id or login');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test('the guild members’ roster — a fellow member sees every active member, private ones included; Anon is refused', async () => {
|
|
682
|
+
const r = await get(MEMBER, `${GUILD_PATH}/members`);
|
|
683
|
+
assert.equal(r.status, 200);
|
|
684
|
+
const listed = new Set(r.json.members.map((m) => m.handle));
|
|
685
|
+
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].members === 'listed', `${k} on the members’ roster: expected ${MATRIX[k].members}`);
|
|
686
|
+
assert.equal(r.json.count, r.json.members.length);
|
|
687
|
+
for (const m of r.json.members) assert.deepEqual(Object.keys(m).sort(), ROSTER_KEYS);
|
|
688
|
+
assert.deepEqual(Object.keys(r.json.you).sort(), ['counted_in_totals', 'membership_kind', 'shown_publicly'], 'the reader’s own two switches, and nobody else’s');
|
|
689
|
+
assert.equal((await get(ANON, `${GUILD_PATH}/members`)).status, 401);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test('an unlisted guild’s public read is a 404 to everyone, its own members included, byte-identical to a slug nobody holds; members reach it through the own-scope door', async () => {
|
|
693
|
+
const none = await get(ANON, '/api/bongos/guilds/mx-nobody');
|
|
694
|
+
assert.equal(none.status, 404);
|
|
695
|
+
for (const who of ALL_VIEWERS()) {
|
|
696
|
+
const r = await get(who, '/api/bongos/guilds/mx-unlisted');
|
|
697
|
+
assert.equal(r.status, 404, `${r.who}: an unlisted guild has no public existence (ADR 0336 D4)`);
|
|
698
|
+
assert.equal(r.text.replaceAll('mx-unlisted', 'S'), none.text.replaceAll('mx-nobody', 'S'), `${r.who}: no existence oracle`);
|
|
699
|
+
}
|
|
700
|
+
const own = await get(MEMBER, '/api/bongos/guilds/mx-unlisted/members');
|
|
701
|
+
assert.equal(own.status, 200);
|
|
702
|
+
const listed = new Set(own.json.members.map((m) => m.handle));
|
|
703
|
+
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].members === 'listed', `${k} on the unlisted guild’s members’ roster`);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// A project invites a guild (task 1002303; ADR 0365 D3). The INVITER is the
|
|
707
|
+
// viewer here — a holder of the route's rank door (the Recruiter fixture carries
|
|
708
|
+
// it); the cell is who they are handed a link for.
|
|
709
|
+
const INVITE_GUILD_PATH = '/api/bongos/projects/invite-guild';
|
|
710
|
+
const INVITE_KEYS = ['display_name', 'handle', 'invite_url'];
|
|
711
|
+
|
|
712
|
+
test('a project invites a guild — one hall link per PUBLIC-roster member, listed or absent per the matrix; a non-inviter is refused', async () => {
|
|
713
|
+
const r = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
|
|
714
|
+
assert.equal(r.status, 200);
|
|
715
|
+
assert.equal(r.json.invited, false, 'the hub writes no invite (ADR 0353)');
|
|
716
|
+
const listed = new Set(r.json.invites.map((i) => i.handle));
|
|
717
|
+
for (const k of KEYS) assert.equal(listed.has(ACCT[k].handle), MATRIX[k].invite === 'listed', `${k} in the invite fan-out: expected ${MATRIX[k].invite}`);
|
|
718
|
+
assert.equal(r.json.count, r.json.invites.length, 'count is the array’s own length');
|
|
719
|
+
for (const i of r.json.invites) {
|
|
720
|
+
assert.deepEqual(Object.keys(i).sort(), INVITE_KEYS, 'a handle, a name and a link — never a GitHub id, a bio or a number');
|
|
721
|
+
const a = ACCOUNTS.find((x) => x.handle === i.handle);
|
|
722
|
+
assert.equal(i.invite_url, `${MX_CLIENT.origin}/builders/watch?invite=${encodeURIComponent(a.github_login)}`, 'the project’s own hall, with the login a public profile already shows');
|
|
723
|
+
}
|
|
724
|
+
const stranger = await post(STRANGER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-guild' });
|
|
725
|
+
assert.equal(stranger.status, 403, 'only the project’s inviter is handed the roster');
|
|
726
|
+
const unlisted = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-unlisted' });
|
|
727
|
+
const nobody = await post(RECRUITER, INVITE_GUILD_PATH, { client_id: MX_CLIENT.client_id, guild: 'mx-nobody' });
|
|
728
|
+
assert.equal(unlisted.status, 404);
|
|
729
|
+
assert.equal(unlisted.text, nobody.text, 'an unlisted guild cannot be invited: the unknown-guild 404, byte for byte');
|
|
730
|
+
});
|
|
731
|
+
|
|
538
732
|
test('the sliver’s key lists are D3’s, transcribed: handle, name, avatar, disciplines, a recent-activity boolean — or the first three', () => {
|
|
539
733
|
// The cells above compare a response to the MODULE's lists; this pins those
|
|
540
734
|
// lists to the spec, so a sixth key added to the module cannot pass with them.
|
|
@@ -594,6 +788,10 @@ test('the recruiting directory — the recruiter sees the full and name-only tie
|
|
|
594
788
|
// ===========================================================================
|
|
595
789
|
|
|
596
790
|
const isOwnRead = (r, a) => r.whoId === a.github_id && r.path === '/api/bongos/me/cross-project';
|
|
791
|
+
// The one place ADR 0336 D4 lets a private member be named: their own guild's
|
|
792
|
+
// members' roster, read by a fellow member — joining is consent to be seen BY THE
|
|
793
|
+
// GUILD, never by the world. A provisional account is not on it (accountActiveSql).
|
|
794
|
+
const isFellowMemberRead = (r) => r.who === 'Member' && /^\/api\/bongos\/guilds\/[^/?]+\/members$/.test(r.path);
|
|
597
795
|
// What a response DISCLOSES: its body minus the fields that only echo the
|
|
598
796
|
// caller's own request back (search's `query` and `discipline`). A discipline
|
|
599
797
|
// the caller typed into the URL is not the server revealing it; the results
|
|
@@ -608,7 +806,7 @@ function disclosed(r) {
|
|
|
608
806
|
}
|
|
609
807
|
|
|
610
808
|
test('the walk covered every surface for every row (so the claims below are about something)', () => {
|
|
611
|
-
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting']) {
|
|
809
|
+
for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
|
|
612
810
|
assert.ok(SEEN.some((r) => r.path.includes(p) && r.status === 200), `no 200 from ${p} — the sweep below would be vacuous for it`);
|
|
613
811
|
}
|
|
614
812
|
assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);
|
|
@@ -630,21 +828,25 @@ test('hide_stats beats everything: no hidden account’s numbers reach anyone bu
|
|
|
630
828
|
assert.ok(SEEN.some((r) => r.who === 'Anon' && r.text.includes(String(ROLLUP[ACCT.PUB.github_id].credits))), 'a shown record must reach Anon, or absence above is vacuous');
|
|
631
829
|
});
|
|
632
830
|
|
|
633
|
-
test('provisional and private (not discoverable) are absent from every cross-project surface, for every viewer but Self', () => {
|
|
831
|
+
test('provisional and private (not discoverable) are absent from every cross-project surface, for every viewer but Self — a private member’s own guild aside', () => {
|
|
634
832
|
for (const k of ['PROV', 'PRIV']) {
|
|
635
833
|
const a = ACCT[k];
|
|
636
|
-
|
|
834
|
+
const ownGuild = (x) => k === 'PRIV' && isFellowMemberRead(x);
|
|
835
|
+
for (const r of SEEN.filter((x) => !isOwnRead(x, a) && !ownGuild(x) && !x.path.endsWith(`/profiles/${a.handle}`) && !x.path.endsWith(`/scouting/${a.handle}`))) {
|
|
637
836
|
for (const s of identitySentinels(a)) assert.ok(!disclosed(r).includes(s), `${k}'s "${s}" reached ${r.who} through GET ${r.path}`);
|
|
638
837
|
}
|
|
639
838
|
}
|
|
640
839
|
});
|
|
641
840
|
|
|
642
|
-
test('private · discoverable reaches the recruiter’s sliver and nowhere else', () => {
|
|
841
|
+
test('private · discoverable reaches the recruiter’s sliver and, as a member, its own guilds’ members’ rosters — nowhere else', () => {
|
|
643
842
|
for (const k of ['PRIVD', 'PRIVDH']) {
|
|
644
843
|
const a = ACCT[k];
|
|
645
844
|
const leaks = SEEN.filter((r) => !isOwnRead(r, a) && identitySentinels(a).some((s) => disclosed(r).includes(s)));
|
|
646
|
-
assert.deepEqual(leaks.map((r) => `${r.who} ${r.path}`), [
|
|
647
|
-
|
|
845
|
+
assert.deepEqual([...new Set(leaks.map((r) => `${r.who} ${r.path}`))].sort(), [
|
|
846
|
+
'Member /api/bongos/guilds/mx-guild/members',
|
|
847
|
+
'Member /api/bongos/guilds/mx-unlisted/members',
|
|
848
|
+
`Recruiter /api/bongos/scouting/${a.handle}`,
|
|
849
|
+
], `${k} may be named only by the sliver, to the recruiter (D3), and to its fellow guild members (ADR 0336 D4)`);
|
|
648
850
|
}
|
|
649
851
|
});
|
|
650
852
|
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
// • a refusal never costs the project or the step — the box stays, re-tryable
|
|
30
30
|
// • Skip advances with ZERO calls
|
|
31
31
|
// • both call sites hand over a slug only for a signed-in owner
|
|
32
|
+
// • a GUILD can be invited from the project's own page (task 1002303, R27; ADR
|
|
33
|
+
// 0365 D3): exactly { client_id, guild } to /projects/invite-guild, its answer
|
|
34
|
+
// drawn as the same hand-off lines, one per member on the guild's public page,
|
|
35
|
+
// an unknown or unlisted guild one plain line, and the wizard has no such door
|
|
32
36
|
//
|
|
33
37
|
// Run: node --test --test-reporter=tap tests/project_invite_ui.mjs
|
|
34
38
|
|
|
@@ -51,7 +55,8 @@ assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
|
|
|
51
55
|
assert.ok(END > START, 'the invite region must end before the boot block');
|
|
52
56
|
const REGION = SCRIPT.slice(START, END);
|
|
53
57
|
for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
|
|
54
|
-
'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions('
|
|
58
|
+
'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(',
|
|
59
|
+
'function doInviteGuild(', 'function inviteGuildSlug(']) {
|
|
55
60
|
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -116,7 +121,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
116
121
|
['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
|
|
117
122
|
'wizInviteWhy', 'wizInviteSugg',
|
|
118
123
|
'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
|
|
119
|
-
'mInviteWhy', 'mInviteSugg'].forEach($);
|
|
124
|
+
'mInviteWhy', 'mInviteSugg', 'mInviteGuild', 'mInviteGuildMsg'].forEach($);
|
|
120
125
|
|
|
121
126
|
const queued = answers.slice();
|
|
122
127
|
const fetch = (url, init = {}) => {
|
|
@@ -150,7 +155,7 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
150
155
|
|
|
151
156
|
const mounts = {
|
|
152
157
|
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
|
|
153
|
-
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
|
|
158
|
+
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg', guild: 'mInviteGuild', guildMsg: 'mInviteGuildMsg' },
|
|
154
159
|
};
|
|
155
160
|
const m = mounts[mount];
|
|
156
161
|
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
@@ -186,6 +191,22 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
186
191
|
async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
|
|
187
192
|
async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
|
|
188
193
|
async pick(login) { pickLogin = login; await press('data-invite-pick'); },
|
|
194
|
+
// ── the guild half (task 1002303, R27) ───────────────────────────────
|
|
195
|
+
guildField: () => $('mInviteGuild'),
|
|
196
|
+
guildMsg: () => $('mInviteGuildMsg'),
|
|
197
|
+
async inviteGuild(value) {
|
|
198
|
+
$('mInviteGuild').value = value;
|
|
199
|
+
const handlers = listeners.get(mount + ':click') || [];
|
|
200
|
+
assert.equal(handlers.length, 1, 'the guild button rides the mount’s one delegated click handler');
|
|
201
|
+
handlers[0]({ target: { closest: (sel) => (sel === '[data-invite-guild]' ? {} : null) } });
|
|
202
|
+
await settle();
|
|
203
|
+
},
|
|
204
|
+
async guildEnter() {
|
|
205
|
+
const handlers = listeners.get('mInviteGuild:keydown') || [];
|
|
206
|
+
assert.equal(handlers.length, 1, 'the guild field takes Enter');
|
|
207
|
+
handlers[0]({ key: 'Enter', preventDefault() {} });
|
|
208
|
+
await settle();
|
|
209
|
+
},
|
|
189
210
|
async enter() {
|
|
190
211
|
const handlers = listeners.get(m.who + ':keydown') || [];
|
|
191
212
|
assert.equal(handlers.length, 1, 'the field takes Enter');
|
|
@@ -708,3 +729,166 @@ test('the suggestion surface is mounted on BOTH the wizard and the project page'
|
|
|
708
729
|
assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
|
|
709
730
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
|
|
710
731
|
});
|
|
732
|
+
|
|
733
|
+
// ── invite a guild (task 1002303, R27; ADR 0365 D3) ──────────────────────────
|
|
734
|
+
// The project's own page names a public guild; the route records that the project
|
|
735
|
+
// invited it and answers one hall hand-off link per member on the guild's PUBLIC
|
|
736
|
+
// page — the people the inviter could see. Nothing is sent from the hub: each
|
|
737
|
+
// becomes the same pending line a single invite gets, finished on the hall.
|
|
738
|
+
|
|
739
|
+
const guildAnswer = (members = [['mara-quinn', 'Mara Quinn'], ['kip', null]], name = 'Forge Crew') => ({
|
|
740
|
+
ok: true, status: 200,
|
|
741
|
+
body: {
|
|
742
|
+
invited: false,
|
|
743
|
+
guild: { slug: 'forge-crew', name },
|
|
744
|
+
invites: members.map(([handle, display]) => ({ handle, display_name: display, invite_url: 'https://mercury.cloudbongos.test/builders/watch?invite=' + handle })),
|
|
745
|
+
count: members.length,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('inviteGuildSlug reads a guild the ways a person pastes one: its name, /g/<name>, or the page’s whole address', () => {
|
|
750
|
+
const f = boot().sandbox.inviteGuildSlug;
|
|
751
|
+
assert.equal(f('forge-crew'), 'forge-crew');
|
|
752
|
+
assert.equal(f(' Forge-Crew '), 'forge-crew', 'trimmed and lowercased, as the slug rule is');
|
|
753
|
+
assert.equal(f('/g/forge-crew'), 'forge-crew');
|
|
754
|
+
assert.equal(f('g/forge-crew/'), 'forge-crew');
|
|
755
|
+
assert.equal(f('https://cloudbongos.com/g/Forge-Crew/?ref=x#members'), 'forge-crew');
|
|
756
|
+
assert.equal(f(''), '');
|
|
757
|
+
assert.equal(f(null), '');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test('a guild invite sends exactly { client_id, guild } to /projects/invite-guild, on the cookie session', async () => {
|
|
761
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
|
|
762
|
+
await ui.open('mercury');
|
|
763
|
+
|
|
764
|
+
await ui.inviteGuild('https://cloudbongos.com/g/forge-crew');
|
|
765
|
+
|
|
766
|
+
assert.equal(ui.requests.length, 1);
|
|
767
|
+
const req = ui.requests[0];
|
|
768
|
+
assert.equal(req.url, '/api/bongos/projects/invite-guild');
|
|
769
|
+
assert.equal(req.method, 'POST');
|
|
770
|
+
assert.equal(req.credentials, 'same-origin');
|
|
771
|
+
assert.deepEqual(JSON.parse(req.body), { client_id: 'mercury', guild: 'forge-crew' }, 'the slug the route reads, and nothing else');
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test('the answer is one hand-off line per member on the guild’s public page — the same lines a single invite gets', async () => {
|
|
775
|
+
// a hand-off link that is not http(s) is never drawn, whoever it belongs to
|
|
776
|
+
const queued = guildAnswer([['mara-quinn', 'Mara Quinn'], ['kip', null]]);
|
|
777
|
+
queued.body.invites.push({ handle: 'bad', display_name: 'Bad Link', invite_url: 'javascript:alert(1)' });
|
|
778
|
+
const h = boot({ mount: 'mInvite', answers: [queued] });
|
|
779
|
+
await h.open('mercury');
|
|
780
|
+
|
|
781
|
+
await h.inviteGuild('forge-crew');
|
|
782
|
+
|
|
783
|
+
assert.deepEqual(h.rows().map((r) => r.login), ['Mara Quinn', 'kip'], 'the display name the guild page shows, else the handle');
|
|
784
|
+
assert.deepEqual(h.rows().map((r) => r.url), [
|
|
785
|
+
'https://mercury.cloudbongos.test/builders/watch?invite=mara-quinn',
|
|
786
|
+
'https://mercury.cloudbongos.test/builders/watch?invite=kip',
|
|
787
|
+
], 'each link is the route’s own invite_url, verbatim');
|
|
788
|
+
for (const r of h.rows()) assert.match(r.said, /not sent yet/i, 'no line claims an invite the hall has not sent');
|
|
789
|
+
assert.doesNotMatch(h.sandbox.$('mInviteList').innerHTML, /javascript:/);
|
|
790
|
+
assert.equal(h.guildMsg().className, 'projMsg ok');
|
|
791
|
+
assert.equal(h.guildMsg().textContent, 'Forge Crew: 2 members to invite. Send each invite from your project’s hall — every line above opens it with their name filled in.');
|
|
792
|
+
assert.equal(h.guildField().value, '', 'the field clears for the next one');
|
|
793
|
+
assert.equal(h.msg().textContent, '', 'the single-invite line is left alone');
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('inviting the same guild twice draws no second line for anyone already on one', async () => {
|
|
797
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer(), guildAnswer()] });
|
|
798
|
+
await ui.open('mercury');
|
|
799
|
+
|
|
800
|
+
await ui.inviteGuild('forge-crew');
|
|
801
|
+
await ui.inviteGuild('forge-crew');
|
|
802
|
+
|
|
803
|
+
assert.deepEqual(ui.rows().map((r) => r.login), ['Mara Quinn', 'kip']);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('a guild with nobody on its public page says so plainly, and draws nothing', async () => {
|
|
807
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer([])] });
|
|
808
|
+
await ui.open('mercury');
|
|
809
|
+
|
|
810
|
+
await ui.inviteGuild('forge-crew');
|
|
811
|
+
|
|
812
|
+
assert.equal(ui.rows().length, 0);
|
|
813
|
+
assert.equal(ui.guildMsg().className, 'projMsg', 'an answer, not a failure');
|
|
814
|
+
assert.equal(ui.guildMsg().textContent, 'Nobody is listed on Forge Crew’s public page yet, so there is no one to invite from here.');
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('an unknown guild and an unlisted one are ONE plain line; the other refusals keep the widget’s vocabulary', async () => {
|
|
818
|
+
const ui = boot({ mount: 'mInvite', answers: [
|
|
819
|
+
refused(404, 'guild_not_found', 'guild_not_found'),
|
|
820
|
+
refused(403, 'permission_forbidden', 'requires project.curate'),
|
|
821
|
+
refused(401, 'unauthenticated'),
|
|
822
|
+
{ reject: true },
|
|
823
|
+
refused(429, 'rate_limited', 'Too many invitations right now — give it a minute.'),
|
|
824
|
+
] });
|
|
825
|
+
await ui.open('mercury');
|
|
826
|
+
|
|
827
|
+
await ui.inviteGuild('night-shift');
|
|
828
|
+
assert.equal(ui.guildMsg().textContent, 'No public guild at that name.');
|
|
829
|
+
assert.equal(ui.guildMsg().className, 'projMsg err');
|
|
830
|
+
assert.equal(ui.guildField().value, 'night-shift', 'a refusal keeps what was typed');
|
|
831
|
+
|
|
832
|
+
await ui.inviteGuild('forge-crew');
|
|
833
|
+
assert.match(ui.guildMsg().textContent, /can’t invite/i, 'the 403 said the careful way (ADR 0248)');
|
|
834
|
+
assert.doesNotMatch(ui.guildMsg().textContent, /project\.curate/);
|
|
835
|
+
|
|
836
|
+
await ui.inviteGuild('forge-crew');
|
|
837
|
+
assert.match(ui.guildMsg().innerHTML, /sign in again/i);
|
|
838
|
+
|
|
839
|
+
await ui.inviteGuild('forge-crew');
|
|
840
|
+
assert.match(ui.guildMsg().textContent, /reach the platform/i);
|
|
841
|
+
|
|
842
|
+
await ui.inviteGuild('forge-crew');
|
|
843
|
+
assert.match(ui.guildMsg().textContent, /Too many invitations right now/);
|
|
844
|
+
assert.equal(ui.rows().length, 0, 'no refusal draws a line');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test('an empty guild field is a question rather than a request; Enter in the field sends too', async () => {
|
|
848
|
+
const ui = boot({ mount: 'mInvite', answers: [guildAnswer()] });
|
|
849
|
+
await ui.open('mercury');
|
|
850
|
+
|
|
851
|
+
await ui.inviteGuild(' ');
|
|
852
|
+
assert.equal(ui.requests.length, 0);
|
|
853
|
+
assert.equal(ui.guildMsg().textContent, 'Type the guild’s name or its /g/ address.');
|
|
854
|
+
assert.ok(ui.focused.includes('mInviteGuild'), 'focus goes to the field it asks about');
|
|
855
|
+
|
|
856
|
+
ui.guildField().value = 'forge-crew';
|
|
857
|
+
await ui.guildEnter();
|
|
858
|
+
assert.equal(ui.requests.length, 1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('the guild door is the project page’s alone: the wizard’s step has none, and a closed mount sends nothing', async () => {
|
|
862
|
+
const wiz = boot({ mount: 'wizInvite' });
|
|
863
|
+
await wiz.open('mercury');
|
|
864
|
+
await wiz.inviteGuild('forge-crew');
|
|
865
|
+
assert.equal(wiz.requests.length, 0, 'the wizard mount names no guild field');
|
|
866
|
+
|
|
867
|
+
const closed = boot({ mount: 'mInvite' });
|
|
868
|
+
await closed.open('');
|
|
869
|
+
await closed.inviteGuild('forge-crew');
|
|
870
|
+
assert.equal(closed.requests.length, 0, 'no project, no request');
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('a new project starts the guild line clean too', async () => {
|
|
874
|
+
const ui = boot({ mount: 'mInvite', answers: [refused(404, 'guild_not_found')] });
|
|
875
|
+
await ui.open('mercury');
|
|
876
|
+
await ui.inviteGuild('night-shift');
|
|
877
|
+
assert.notEqual(ui.guildMsg().textContent, '');
|
|
878
|
+
|
|
879
|
+
await ui.open('venus');
|
|
880
|
+
assert.equal(ui.guildMsg().textContent, '', 'the answer belonged to the project it was asked for');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('the project’s own page carries the guild door: a field, its button, and the one-line why — as real markup', () => {
|
|
884
|
+
const sec = (HUB.match(/<div class="card mSec" id="mInvite">[\s\S]*?\n <\/div>/) || [''])[0];
|
|
885
|
+
assert.match(sec, /<div class="attach">\s*<label class="vh" for="mInviteGuild">Guild name or address<\/label>\s*<input type="text" id="mInviteGuild"/, 'the page’s own input+button idiom');
|
|
886
|
+
assert.match(sec, /data-invite-guild="mInvite"/);
|
|
887
|
+
assert.match(sec, /Each member gets their own invite, and only the members shown on the guild’s public page are listed\./);
|
|
888
|
+
assert.match(sec, /<div class="projMsg" id="mInviteGuildMsg" role="status" aria-live="polite"><\/div>/, 'its answer has a line of its own, under the button');
|
|
889
|
+
assert.ok(sec.indexOf('id="mInviteGuild"') > sec.indexOf('id="mInviteList"'), 'below the pending lines it fills — "every line above"');
|
|
890
|
+
const panel = (HUB.match(/<section class="panel" id="panel-done"[\s\S]*?<\/section>/) || [''])[0];
|
|
891
|
+
assert.doesNotMatch(panel, /data-invite-guild/, 'the wizard’s step invites people, not guilds');
|
|
892
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
893
|
+
assert.ok(states['in-manage-invite-guild'], 'the guild lines have a kit state');
|
|
894
|
+
});
|
package/tests/render_check.mjs
CHANGED
|
@@ -24,8 +24,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
|
|
|
24
24
|
// sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
|
|
25
25
|
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
|
|
26
26
|
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
|
|
27
|
-
// the account page links the hub's sheet too since task 1004259
|
|
28
|
-
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'projects.states.json']);
|
|
27
|
+
// the account page links the hub's sheet too since task 1004259, and the guild page since task 1002301
|
|
28
|
+
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'guild.states.json', 'projects.states.json']);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
|
package/tests/ui_design_kit.mjs
CHANGED
|
@@ -34,7 +34,7 @@ test('the platform floors are a pinned list — a floor cannot vanish quietly',
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test('the shipped states files validate and sit beside the page they name', () => {
|
|
37
|
-
for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/projects-dns-pending.states.json', 'modules/public-landing/public/projects-hall-live.states.json','modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/hall-ui/public/ranks.states.json']) {
|
|
37
|
+
for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/projects-empty.states.json', 'modules/public-landing/public/projects-dns-pending.states.json', 'modules/public-landing/public/projects-hall-live.states.json','modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/account-private.states.json', 'modules/public-landing/public/guild.states.json', 'modules/hall-ui/public/ranks.states.json']) {
|
|
38
38
|
const json = readJson(rel);
|
|
39
39
|
const v = lib.validateStatesFile(json);
|
|
40
40
|
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
@@ -43,14 +43,14 @@ test('the shipped states files validate and sit beside the page they name', () =
|
|
|
43
43
|
}
|
|
44
44
|
// the apex is dark only (task 1003336): its states files name the one mode, and
|
|
45
45
|
// render.js reads it as the default so a plain `--page /` never renders light
|
|
46
|
-
for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json']) {
|
|
46
|
+
for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/guild.states.json']) {
|
|
47
47
|
assert.deepEqual(readJson(rel).modes, ['dark'], `${rel}: the apex renders dark only`);
|
|
48
48
|
assert.equal(readJson(rel).modeQuery, false, `${rel}: no query walks a second mode`);
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
test('the shipped probes files validate', () => {
|
|
53
|
-
for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json']) {
|
|
53
|
+
for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json', 'modules/public-landing/public/guild.probes.json']) {
|
|
54
54
|
const v = lib.validateProbesFile(readJson(rel));
|
|
55
55
|
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
56
56
|
}
|