@bongos/core 1.21.65 → 1.21.67
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 +114 -49
- package/docs/copy-inventory.md +432 -405
- package/docs/copy-registry.json +840 -571
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +45 -13
- package/docs/page-readings.json +969 -917
- package/modules/hall-ui/public/profile.html +4 -7
- package/modules/hall-ui/public/profile.js +0 -5
- 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 +956 -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/canonical_profile_url.mjs +23 -157
- 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 +414 -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
- package/modules/hall-ui/public/profile-canonical.js +0 -137
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
// OWN WHERE CLAUSE and throws on a placeholder or a literal comparison it did
|
|
10
10
|
// not consume, so a predicate this file cannot read can never degrade into "no
|
|
11
11
|
// constraint" and quietly pass. The REAL core router runs above it over a REAL
|
|
12
|
-
// loopback socket
|
|
13
|
-
// Nothing here asserts against a canned response.
|
|
12
|
+
// loopback socket. Nothing here asserts against a canned response.
|
|
14
13
|
//
|
|
15
|
-
// THE FLAG IS FALSE
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
14
|
+
// THE FLAG IS FALSE (ADR 0214 D1), and the page that would flip it (goal
|
|
15
|
+
// 1000048 R14, task 1002365) was abandoned. So the browser half that would have
|
|
16
|
+
// rendered rel=canonical, the link-out and the numeric-id redirect was removed
|
|
17
|
+
// (task 1003451): it shipped 137 lines of permanently dead branches to every
|
|
18
|
+
// /profile reader. The server half stays — getPublicHandle is live, and the
|
|
19
|
+
// resolver is what a rebuilt page will call. The test below pins the absence,
|
|
20
|
+
// and fails the moment the flag flips without the browser half coming back.
|
|
20
21
|
//
|
|
21
22
|
// LANE: DB-free. It must run on every PR, where there is no Postgres. The last
|
|
22
23
|
// test asserts that membership from the runner's own source (learning 1000218 —
|
|
@@ -26,7 +27,6 @@
|
|
|
26
27
|
import { test } from 'node:test';
|
|
27
28
|
import assert from 'node:assert/strict';
|
|
28
29
|
import http from 'node:http';
|
|
29
|
-
import vm from 'node:vm';
|
|
30
30
|
import express from 'express';
|
|
31
31
|
import { createRequire } from 'node:module';
|
|
32
32
|
import { readFileSync } from 'node:fs';
|
|
@@ -48,7 +48,6 @@ const require = createRequire(import.meta.url);
|
|
|
48
48
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
49
|
const read = (...p) => readFileSync(join(ROOT, ...p), 'utf8');
|
|
50
50
|
|
|
51
|
-
const CLIENT_SRC = read('modules', 'hall-ui', 'public', 'profile-canonical.js');
|
|
52
51
|
const MODULE_SRC = read('modules', 'platform-identity', 'canonical-profile.js');
|
|
53
52
|
const PROFILE_JS = read('modules', 'hall-ui', 'public', 'profile.js');
|
|
54
53
|
const PROFILE_HTML = read('modules', 'hall-ui', 'public', 'profile.html');
|
|
@@ -429,154 +428,21 @@ test('an older hub provider without getPublicHandle degrades to null instead of
|
|
|
429
428
|
test.after(() => server.close());
|
|
430
429
|
|
|
431
430
|
// ===========================================================================
|
|
432
|
-
// The
|
|
431
|
+
// The wiring: the page and the stamp
|
|
433
432
|
// ===========================================================================
|
|
434
433
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
function boot(src, { hubOriginMeta, modules, search } = {}) {
|
|
444
|
-
const head = { children: [], appendChild(el) { this.children.push(el); } };
|
|
445
|
-
const els = new Map();
|
|
446
|
-
const el = (id) => {
|
|
447
|
-
if (!els.has(id)) els.set(id, { id, hidden: true, _html: '', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; }, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } });
|
|
448
|
-
return els.get(id);
|
|
449
|
-
};
|
|
450
|
-
const meta = hubOriginMeta === undefined ? null : { getAttribute: () => hubOriginMeta };
|
|
451
|
-
const replaced = [];
|
|
452
|
-
const assigned = [];
|
|
453
|
-
const sandbox = {
|
|
454
|
-
console: { error() {}, warn() {}, log() {} },
|
|
455
|
-
URL,
|
|
456
|
-
URLSearchParams,
|
|
457
|
-
document: {
|
|
458
|
-
head,
|
|
459
|
-
querySelector: (sel) => (sel === 'meta[name="hub-origin"]' ? meta : (head.children.find((c) => c.attrs.rel === 'canonical') || null)),
|
|
460
|
-
getElementById: (id) => (id === 'xp-scroll' || id === 'xp-body' ? el(id) : null),
|
|
461
|
-
createElement: () => ({ attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; } }),
|
|
462
|
-
},
|
|
463
|
-
// `assign` is stubbed too, and deliberately records into a SEPARATE list:
|
|
464
|
-
// swapping replace→assign must fail on the assertion (the numeric URL would
|
|
465
|
-
// stay in history as a place Back returns to), not crash on an undefined
|
|
466
|
-
// method, which would "pass" the mutation check for the wrong reason.
|
|
467
|
-
location: { search: search || '', replace: (u) => replaced.push(u), assign: (u) => assigned.push(u), href: '' },
|
|
468
|
-
window: { OTB: { escapeHtml: (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }, __MODULES__: modules || [] },
|
|
469
|
-
};
|
|
470
|
-
sandbox.globalThis = sandbox;
|
|
471
|
-
vm.createContext(sandbox);
|
|
472
|
-
vm.runInContext(src, sandbox, { filename: 'profile-canonical.js' });
|
|
473
|
-
return {
|
|
474
|
-
api: sandbox.window.OTBCanonical,
|
|
475
|
-
canonicalHref: () => { const l = head.children.find((c) => c.attrs.rel === 'canonical'); return l ? l.attrs.href : null; },
|
|
476
|
-
xp: () => el('xp-scroll'),
|
|
477
|
-
xpHtml: () => el('xp-body').innerHTML,
|
|
478
|
-
replaced,
|
|
479
|
-
assigned,
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
const FEDERATED = { hubOriginMeta: 'https://cloudbongos.com', modules: ['lifecycle', 'hall-ui'] };
|
|
484
|
-
const HUB = { hubOriginMeta: 'https://cloudbongos.com', modules: ['platform-identity', 'hall-ui'] };
|
|
485
|
-
|
|
486
|
-
test('DARK today: with the shipped source nothing is emitted, anywhere', () => {
|
|
487
|
-
const fed = boot(CLIENT_SRC, FEDERATED);
|
|
488
|
-
fed.api.render({ handle: 'ada' });
|
|
489
|
-
assert.equal(fed.canonicalHref(), null, 'a canonical must not name a URL that 404s');
|
|
490
|
-
assert.equal(fed.xp().hidden, true);
|
|
491
|
-
const hub = boot(CLIENT_SRC, { ...HUB, search: '?id=7' });
|
|
492
|
-
hub.api.render({ handle: 'ada' });
|
|
493
|
-
assert.deepEqual(hub.replaced, [], 'redirecting a working page to a 404 would be the worst of the three');
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
test('LIVE, federated: rel=canonical points at the hub and the link-out is visible', () => {
|
|
497
|
-
const page = boot(LIVE_SRC, FEDERATED);
|
|
498
|
-
page.api.render({ handle: 'ada' });
|
|
499
|
-
assert.equal(page.canonicalHref(), 'https://cloudbongos.com/u/ada');
|
|
500
|
-
assert.equal(page.xp().hidden, false);
|
|
501
|
-
assert.match(page.xpHtml(), /href="https:\/\/cloudbongos\.com\/u\/ada"/);
|
|
502
|
-
assert.match(page.xpHtml(), /See the cross-project record/,
|
|
503
|
-
'the link-out is what ADR 0171 D1 asks a federated instance to render instead of inlining numbers');
|
|
504
|
-
assert.deepEqual(page.replaced, [], 'a federated instance shows its OWN record — it must never redirect away from it');
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
test('LIVE, hub: the numeric ?id= form redirects to the canonical handle URL', () => {
|
|
508
|
-
const page = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
|
|
509
|
-
page.api.render({ handle: 'ada' });
|
|
510
|
-
assert.deepEqual(page.replaced, ['https://cloudbongos.com/u/ada']);
|
|
511
|
-
assert.equal(page.canonicalHref(), null, 'a page that redirects must not also declare a canonical');
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
test('LIVE, hub without ?id=: the self view is not redirected', () => {
|
|
515
|
-
const page = boot(LIVE_SRC, { ...HUB, search: '' });
|
|
516
|
-
page.api.render({ handle: 'ada' });
|
|
517
|
-
assert.deepEqual(page.replaced, [], 'the handle-less /profile is the viewer\'s own page, not a numeric address');
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
test('LIVE: no handle means no canonical, no link-out and NO REDIRECT — the fallback is a working page', () => {
|
|
521
|
-
for (const data of [{}, { handle: null }, { handle: '' }]) {
|
|
522
|
-
const fed = boot(LIVE_SRC, FEDERATED);
|
|
523
|
-
fed.api.render(data);
|
|
524
|
-
assert.equal(fed.canonicalHref(), null);
|
|
525
|
-
assert.equal(fed.xp().hidden, true);
|
|
526
|
-
|
|
527
|
-
const hub = boot(LIVE_SRC, { ...HUB, search: '?id=7' });
|
|
528
|
-
hub.api.render(data);
|
|
529
|
-
assert.deepEqual(hub.replaced, [],
|
|
530
|
-
'absent, provisional and private all arrive here as a missing handle and all get the ordinary local page — there is no error shape to read a signal off');
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
test('LIVE: a vanilla instance with no hub origin emits nothing', () => {
|
|
535
|
-
for (const meta of [undefined, '', ' ', 'http://cloudbongos.com', 'https://cloudbongos.com/u']) {
|
|
536
|
-
const page = boot(LIVE_SRC, { hubOriginMeta: meta, modules: [] });
|
|
537
|
-
page.api.render({ handle: 'ada' });
|
|
538
|
-
assert.equal(page.canonicalHref(), null, `an unusable hub-origin meta (${JSON.stringify(meta)}) must produce no canonical, never a relative one`);
|
|
539
|
-
assert.equal(page.xp().hidden, true);
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test('LIVE: a hostile handle from the wire never reaches an href', () => {
|
|
544
|
-
for (const handle of ['ada/../../admin', 'ada"onload=x', '../evil', 'a b']) {
|
|
545
|
-
const page = boot(LIVE_SRC, FEDERATED);
|
|
546
|
-
page.api.render({ handle });
|
|
547
|
-
assert.equal(page.canonicalHref(), null, `handle ${JSON.stringify(handle)} must be refused, not encoded`);
|
|
548
|
-
}
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
// ===========================================================================
|
|
552
|
-
// The wiring: the mirror, the page, and the stamp
|
|
553
|
-
// ===========================================================================
|
|
554
|
-
|
|
555
|
-
test('the client mirror and the module agree on both constants', () => {
|
|
556
|
-
const liveInModule = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(MODULE_SRC);
|
|
557
|
-
const liveInClient = /const CANONICAL_PROFILE_PAGE_LIVE = (true|false);/.exec(CLIENT_SRC);
|
|
558
|
-
assert.ok(liveInModule && liveInClient, 'both files must spell the flag as a plain const so this guard can read it');
|
|
559
|
-
assert.equal(liveInClient[1], liveInModule[1],
|
|
560
|
-
'the browser mirror has drifted from modules/platform-identity/canonical-profile.js — flip both or neither');
|
|
561
|
-
assert.equal(String(CANONICAL_PROFILE_PAGE_LIVE), liveInModule[1], 'the module export disagrees with its own source');
|
|
562
|
-
|
|
563
|
-
const prefixInClient = /const CANONICAL_PROFILE_PREFIX = '([^']+)';/.exec(CLIENT_SRC);
|
|
564
|
-
assert.ok(prefixInClient, 'the client must spell the prefix as a plain const');
|
|
565
|
-
assert.equal(prefixInClient[1], CANONICAL_PROFILE_PREFIX, 'the two canonical namespaces have drifted apart');
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
test('the profile page loads the script BEFORE profile.js, and profile.js calls it', () => {
|
|
569
|
-
// Match the <script> TAGS, not the filenames: both names also appear in the
|
|
570
|
-
// page's prose, and an ordering assertion that reads a comment proves nothing.
|
|
434
|
+
test('while the page is dark, /profile ships no canonical wiring at all (task 1003451)', () => {
|
|
435
|
+
// The browser half (profile-canonical.js) was removed when R14 — the
|
|
436
|
+
// /u/<handle> page that would have turned it on — was abandoned: 137 lines
|
|
437
|
+
// whose every branch sat behind a false flag, shipped to every /profile
|
|
438
|
+
// reader. Rebuild it with the page. Until then the module's flag is the one
|
|
439
|
+
// statement of "dark", and nothing in the hall may branch on it.
|
|
440
|
+
assert.equal(CANONICAL_PROFILE_PAGE_LIVE, false,
|
|
441
|
+
'the flag flipped: the /u/<handle> page now resolves, so the browser half (rel=canonical, link-out, numeric-id redirect) has to be rebuilt in this same change');
|
|
571
442
|
const tags = [...PROFILE_HTML.matchAll(/<script\s+src="\/builders\/([a-z-]+\.js)/g)].map((m) => m[1]);
|
|
572
|
-
assert.ok(tags.includes('profile
|
|
573
|
-
|
|
574
|
-
assert.
|
|
575
|
-
tags.indexOf('profile-canonical.js') < tags.indexOf('profile.js'),
|
|
576
|
-
`profile.js must load AFTER profile-canonical.js so window.OTBCanonical exists when renderProfile runs — got ${tags.join(', ')}`,
|
|
577
|
-
);
|
|
578
|
-
assert.match(PROFILE_JS, /window\.OTBCanonical\.render\(data\)/,
|
|
579
|
-
'profile.js must hand the profile response to the canonical wiring');
|
|
443
|
+
assert.ok(tags.includes('profile.js'), 'the tag reader matched nothing, so the next assertion would pass vacuously');
|
|
444
|
+
assert.ok(!tags.includes('profile-canonical.js'), 'the page loads a canonical script whose every branch is dead');
|
|
445
|
+
assert.doesNotMatch(PROFILE_JS, /OTBCanonical/, 'profile.js branches on a canonical wiring that does not exist');
|
|
580
446
|
});
|
|
581
447
|
|
|
582
448
|
test('the hub origin is stamped as a meta, and never straight into an href', () => {
|
|
@@ -593,9 +459,9 @@ test('the hub origin is stamped as a meta, and never straight into an href', ()
|
|
|
593
459
|
});
|
|
594
460
|
|
|
595
461
|
test('the resolver is the ONLY place that spells the canonical namespace', () => {
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
const owners = ['modules/platform-identity/canonical-profile.js'
|
|
462
|
+
// One file carries it: the module. A second would be a second opinion
|
|
463
|
+
// about where a profile lives.
|
|
464
|
+
const owners = ['modules/platform-identity/canonical-profile.js'];
|
|
599
465
|
const offenders = [];
|
|
600
466
|
const walk = (rel) => {
|
|
601
467
|
for (const e of require('node:fs').readdirSync(join(ROOT, rel), { withFileTypes: true })) {
|
|
@@ -134,7 +134,7 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
|
|
|
134
134
|
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
135
135
|
], 'this page has no views of its own, so every item names /projects in full');
|
|
136
136
|
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
|
|
137
|
-
['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
|
|
137
|
+
['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
|
|
138
138
|
]);
|
|
139
139
|
assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/account" aria-current="page">Account']);
|
|
140
140
|
});
|
|
@@ -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\)/);
|