@bongos/core 1.21.82 → 1.21.83
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 +43 -28
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +7 -0
- package/docs/page-readings.json +640 -639
- package/modules/platform-identity/invite-notices.js +78 -10
- package/modules/platform-identity/migrations/platform_identity_031_parked_invite_notices.sql +45 -0
- package/modules/platform-identity/routes/my-projects.js +7 -3
- package/modules/public-landing/public/account.html +1 -0
- package/modules/public-landing/public/assets/cosmos.css +8 -0
- package/modules/public-landing/public/assets/invite-badge.js +91 -0
- package/modules/public-landing/public/contact.html +1 -0
- package/modules/public-landing/public/guild.html +1 -0
- package/modules/public-landing/public/invitations.html +7 -2
- package/modules/public-landing/public/privacy.html +1 -0
- package/modules/public-landing/public/projects.html +3 -0
- package/modules/public-landing/public/terms.html +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/hub_invite_badge.mjs +204 -0
- package/tests/hub_invite_notices.mjs +61 -6
- package/tests/landing_legal_pages.mjs +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// tests/hub_invite_badge.mjs — what is waiting on you, said where you already look
|
|
2
|
+
// (task 1004646; the owner's report of 2026-10-08 that invitations "aren't obvious").
|
|
3
|
+
//
|
|
4
|
+
// The REAL assets/invite-badge.js, executed in a node:vm under a small DOM stub: the
|
|
5
|
+
// count on the identity chip and on the profile band's Invitations item, the line on
|
|
6
|
+
// the mine view, nothing at all when nothing waits or nobody is signed in, and no
|
|
7
|
+
// read at all on a child instance (its Invitations item is stripped as apex-only).
|
|
8
|
+
// Then the pages: every page that wears the hub's bar loads it.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/hub_invite_badge.mjs
|
|
11
|
+
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
21
|
+
const BADGE = fs.readFileSync(path.join(LANDING, 'assets', 'invite-badge.js'), 'utf8');
|
|
22
|
+
|
|
23
|
+
// ── a DOM just big enough for this file ──────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
class El {
|
|
26
|
+
constructor(tag, attrs = {}) {
|
|
27
|
+
this.tagName = tag.toUpperCase();
|
|
28
|
+
this.children = [];
|
|
29
|
+
this.parentNode = null;
|
|
30
|
+
this.attrs = { ...attrs };
|
|
31
|
+
this.className = attrs.class || '';
|
|
32
|
+
this.id = attrs.id || '';
|
|
33
|
+
this._text = '';
|
|
34
|
+
}
|
|
35
|
+
get firstChild() { return this.children[0] || null; }
|
|
36
|
+
get lastChild() { return this.children[this.children.length - 1] || null; }
|
|
37
|
+
get nextSibling() { const k = this.parentNode.children; return k[k.indexOf(this) + 1] || null; }
|
|
38
|
+
set textContent(v) { this.children = []; this._text = String(v); }
|
|
39
|
+
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
|
|
40
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
41
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
42
|
+
set href(v) { this.attrs.href = v; }
|
|
43
|
+
get href() { return this.attrs.href; }
|
|
44
|
+
appendChild(c) { return this.insertBefore(c, null); }
|
|
45
|
+
insertBefore(c, ref) {
|
|
46
|
+
if (c.parentNode) c.remove();
|
|
47
|
+
c.parentNode = this;
|
|
48
|
+
const i = ref ? this.children.indexOf(ref) : -1;
|
|
49
|
+
if (i < 0) this.children.push(c); else this.children.splice(i, 0, c);
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
remove() { const p = this.parentNode; if (p) p.children.splice(p.children.indexOf(this), 1); this.parentNode = null; }
|
|
53
|
+
*walk() { for (const c of this.children) { yield c; yield* c.walk(); } }
|
|
54
|
+
matches(sel) {
|
|
55
|
+
if (sel === '.invBadge') return this.className.split(' ').includes('invBadge');
|
|
56
|
+
if (sel === '.hero') return this.className === 'hero';
|
|
57
|
+
if (sel === 'svg.cv') return this.tagName === 'SVG' && this.className === 'cv';
|
|
58
|
+
if (sel === 'a[href="/invitations"]') return this.tagName === 'A' && this.attrs.href === '/invitations';
|
|
59
|
+
throw new Error(`stub selector: ${sel}`);
|
|
60
|
+
}
|
|
61
|
+
querySelector(sel) { for (const n of this.walk()) if (n.matches(sel)) return n; return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function world({ child = false, mineView = false } = {}) {
|
|
65
|
+
const body = new El('body');
|
|
66
|
+
const who = body.appendChild(new El('button', { id: 'who', class: 'trig who' }));
|
|
67
|
+
who.appendChild(new El('span', { class: 'whoName' }));
|
|
68
|
+
who.appendChild(new El('svg', { class: 'cv' }));
|
|
69
|
+
const band = body.appendChild(new El('div', { id: 'menuProfile' }));
|
|
70
|
+
const mine = band.appendChild(new El('a', { href: '/projects?view=mine' }));
|
|
71
|
+
mine.textContent = 'My projects';
|
|
72
|
+
let inv = null;
|
|
73
|
+
if (!child) { inv = band.appendChild(new El('a', { href: '/invitations', class: 'bandT' })); inv.textContent = 'Invitations'; }
|
|
74
|
+
let section = null;
|
|
75
|
+
if (mineView) {
|
|
76
|
+
const view = body.appendChild(new El('section', { id: 'view-mine' }));
|
|
77
|
+
section = view.appendChild(new El('div', { class: 'hero' }));
|
|
78
|
+
section.appendChild(new El('h1')).textContent = 'Your projects';
|
|
79
|
+
section.appendChild(new El('p', { class: 'lede' }));
|
|
80
|
+
view.appendChild(new El('ul', { id: 'projectsList' }));
|
|
81
|
+
}
|
|
82
|
+
const byId = (id) => { for (const n of body.walk()) if (n.id === id) return n; return null; };
|
|
83
|
+
const document = { getElementById: byId, createElement: (t) => new El(t) };
|
|
84
|
+
return { body, who, inv, section, document, byId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function run(w, answers) {
|
|
88
|
+
const asked = [];
|
|
89
|
+
const fetch = async (url) => {
|
|
90
|
+
asked.push(url);
|
|
91
|
+
const a = answers[url];
|
|
92
|
+
if (a === undefined) return { ok: false, status: 404, json: async () => ({}) };
|
|
93
|
+
if (a instanceof Error) throw a;
|
|
94
|
+
return { ok: a.status === undefined, status: a.status || 200, json: async () => a };
|
|
95
|
+
};
|
|
96
|
+
const window = {};
|
|
97
|
+
vm.runInNewContext(BADGE, { window, document: w.document, fetch, Promise });
|
|
98
|
+
await window.InviteBadge.refresh();
|
|
99
|
+
return { asked, window };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const INVITES = '/api/bongos/my-projects/invites';
|
|
103
|
+
const GUILDS = '/api/bongos/me/guild-requests';
|
|
104
|
+
const badgeOf = (el) => el && el.querySelector('.invBadge');
|
|
105
|
+
|
|
106
|
+
// ── the count ────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test('project invites and guild asks add up, on the chip and on the Invitations item', async () => {
|
|
109
|
+
const w = world();
|
|
110
|
+
await run(w, {
|
|
111
|
+
[INVITES]: { invites: [{ client_id: 'a' }, { client_id: 'b' }] },
|
|
112
|
+
[GUILDS]: { incoming: [{ id: '1' }], owner_incoming: [{ id: '2' }], outgoing: [{ id: '3' }] },
|
|
113
|
+
});
|
|
114
|
+
for (const host of [w.who, w.inv]) {
|
|
115
|
+
const b = badgeOf(host);
|
|
116
|
+
assert.ok(b, 'a badge is drawn');
|
|
117
|
+
assert.equal(b.firstChild.textContent, '4', 'two project invites, a guild invite and a request to a guild you own');
|
|
118
|
+
assert.equal(b.firstChild.getAttribute('aria-hidden'), 'true');
|
|
119
|
+
assert.equal(b.lastChild.className, 'vh');
|
|
120
|
+
assert.equal(b.lastChild.textContent, ', 4 invitations waiting', 'a screen reader hears what the number means');
|
|
121
|
+
}
|
|
122
|
+
assert.equal(w.who.children.indexOf(badgeOf(w.who)), 1, 'on the chip, before its chevron');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('one invitation reads in the singular, and a large count is capped', async () => {
|
|
126
|
+
let w = world();
|
|
127
|
+
await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
|
|
128
|
+
assert.equal(badgeOf(w.inv).lastChild.textContent, ', 1 invitation waiting');
|
|
129
|
+
w = world();
|
|
130
|
+
await run(w, { [INVITES]: { invites: Array.from({ length: 120 }, () => ({})) }, [GUILDS]: {} });
|
|
131
|
+
assert.equal(badgeOf(w.who).firstChild.textContent, '99+');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the mine view says it in a line that links to the invitations page', async () => {
|
|
135
|
+
const w = world({ mineView: true });
|
|
136
|
+
await run(w, { [INVITES]: { invites: [{}, {}] }, [GUILDS]: {} });
|
|
137
|
+
const p = w.byId('mineInvitesWaiting');
|
|
138
|
+
assert.ok(p, 'the line is drawn');
|
|
139
|
+
assert.equal(w.section.children[2], p, 'at the top of the view, under its heading and lede — not below a long list');
|
|
140
|
+
assert.equal(p.firstChild.tagName, 'A');
|
|
141
|
+
assert.equal(p.firstChild.href, '/invitations');
|
|
142
|
+
assert.equal(p.textContent, 'You have 2 invitations waiting →');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('nothing waiting, signed out, or a failed read: nothing is drawn', async () => {
|
|
146
|
+
for (const answers of [
|
|
147
|
+
{ [INVITES]: { invites: [] }, [GUILDS]: { incoming: [], owner_incoming: [] } },
|
|
148
|
+
{ [INVITES]: { status: 401 }, [GUILDS]: { status: 401 } },
|
|
149
|
+
{ [INVITES]: new Error('offline'), [GUILDS]: new Error('offline') },
|
|
150
|
+
]) {
|
|
151
|
+
const w = world({ mineView: true });
|
|
152
|
+
await run(w, answers);
|
|
153
|
+
assert.equal(badgeOf(w.who), null);
|
|
154
|
+
assert.equal(badgeOf(w.inv), null);
|
|
155
|
+
assert.equal(w.byId('mineInvitesWaiting'), null);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a refresh after an answer takes the badge and the line away again', async () => {
|
|
160
|
+
const w = world({ mineView: true });
|
|
161
|
+
const answers = { [INVITES]: { invites: [{}] }, [GUILDS]: {} };
|
|
162
|
+
const { window } = await run(w, answers);
|
|
163
|
+
assert.ok(badgeOf(w.who));
|
|
164
|
+
window.InviteBadge.paint(0);
|
|
165
|
+
assert.equal(badgeOf(w.who), null);
|
|
166
|
+
assert.equal(badgeOf(w.inv), null);
|
|
167
|
+
assert.equal(w.byId('mineInvitesWaiting'), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a child instance, with no Invitations item, reads nothing', async () => {
|
|
171
|
+
const w = world({ child: true });
|
|
172
|
+
const { asked } = await run(w, { [INVITES]: { invites: [{}] }, [GUILDS]: {} });
|
|
173
|
+
assert.deepEqual(asked, [], 'the hub-only routes are never asked');
|
|
174
|
+
assert.equal(badgeOf(w.who), null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the file builds nodes only and makes only its two reads', () => {
|
|
178
|
+
assert.doesNotMatch(BADGE, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
179
|
+
const calls = [...BADGE.matchAll(/readCount\('([^']+)'/g)].map((m) => m[1]);
|
|
180
|
+
assert.deepEqual(calls, ['/my-projects/invites', '/me/guild-requests']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── the pages ────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
test('every page that wears the hub bar loads it once, before the Artist review tag', () => {
|
|
186
|
+
for (const page of ['projects', 'account', 'invitations', 'guild', 'privacy', 'terms', 'contact']) {
|
|
187
|
+
const html = fs.readFileSync(path.join(LANDING, `${page}.html`), 'utf8');
|
|
188
|
+
const tags = html.match(/<script src="\/assets\/invite-badge\.js"><\/script>/g) || [];
|
|
189
|
+
assert.equal(tags.length, 1, `${page}: one tag`);
|
|
190
|
+
assert.ok(html.indexOf(tags[0]) < html.lastIndexOf('<script src="/assets/artist-review.js"'), `${page}: before the review button`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('the hub page repaints the count after it rebuilds the chip, and the invitations page after an answer', () => {
|
|
195
|
+
const hub = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
196
|
+
assert.match(hub, /renderWho\(\);\r?\n[^\n]*\r?\n\s*if \(window\.InviteBadge\) window\.InviteBadge\.refresh\(\);/);
|
|
197
|
+
const inv = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8');
|
|
198
|
+
assert.equal((inv.match(/recount\(\);/g) || []).length, 4, 'a decline, a guild decline, a guild accept and a request accepted');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the badge is the accent with its own ink, the .btn pairing', () => {
|
|
202
|
+
const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
203
|
+
assert.match(css, /\.invBadge\{[^}]*background:var\(--accent\);color:var\(--accent-on\);/);
|
|
204
|
+
});
|
|
@@ -55,6 +55,8 @@ const pool = {
|
|
|
55
55
|
}
|
|
56
56
|
if (/^\s*INSERT INTO platform_identity_project_memberships/.test(sql)) return { rows: [{ id: '1', membership_kind: 'pending' }] };
|
|
57
57
|
if (/^\s*DELETE FROM platform_identity_project_memberships/.test(sql)) return { rowCount: 1, rows: [] };
|
|
58
|
+
if (/^\s*INSERT INTO platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 1, rows: [] };
|
|
59
|
+
if (/^\s*DELETE FROM platform_identity_parked_invite_notices/.test(sql)) return { rowCount: 0, rows: [] };
|
|
58
60
|
throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
|
|
59
61
|
},
|
|
60
62
|
};
|
|
@@ -119,13 +121,15 @@ test('clear drops ONLY a pending row on the calling project', async () => {
|
|
|
119
121
|
assert.equal(res.status, 200);
|
|
120
122
|
assert.deepEqual(res.body, { ok: true });
|
|
121
123
|
const w = writes();
|
|
122
|
-
assert.equal(w.length,
|
|
123
|
-
assert.match(w[0].sql, /^\s*DELETE FROM
|
|
124
|
+
assert.equal(w.length, 2, 'the parked notice under the login, then the pending row on the account');
|
|
125
|
+
assert.match(w[0].sql, /^\s*DELETE FROM platform_identity_parked_invite_notices WHERE client_id = \$1 AND login_lower = \$2/);
|
|
126
|
+
assert.deepEqual(w[0].params, ['mercury', 'octo']);
|
|
127
|
+
assert.match(w[1].sql, /^\s*DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/,
|
|
124
128
|
'a rescind can never remove someone who has signed in');
|
|
125
|
-
assert.deepEqual(w[
|
|
129
|
+
assert.deepEqual(w[1].params, ['mercury', '4242']);
|
|
126
130
|
});
|
|
127
131
|
|
|
128
|
-
test('a login with no hub account
|
|
132
|
+
test('a login with no hub account is PARKED by login — and the answer is byte-identical (task 1004646)', async () => {
|
|
129
133
|
// The project must not learn from this route which GitHub logins have platform accounts.
|
|
130
134
|
for (const route of ['/sso/invites/notify', '/sso/invites/clear']) {
|
|
131
135
|
calls = [];
|
|
@@ -133,9 +137,60 @@ test('a login with no hub account writes nothing — and the answer is byte-iden
|
|
|
133
137
|
const unknown = await notice(route, { github_login: 'nobody-here' });
|
|
134
138
|
assert.deepEqual(unknown, known, `${route}: same status, same body`);
|
|
135
139
|
}
|
|
140
|
+
// Before task 1004646 this wrote nothing, so a new invitee — no account until they sign
|
|
141
|
+
// in to a project through the hub — never saw the invite. Now it waits under the login.
|
|
136
142
|
calls = [];
|
|
137
|
-
await notice('/sso/invites/notify', { github_login: '
|
|
138
|
-
|
|
143
|
+
await notice('/sso/invites/notify', { github_login: '@Nobody-Here' });
|
|
144
|
+
const w = writes();
|
|
145
|
+
assert.equal(w.length, 1);
|
|
146
|
+
assert.match(w[0].sql, /^\s*INSERT INTO platform_identity_parked_invite_notices \(client_id, login_lower\)/);
|
|
147
|
+
assert.match(w[0].sql, /ON CONFLICT \(client_id, login_lower\) DO UPDATE SET noted_at = now\(\)/, 'a re-invite restarts its 30 days in place');
|
|
148
|
+
assert.deepEqual(w[0].params, ['mercury', 'nobody-here'], 'the calling project, and the login folded: no @, lower case');
|
|
149
|
+
assert.equal(w.filter((x) => /project_memberships/.test(x.sql)).length, 0, 'no membership row for a login with no account');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the invitations read lists a parked notice for whoever signs in with that login', async () => {
|
|
153
|
+
const seen = [];
|
|
154
|
+
const readPool = {
|
|
155
|
+
async query(text, params = []) {
|
|
156
|
+
const sql = String(text);
|
|
157
|
+
seen.push({ sql, params });
|
|
158
|
+
if (/FROM platform_identity_project_memberships m\s+LEFT JOIN/.test(sql)) {
|
|
159
|
+
return { rows: [{ client_id: 'mercury', invited_at: 't2', project_name: 'Mercury', project_origin: 'https://mercury.test' }] };
|
|
160
|
+
}
|
|
161
|
+
if (/FROM platform_identity_parked_invite_notices n/.test(sql)) {
|
|
162
|
+
return { rows: [
|
|
163
|
+
{ client_id: 'mercury', invited_at: 't1', project_name: 'Mercury', project_origin: 'https://mercury.test' },
|
|
164
|
+
{ client_id: 'venus', invited_at: 't0', project_name: 'Venus', project_origin: 'https://venus.test' },
|
|
165
|
+
] };
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`unexpected statement: ${sql.trim().slice(0, 60)}`);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const out = await inviteNotices.listInvitesForBuilder({ githubId: '4242', githubLogin: 'Octo' }, { pool: readPool });
|
|
171
|
+
assert.deepEqual(out.map((r) => r.client_id), ['mercury', 'venus'], 'one row per project: the pending row wins over its parked twin');
|
|
172
|
+
const parked = seen.find((c) => /parked_invite_notices/.test(c.sql));
|
|
173
|
+
assert.deepEqual(parked.params, ['octo', 30, '4242'], 'keyed on the SESSION login, folded; the 30 days; the session account');
|
|
174
|
+
assert.match(parked.sql, /n\.noted_at > now\(\) - make_interval\(days => \$2\)/, 'a lapsed invite is not shown (ADR 0356)');
|
|
175
|
+
assert.match(parked.sql, /NOT EXISTS \([\s\S]*m\.github_id = \$3 AND m\.membership_kind <> 'pending'\)/,
|
|
176
|
+
'a project the reader already builds on is not listed: signing in there accepted it');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a decline drops the parked notice too, keyed on the session login', async () => {
|
|
180
|
+
const seen = [];
|
|
181
|
+
const p = { async query(text, params) { seen.push({ sql: String(text), params }); return { rowCount: 1, rows: [] }; } };
|
|
182
|
+
const out = await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: 'Octo' }, { pool: p });
|
|
183
|
+
assert.deepEqual(out, { removed: true });
|
|
184
|
+
assert.deepEqual(seen[0].params, ['venus', 'octo']);
|
|
185
|
+
assert.deepEqual(await inviteNotices.removeParkedInvite({ clientId: 'venus', githubLogin: '' }, { pool: p }), { removed: false });
|
|
186
|
+
assert.equal(seen.length, 1, 'no login, no statement');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the invitations routes read and decline through the parked notices', () => {
|
|
190
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/my-projects.js'), 'utf8');
|
|
191
|
+
assert.match(src, /inviteNotices\.listInvitesForBuilder\(\s*\{ githubId: req\.builder\.github_id, githubLogin: req\.builder\.github_login \}/);
|
|
192
|
+
assert.match(src, /inviteNotices\.removeParkedInvite\(\{ clientId, githubLogin: req\.builder\.github_login \}/);
|
|
193
|
+
assert.match(src, /if \(!out\.removed && !parked\.removed\) return res\.fail\('no_pending_invite', 404\)/);
|
|
139
194
|
});
|
|
140
195
|
|
|
141
196
|
test('only an authenticated, active, credentialed project may speak — and writes nothing otherwise', async () => {
|
|
@@ -207,7 +207,7 @@ for (const p of PAGES) {
|
|
|
207
207
|
const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
208
208
|
assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
|
|
209
209
|
// then the Artist review button's loader (task 1004499), the page's last tag
|
|
210
|
-
assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, '
|
|
210
|
+
assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<script src="\/assets\/invite-badge\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, 'the shared bar and its waiting count (task 1004646), after the markup they drive');
|
|
211
211
|
assert.doesNotMatch(html, /<script>/, 'and no inline copy of the bands');
|
|
212
212
|
assert.match(html, /<a data-link-hall href="\{\{buildersOrigin\}\}">Builders' hall<\/a><\/nav><\/footer>/, 'the hall keeps a door in the footer row now the bar has no Hall pill, as on /account');
|
|
213
213
|
});
|