@bongos/core 1.19.512 → 1.19.514
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 +77 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0182-project-visibility-two-axes.md +2 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +41 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/projects_hub.mjs +5 -1
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/visible_projects_feed.mjs +320 -0
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// tests/watch_applications_queue.mjs — the owner's applications review queue,
|
|
2
|
+
// EXECUTED (task 1002332, BV1.R13 of goal 1000046, criterion proj-join-policy).
|
|
3
|
+
//
|
|
4
|
+
// An application to a project IS an `access_requests` row on that project's own
|
|
5
|
+
// instance (ADR 0182 §Scope routes R13 here; the admin-console spec D2 settled
|
|
6
|
+
// the storage). The route side has carried the whole lifecycle since task
|
|
7
|
+
// 1002284 — approve, decline, RESCIND, idempotence — and `tests/application_
|
|
8
|
+
// lifecycle.mjs` executes it. What had no test, and largely no UI, was the half
|
|
9
|
+
// an owner actually touches:
|
|
10
|
+
//
|
|
11
|
+
// • ADR 0201, Consequences: "the queue gains a two-class row: vouched … and
|
|
12
|
+
// unvouched … The owner must be able to see which is which — an unvouched
|
|
13
|
+
// row that *looked* vouched is the whole vulnerability, so the distinction
|
|
14
|
+
// is UI-load-bearing, not decoration." POST /access-requests is the one
|
|
15
|
+
// PUBLIC unauthenticated write, so anyone may apply under anyone's name.
|
|
16
|
+
// • ADR 0208: "There is no UI path to the rescind. watch.js fetches only
|
|
17
|
+
// ?status=pending, so the invited history is not rendered and nothing offers
|
|
18
|
+
// the action." An approval given by mistake must be revocable before its
|
|
19
|
+
// holder ever signs in.
|
|
20
|
+
//
|
|
21
|
+
// Both are assertions about what a human sees and clicks, so this runs the real
|
|
22
|
+
// watch.js inside `vm` against the real watch.html — the parsing DOM stub from
|
|
23
|
+
// tests/task_detail_ui.mjs (task 1003516), now shared out of helpers.mjs. A
|
|
24
|
+
// render that throws, a button wired to nothing, or a pill written on the wrong
|
|
25
|
+
// row fails here; source-text assertions cannot see any of that.
|
|
26
|
+
//
|
|
27
|
+
// The vouch is DESCRIPTIVE and never authority (ADR 0016) — this file asserts
|
|
28
|
+
// only that the queue SAYS which class a row is, never that the class changes
|
|
29
|
+
// what the buttons do.
|
|
30
|
+
//
|
|
31
|
+
// Run: node tests/watch_applications_queue.mjs
|
|
32
|
+
|
|
33
|
+
import { strict as assert } from 'node:assert';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import vm from 'node:vm';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { makeRunner, makeEl, parseInto } from './helpers.mjs';
|
|
39
|
+
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
42
|
+
const SRC = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
|
|
43
|
+
const PAGE = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
|
|
44
|
+
|
|
45
|
+
const { test, summary } = makeRunner();
|
|
46
|
+
|
|
47
|
+
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
|
|
48
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
49
|
+
|
|
50
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
51
|
+
async function settle() { for (let i = 0; i < 40; i++) await tick(); }
|
|
52
|
+
|
|
53
|
+
// A request body (or an options array) the page built inside the vm carries that
|
|
54
|
+
// realm's prototypes, which deepStrictEqual reads as "same structure, different
|
|
55
|
+
// object". Round-tripping it through JSON compares the values, which is the only
|
|
56
|
+
// part under test.
|
|
57
|
+
const plain = (v) => JSON.parse(JSON.stringify(v));
|
|
58
|
+
|
|
59
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
60
|
+
const fail = (status, code, message) => ({ status, ok: false, data: { error: { code, message } } });
|
|
61
|
+
|
|
62
|
+
// Rows as GET /access-requests returns them: `vouched` is derived server-side
|
|
63
|
+
// from applicant_github_id IS NOT NULL (core_230), never stored twice.
|
|
64
|
+
const WAITING = [
|
|
65
|
+
{
|
|
66
|
+
id: '7', github_login: 'octocat', display_name: 'Mona Lisa', note: 'I want to build the map. (via platform My Projects)',
|
|
67
|
+
status: 'pending', created_at: '2026-09-01T10:00:00Z', resolved_at: null,
|
|
68
|
+
vouched: true, resolved_by_login: null,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: '8', github_login: 'stranger', display_name: 'Mona Lisa', note: 'let me in',
|
|
72
|
+
status: 'pending', created_at: '2026-09-02T10:00:00Z', resolved_at: null,
|
|
73
|
+
vouched: false, resolved_by_login: null,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
const ADMITTED = [{
|
|
77
|
+
id: '3', github_login: 'earlybird', display_name: null, note: null,
|
|
78
|
+
status: 'invited', created_at: '2026-08-01T10:00:00Z', resolved_at: '2026-08-02T09:00:00Z',
|
|
79
|
+
vouched: true, resolved_by_login: 'theowner',
|
|
80
|
+
}];
|
|
81
|
+
const DECLINED = [{
|
|
82
|
+
id: '2', github_login: 'spammer', display_name: null, note: 'buy followers',
|
|
83
|
+
status: 'dismissed', created_at: '2026-07-01T10:00:00Z', resolved_at: '2026-07-01T11:00:00Z',
|
|
84
|
+
vouched: false, resolved_by_login: 'theowner',
|
|
85
|
+
}];
|
|
86
|
+
|
|
87
|
+
// ===========================================================================
|
|
88
|
+
// Boot the real page against a scripted API and hand back handles.
|
|
89
|
+
// ===========================================================================
|
|
90
|
+
|
|
91
|
+
async function boot(opts = {}) {
|
|
92
|
+
const {
|
|
93
|
+
rows = { pending: WAITING, invited: ADMITTED, dismissed: DECLINED },
|
|
94
|
+
rank = 'archon',
|
|
95
|
+
queueStatus = null, // non-200 for the queue read (403 seals it)
|
|
96
|
+
patchResult = null, // a scripted answer for PATCH /access-requests/:id
|
|
97
|
+
} = opts;
|
|
98
|
+
|
|
99
|
+
const requests = [];
|
|
100
|
+
const toasts = [];
|
|
101
|
+
const filterBars = [];
|
|
102
|
+
|
|
103
|
+
const api = {
|
|
104
|
+
async request(method, p, init) {
|
|
105
|
+
const body = init && init.body;
|
|
106
|
+
requests.push({ method, path: p, body });
|
|
107
|
+
const url = String(p).replace('/api/bongos', '');
|
|
108
|
+
const [route, query] = url.split('?');
|
|
109
|
+
if (method === 'GET' && route === '/me') return ok({ builder: { id: 1, rank, github_login: 'theowner' } });
|
|
110
|
+
if (method === 'GET' && route === '/access-requests') {
|
|
111
|
+
if (queueStatus) return { status: queueStatus, ok: false, data: null };
|
|
112
|
+
const status = new URLSearchParams(query || '').get('status') || 'pending';
|
|
113
|
+
return ok({ requests: rows[status] || [] });
|
|
114
|
+
}
|
|
115
|
+
if (method === 'PATCH' && route.startsWith('/access-requests/')) {
|
|
116
|
+
return patchResult || ok({ ok: true, request: { id: route.split('/').pop() } });
|
|
117
|
+
}
|
|
118
|
+
if (method === 'POST' && route === '/access-requests/invite') return ok({ ok: true });
|
|
119
|
+
// Every other panel on this page loads independently and degrades on its
|
|
120
|
+
// own; an unscripted shape is exactly the failure `section` swallows.
|
|
121
|
+
return ok({});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const documentObj = {
|
|
126
|
+
readyState: 'complete',
|
|
127
|
+
body: makeEl('body'),
|
|
128
|
+
documentElement: makeEl('html'),
|
|
129
|
+
createElement: (tag) => makeEl(tag),
|
|
130
|
+
getElementById(id) { return this.body.querySelector(`#${id}`); },
|
|
131
|
+
querySelector(sel) { return this.body.querySelector(sel); },
|
|
132
|
+
querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
|
|
133
|
+
addEventListener() {},
|
|
134
|
+
};
|
|
135
|
+
// The REAL page markup, so #gate-body, #att-access and the section ids are
|
|
136
|
+
// the ones shipped rather than ones this test invented.
|
|
137
|
+
parseInto(documentObj.body, PAGE);
|
|
138
|
+
|
|
139
|
+
const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
|
|
140
|
+
|
|
141
|
+
const kit = {
|
|
142
|
+
makeTabs: () => ({}),
|
|
143
|
+
makeLedger: () => ({ render() {} }),
|
|
144
|
+
makePager: () => ({ page: 1, perPage: 25 }),
|
|
145
|
+
emptyStateHtml: (msg) => `<div class="empty-state"><p>${escapeHtml(msg)}</p></div>`,
|
|
146
|
+
// Recorded rather than reimplemented: the contract watch.js depends on is
|
|
147
|
+
// "paint a control, call onChange with the chosen value", and a test that
|
|
148
|
+
// drives onChange drives exactly that.
|
|
149
|
+
makeFilterBar(o) {
|
|
150
|
+
filterBars.push(o);
|
|
151
|
+
const c = o.primary[0];
|
|
152
|
+
o.mount.innerHTML = `<div class="filters"><label class="filter">${escapeHtml(c.label)}<select id="${escapeHtml(c.id)}">`
|
|
153
|
+
+ c.options.map((x) => `<option value="${escapeHtml(x.value)}"${String(x.value) === String(c.value) ? ' selected' : ''}>${escapeHtml(x.label)}</option>`).join('')
|
|
154
|
+
+ '</select></label></div>';
|
|
155
|
+
return { el: o.mount, values: () => ({ [c.id]: c.value }) };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const windowObj = {
|
|
160
|
+
__BRANDING__: { project: { joinability: 'apply' } },
|
|
161
|
+
OTB: {
|
|
162
|
+
escapeHtml,
|
|
163
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
164
|
+
num: (v) => Number(v),
|
|
165
|
+
fmtInt: (n) => String(n),
|
|
166
|
+
fmtCompact: (n) => String(n),
|
|
167
|
+
fmtDate: (iso) => String(iso).slice(0, 10),
|
|
168
|
+
rankLabel: (r) => String(r),
|
|
169
|
+
toast: (m) => toasts.push(m),
|
|
170
|
+
},
|
|
171
|
+
OTBKit: kit,
|
|
172
|
+
OTBBranding: { currencyLabel: () => 'Credits' },
|
|
173
|
+
BongosClient: { createClient: () => api },
|
|
174
|
+
location: locationObj,
|
|
175
|
+
addEventListener() {},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const sandbox = {
|
|
179
|
+
window: windowObj, document: documentObj, location: locationObj,
|
|
180
|
+
console: { log() {}, warn() {}, error() {} },
|
|
181
|
+
setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
|
|
182
|
+
Number, String, Object, Array, Boolean, Set, Map, RegExp,
|
|
183
|
+
encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
|
|
184
|
+
};
|
|
185
|
+
sandbox.globalThis = sandbox;
|
|
186
|
+
vm.createContext(sandbox);
|
|
187
|
+
vm.runInContext(SRC, sandbox, { filename: 'watch.js' });
|
|
188
|
+
await settle();
|
|
189
|
+
|
|
190
|
+
const gate = () => documentObj.getElementById('gate-body');
|
|
191
|
+
const rowsOut = () => gate().querySelectorAll('li.ov-row');
|
|
192
|
+
const rowFor = (id) => rowsOut().find((r) => r.getAttribute('data-req-id') === id);
|
|
193
|
+
const queueReads = () => requests.filter((r) => r.method === 'GET' && r.path.includes('/access-requests?'));
|
|
194
|
+
const patches = () => requests.filter((r) => r.method === 'PATCH');
|
|
195
|
+
|
|
196
|
+
async function click(rowId, action) {
|
|
197
|
+
const row = rowFor(rowId);
|
|
198
|
+
assert.ok(row, `no row rendered for request ${rowId}`);
|
|
199
|
+
const btn = row.querySelector(`[data-gate-action="${action}"]`);
|
|
200
|
+
assert.ok(btn, `row ${rowId} offers no "${action}" control — got: ${row.innerHTML}`);
|
|
201
|
+
btn.fire('click', {});
|
|
202
|
+
await settle();
|
|
203
|
+
return btn;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function switchView(value) {
|
|
207
|
+
const bar = filterBars.at(-1);
|
|
208
|
+
assert.ok(bar, 'the queue offers no view switch');
|
|
209
|
+
await bar.onChange({ 'gate-status': value });
|
|
210
|
+
await settle();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
requests, toasts, filterBars, documentObj,
|
|
215
|
+
gate, rows: rowsOut, rowFor, queueReads, patches, click, switchView, settle,
|
|
216
|
+
attention: () => documentObj.getElementById('att-access').textContent,
|
|
217
|
+
lede: () => documentObj.getElementById('gate-scroll').querySelector('.scroll__lede').textContent,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ===========================================================================
|
|
222
|
+
// The two-class row (ADR 0201) — the reason this queue is safe to read at all
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
|
|
225
|
+
await test('every waiting request says whether the platform vouched for the name on it', async () => {
|
|
226
|
+
const h = await boot();
|
|
227
|
+
assert.equal(h.rows().length, 2, 'both waiting requests render');
|
|
228
|
+
for (const row of h.rows()) {
|
|
229
|
+
assert.ok(row.querySelector('.fact-pill'), `request ${row.getAttribute('data-req-id')} carries no vouch pill`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('a vouched request reads as vouched and an unvouched one does not', async () => {
|
|
234
|
+
const h = await boot();
|
|
235
|
+
const vouched = h.rowFor('7').querySelector('.fact-pill').textContent;
|
|
236
|
+
const unvouched = h.rowFor('8').querySelector('.fact-pill').textContent;
|
|
237
|
+
assert.match(vouched, /vouched/i);
|
|
238
|
+
assert.doesNotMatch(vouched, /unvouched/i, 'the vouched row must not read as unvouched');
|
|
239
|
+
assert.match(unvouched, /unvouched/i);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('an unvouched request wearing a well-known display name is still marked unvouched', async () => {
|
|
243
|
+
// Both fixtures claim "Mona Lisa" — the impersonation shape the vouch closes.
|
|
244
|
+
const h = await boot();
|
|
245
|
+
const impostor = h.rowFor('8');
|
|
246
|
+
assert.match(impostor.textContent, /Mona Lisa/, 'the claimed name is still shown');
|
|
247
|
+
assert.match(impostor.querySelector('.fact-pill').textContent, /unvouched/i,
|
|
248
|
+
'a name anyone could have typed must never render as confirmed');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('the vouch changes what the row SAYS, never what it offers (ADR 0016)', async () => {
|
|
252
|
+
const h = await boot();
|
|
253
|
+
const verbs = (id) => h.rowFor(id).querySelectorAll('[data-gate-action]').map((b) => b.getAttribute('data-gate-action'));
|
|
254
|
+
assert.deepEqual(verbs('7'), verbs('8'), 'a vouch is descriptive; it is not authority');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ===========================================================================
|
|
258
|
+
// Deciding
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
|
|
261
|
+
await test('a waiting request offers both decisions', async () => {
|
|
262
|
+
const h = await boot();
|
|
263
|
+
const verbs = h.rowFor('7').querySelectorAll('[data-gate-action]').map((b) => b.getAttribute('data-gate-action'));
|
|
264
|
+
assert.deepEqual(verbs, ['invited', 'dismissed']);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await test('Invite PATCHes the request to invited — the one write that admits anyone', async () => {
|
|
268
|
+
const h = await boot();
|
|
269
|
+
await h.click('7', 'invited');
|
|
270
|
+
const p = h.patches();
|
|
271
|
+
assert.equal(p.length, 1);
|
|
272
|
+
assert.equal(p[0].path, '/api/bongos/access-requests/7');
|
|
273
|
+
assert.deepEqual(plain(p[0].body), { status: 'invited' });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('Dismiss PATCHes the request to dismissed', async () => {
|
|
277
|
+
const h = await boot();
|
|
278
|
+
await h.click('8', 'dismissed');
|
|
279
|
+
assert.deepEqual(plain(h.patches()[0].body), { status: 'dismissed' });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await test('a decision re-reads the queue, so the row it settled leaves the list', async () => {
|
|
283
|
+
const h = await boot();
|
|
284
|
+
const before = h.queueReads().length;
|
|
285
|
+
await h.click('7', 'invited');
|
|
286
|
+
assert.ok(h.queueReads().length > before, 'the queue is re-read after a decision');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ===========================================================================
|
|
290
|
+
// The history, and the rescind ADR 0208 said nothing offered
|
|
291
|
+
// ===========================================================================
|
|
292
|
+
|
|
293
|
+
await test('the queue offers all three lifecycle states as views', async () => {
|
|
294
|
+
const h = await boot();
|
|
295
|
+
const values = plain(h.filterBars.at(-1).primary[0].options).map((o) => o.value);
|
|
296
|
+
assert.deepEqual(values, ['pending', 'invited', 'dismissed']);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await test('switching to the admitted view re-reads the queue at that status', async () => {
|
|
300
|
+
const h = await boot();
|
|
301
|
+
await h.switchView('invited');
|
|
302
|
+
assert.match(h.queueReads().at(-1).path, /status=invited/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await test('an admitted request offers Rescind, and it PATCHes back to dismissed', async () => {
|
|
306
|
+
const h = await boot();
|
|
307
|
+
await h.switchView('invited');
|
|
308
|
+
const btn = h.rowFor('3').querySelector('[data-gate-action]');
|
|
309
|
+
assert.match(btn.textContent, /Rescind/);
|
|
310
|
+
await h.click('3', 'dismissed');
|
|
311
|
+
assert.deepEqual(plain(h.patches().at(-1).body), { status: 'dismissed' });
|
|
312
|
+
assert.equal(h.patches().at(-1).path, '/api/bongos/access-requests/3');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await test('a declined request offers no decision — reviving one is the invite form, not a button', async () => {
|
|
316
|
+
const h = await boot();
|
|
317
|
+
await h.switchView('dismissed');
|
|
318
|
+
assert.equal(h.rowFor('2').querySelectorAll('[data-gate-action]').length, 0);
|
|
319
|
+
assert.ok(h.gate().querySelector('#gate-invite'), 'the invite form the server names is still on the page');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await test('a decided request names who decided it and when', async () => {
|
|
323
|
+
const h = await boot();
|
|
324
|
+
await h.switchView('invited');
|
|
325
|
+
const text = h.rowFor('3').textContent;
|
|
326
|
+
assert.match(text, /Admitted/);
|
|
327
|
+
assert.match(text, /@theowner/);
|
|
328
|
+
assert.match(text, /2026-08-02/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await test('a refused rescind shows the server sentence, not [object Object]', async () => {
|
|
332
|
+
const h = await boot({
|
|
333
|
+
patchResult: fail(409, 'already_member',
|
|
334
|
+
'They have already signed in, so this request is history — dismissing it would remove nothing.'),
|
|
335
|
+
});
|
|
336
|
+
await h.switchView('invited');
|
|
337
|
+
await h.click('3', 'dismissed');
|
|
338
|
+
const said = h.toasts.join(' | ');
|
|
339
|
+
assert.match(said, /already signed in/);
|
|
340
|
+
assert.doesNotMatch(said, /\[object Object\]/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ===========================================================================
|
|
344
|
+
// The board, the empty states, the seal
|
|
345
|
+
// ===========================================================================
|
|
346
|
+
|
|
347
|
+
await test('the attention tile counts waiting requests only, never the history on screen', async () => {
|
|
348
|
+
const h = await boot();
|
|
349
|
+
assert.equal(h.attention(), '2', 'the tile starts on the waiting count');
|
|
350
|
+
await h.switchView('dismissed');
|
|
351
|
+
assert.equal(h.attention(), '2',
|
|
352
|
+
'reading the decided history must not repaint a tile whose own sub line says "pending"');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await test('each view has an empty state in its own words', async () => {
|
|
356
|
+
const h = await boot({ rows: { pending: [], invited: [], dismissed: [] } });
|
|
357
|
+
assert.match(h.gate().querySelector('.empty-state').textContent, /No pending access requests/);
|
|
358
|
+
await h.switchView('invited');
|
|
359
|
+
assert.match(h.gate().querySelector('.empty-state').textContent, /admitted/i);
|
|
360
|
+
await h.switchView('dismissed');
|
|
361
|
+
assert.match(h.gate().querySelector('.empty-state').textContent, /declined/i);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await test('an empty view still offers the invite form and the switch out of it', async () => {
|
|
365
|
+
const h = await boot({ rows: { pending: [], invited: [], dismissed: [] } });
|
|
366
|
+
assert.ok(h.gate().querySelector('#gate-invite'));
|
|
367
|
+
assert.ok(h.gate().querySelector('#gate-view').querySelector('select'));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await test('a 403 seals the section rather than blanking the page', async () => {
|
|
371
|
+
const h = await boot({ queueStatus: 403 });
|
|
372
|
+
assert.match(h.gate().textContent, /Archons only/);
|
|
373
|
+
assert.equal(h.rows().length, 0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await test('the section lede tells the owner the queue is more than one decision', async () => {
|
|
377
|
+
const h = await boot();
|
|
378
|
+
assert.match(h.lede(), /decided/i);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
summary();
|