@bongos/core 1.19.538 → 1.19.539
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 +30 -20
- package/docs/adr/0249-the-invite-step-is-the-done-panel-s-first-act-not-a-rail-step.md +115 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +131 -123
- package/docs/copy-registry.json +244 -160
- package/docs/module-api-changelog.md +2 -0
- package/modules/public-landing/public/assets/cosmos.css +18 -0
- package/modules/public-landing/public/projects.html +220 -1
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_invite_ui.mjs +502 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +2 -2
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// tests/project_invite_ui.mjs — inviting a builder to a project, EXECUTED
|
|
2
|
+
// (BV1.R27, task 1002346, goal 1000046 criterion C6, ADR 0249).
|
|
3
|
+
//
|
|
4
|
+
// tests/project_invite_ownership.mjs pins the ROUTE half — who POST
|
|
5
|
+
// /projects/invite admits and what it refuses. This file pins the SURFACE an
|
|
6
|
+
// owner actually uses: the creation wizard's optional invite step, and the same
|
|
7
|
+
// widget mounted again on the project's own page so a skipped step is a default
|
|
8
|
+
// rather than a dead end.
|
|
9
|
+
//
|
|
10
|
+
// The idiom is tests/project_modules_ui.mjs beside it (there is no jsdom in this
|
|
11
|
+
// repo): lift the region out of modules/public-landing/public/projects.html, run
|
|
12
|
+
// it in a vm under a minimal DOM stub, PARSE the markup it writes, drive real
|
|
13
|
+
// clicks — and assert the slice boundaries themselves, so a rename can never
|
|
14
|
+
// leave this file quietly executing an empty string.
|
|
15
|
+
//
|
|
16
|
+
// What it proves, and why each matters:
|
|
17
|
+
// • the step is INVISIBLE until there is a real project to invite to — the
|
|
18
|
+
// catalog row a client_id resolves is projected at create (ADR 0246), so
|
|
19
|
+
// before that an invite could only ever 404
|
|
20
|
+
// • an invite sends exactly { client_id, github_login } — the slug IS the
|
|
21
|
+
// client_id — on the cookie session, and nothing else
|
|
22
|
+
// • the 201 row is what the pending line is drawn from, and the github_id it
|
|
23
|
+
// carries is never shown
|
|
24
|
+
// • every refusal the route can give is said in the route's own vocabulary,
|
|
25
|
+
// and the 403 is said CAREFULLY: it is byte-identical whether or not the
|
|
26
|
+
// project exists (ADR 0248), so the page may not read it as "it is there"
|
|
27
|
+
// • a refusal never costs the project or the step — the box stays, re-tryable
|
|
28
|
+
// • Skip advances with ZERO calls
|
|
29
|
+
// • both call sites hand over a slug only for a signed-in owner
|
|
30
|
+
//
|
|
31
|
+
// Run: node --test --test-reporter=tap tests/project_invite_ui.mjs
|
|
32
|
+
|
|
33
|
+
import test from 'node:test';
|
|
34
|
+
import assert from 'node:assert/strict';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import vm from 'node:vm';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
42
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
43
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
45
|
+
|
|
46
|
+
const START = SCRIPT.indexOf('var INVITE_MOUNTS = {');
|
|
47
|
+
const END = SCRIPT.indexOf('/* ── boot: who am I');
|
|
48
|
+
assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
|
|
49
|
+
assert.ok(END > START, 'the invite region must end before the boot block');
|
|
50
|
+
const REGION = SCRIPT.slice(START, END);
|
|
51
|
+
for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(']) {
|
|
52
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
56
|
+
// Narrow on purpose (the project_modules_ui.mjs rule): it supports exactly what
|
|
57
|
+
// the region uses, so it stays honest about what it can prove.
|
|
58
|
+
|
|
59
|
+
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
|
|
60
|
+
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
|
|
61
|
+
|
|
62
|
+
function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
63
|
+
const requests = [];
|
|
64
|
+
const els = new Map();
|
|
65
|
+
const listeners = new Map(); // "elId:type" → [fn]
|
|
66
|
+
const focused = [];
|
|
67
|
+
|
|
68
|
+
const makeEl = (id) => ({
|
|
69
|
+
_id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
|
|
70
|
+
set innerHTML(v) { this._html = String(v); },
|
|
71
|
+
get innerHTML() { return this._html; },
|
|
72
|
+
addEventListener(type, fn) {
|
|
73
|
+
const key = id + ':' + type;
|
|
74
|
+
if (!listeners.has(key)) listeners.set(key, []);
|
|
75
|
+
listeners.get(key).push(fn);
|
|
76
|
+
},
|
|
77
|
+
setAttribute() {},
|
|
78
|
+
focus() { focused.push(id); },
|
|
79
|
+
});
|
|
80
|
+
const $ = (id) => {
|
|
81
|
+
if (id == null) return null;
|
|
82
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
83
|
+
return els.get(id);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Every mount's elements exist before the region wires them, exactly as the
|
|
87
|
+
// real page has them: the script runs at the end of <body>.
|
|
88
|
+
['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
|
|
89
|
+
'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg'].forEach($);
|
|
90
|
+
|
|
91
|
+
const queued = answers.slice();
|
|
92
|
+
const fetch = (url, init = {}) => {
|
|
93
|
+
requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
|
|
94
|
+
const a = queued.shift() || { ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 4242, membership_kind: 'pending' } } };
|
|
95
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
96
|
+
return Promise.resolve({ ok: a.ok, status: a.status, _json: a.body, json: () => Promise.resolve(a.body) });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const sandbox = {
|
|
100
|
+
$, fetch, console, Promise, JSON, String, Object, Array, Error,
|
|
101
|
+
encodeURIComponent, setTimeout, clearTimeout,
|
|
102
|
+
API: '/api/bongos',
|
|
103
|
+
esc: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
104
|
+
jsonOrEmpty: (res) => Promise.resolve(res._json),
|
|
105
|
+
apiError: (data, status) => ({
|
|
106
|
+
code: (data && data.error && data.error.code) || ('http_' + status),
|
|
107
|
+
message: (data && data.error && data.error.message) || '',
|
|
108
|
+
}),
|
|
109
|
+
signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
|
|
110
|
+
};
|
|
111
|
+
sandbox.globalThis = sandbox;
|
|
112
|
+
vm.createContext(sandbox);
|
|
113
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-invite' });
|
|
114
|
+
|
|
115
|
+
const mounts = {
|
|
116
|
+
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg' },
|
|
117
|
+
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg' },
|
|
118
|
+
};
|
|
119
|
+
const m = mounts[mount];
|
|
120
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
121
|
+
|
|
122
|
+
const press = async (marker) => {
|
|
123
|
+
const handlers = listeners.get(mount + ':click') || [];
|
|
124
|
+
assert.equal(handlers.length, 1, 'the mount wires exactly one delegated click handler');
|
|
125
|
+
handlers[0]({
|
|
126
|
+
target: {
|
|
127
|
+
closest: (sel) => (sel === '[data-invite-send],[data-invite-skip]'
|
|
128
|
+
? { hasAttribute: (a) => a === marker }
|
|
129
|
+
: null),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
await settle();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
requests, sandbox, settle, focused,
|
|
137
|
+
box: () => $(mount),
|
|
138
|
+
field: () => $(m.who),
|
|
139
|
+
msg: () => $(m.msg),
|
|
140
|
+
rows: () => parseRows($(m.list).innerHTML),
|
|
141
|
+
async open(slug) { sandbox.setInviteSlug(mount, slug); await settle(); },
|
|
142
|
+
async type(login) { $(m.who).value = login; },
|
|
143
|
+
async send() { await press('data-invite-send'); },
|
|
144
|
+
async skip() { await press('data-invite-skip'); },
|
|
145
|
+
async enter() {
|
|
146
|
+
const handlers = listeners.get(m.who + ':keydown') || [];
|
|
147
|
+
assert.equal(handlers.length, 1, 'the field takes Enter');
|
|
148
|
+
handlers[0]({ key: 'Enter', preventDefault() {} });
|
|
149
|
+
await settle();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const refused = (status, code, message) => ({ ok: false, status, body: { error: { code, message: message || '' } } });
|
|
155
|
+
|
|
156
|
+
// ── the step only exists once the project does ───────────────────────────────
|
|
157
|
+
|
|
158
|
+
test('the step is invisible until a real project exists — no slug, no step', async () => {
|
|
159
|
+
// Arrange — the mount is what the page ships: hidden, with nothing to invite to
|
|
160
|
+
const ui = boot();
|
|
161
|
+
|
|
162
|
+
// Act
|
|
163
|
+
await ui.open('');
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
assert.equal(ui.box().hidden, true, 'the box stays hidden');
|
|
167
|
+
assert.equal(ui.rows().length, 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a real slug opens the step', async () => {
|
|
171
|
+
const ui = boot();
|
|
172
|
+
|
|
173
|
+
await ui.open('mercury');
|
|
174
|
+
|
|
175
|
+
assert.equal(ui.box().hidden, false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a click while the step is closed sends nothing at all', async () => {
|
|
179
|
+
// This is how "signed out" and "not the owner" reach the widget: the call site
|
|
180
|
+
// hands over no slug, and there is nothing to send.
|
|
181
|
+
const ui = boot();
|
|
182
|
+
await ui.open('');
|
|
183
|
+
|
|
184
|
+
await ui.type('octo-builder');
|
|
185
|
+
await ui.send();
|
|
186
|
+
|
|
187
|
+
assert.equal(ui.requests.length, 0, 'no project, no request');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── the invite itself ────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
test('an invite carries exactly { client_id, github_login } — the slug IS the client_id', async () => {
|
|
193
|
+
// Arrange
|
|
194
|
+
const ui = boot();
|
|
195
|
+
await ui.open('mercury');
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
await ui.type('octo-builder');
|
|
199
|
+
await ui.send();
|
|
200
|
+
|
|
201
|
+
// Assert
|
|
202
|
+
assert.equal(ui.requests.length, 1);
|
|
203
|
+
const req = ui.requests[0];
|
|
204
|
+
assert.equal(req.url, '/api/bongos/projects/invite');
|
|
205
|
+
assert.equal(req.method, 'POST');
|
|
206
|
+
assert.equal(req.credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
|
|
207
|
+
assert.deepEqual(JSON.parse(req.body), { client_id: 'mercury', github_login: 'octo-builder' },
|
|
208
|
+
'exactly the two fields the route validates, and nothing else');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the pending line is drawn from the 201 ROW, and the github_id on it is never shown', async () => {
|
|
212
|
+
const ui = boot();
|
|
213
|
+
await ui.open('mercury');
|
|
214
|
+
|
|
215
|
+
await ui.type('octo-builder');
|
|
216
|
+
await ui.send();
|
|
217
|
+
|
|
218
|
+
assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
|
|
219
|
+
assert.match(ui.rows()[0].said, /waiting for them to accept/i, "the row's own 'pending' is what the line says");
|
|
220
|
+
assert.equal(ui.msg().className, 'projMsg ok');
|
|
221
|
+
assert.doesNotMatch(ui.msg().textContent + ui.rows()[0].said + ui.rows()[0].login, /4242/,
|
|
222
|
+
'the row carries a github_id; a person who has accepted nothing yet is not published by it');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a membership_kind the page does not know is shown VERBATIM, never papered over as pending', async () => {
|
|
226
|
+
// The state word is the row's, not this page's: if the route ever seeds a
|
|
227
|
+
// different kind, an owner sees that rather than a comfortable fiction.
|
|
228
|
+
const ui = boot({ answers: [{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'member' } } }] });
|
|
229
|
+
await ui.open('mercury');
|
|
230
|
+
|
|
231
|
+
await ui.type('octo-builder');
|
|
232
|
+
await ui.send();
|
|
233
|
+
|
|
234
|
+
assert.equal(ui.rows()[0].said, 'member');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a name is trimmed, and an empty one is a question rather than a request', async () => {
|
|
238
|
+
const ui = boot();
|
|
239
|
+
await ui.open('mercury');
|
|
240
|
+
|
|
241
|
+
await ui.type(' ');
|
|
242
|
+
await ui.send();
|
|
243
|
+
assert.equal(ui.requests.length, 0, 'nothing is sent for a blank field');
|
|
244
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
245
|
+
|
|
246
|
+
await ui.type(' octo-builder ');
|
|
247
|
+
await ui.send();
|
|
248
|
+
assert.deepEqual(JSON.parse(ui.requests[0].body).github_login, 'octo-builder');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the field clears after a sent invite, so the next one starts empty', async () => {
|
|
252
|
+
const ui = boot();
|
|
253
|
+
await ui.open('mercury');
|
|
254
|
+
|
|
255
|
+
await ui.type('octo-builder');
|
|
256
|
+
await ui.send();
|
|
257
|
+
|
|
258
|
+
assert.equal(ui.field().value, '');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('Enter in the field sends the invite too', async () => {
|
|
262
|
+
const ui = boot();
|
|
263
|
+
await ui.open('mercury');
|
|
264
|
+
|
|
265
|
+
await ui.type('octo-builder');
|
|
266
|
+
await ui.enter();
|
|
267
|
+
|
|
268
|
+
assert.equal(ui.requests.length, 1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('two invites in a row both stand, newest last', async () => {
|
|
272
|
+
const ui = boot({
|
|
273
|
+
answers: [
|
|
274
|
+
{ ok: true, status: 201, body: { invited: true, row: { id: 7, client_id: 'mercury', github_id: 1, membership_kind: 'pending' } } },
|
|
275
|
+
{ ok: true, status: 201, body: { invited: true, row: { id: 8, client_id: 'mercury', github_id: 2, membership_kind: 'pending' } } },
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
await ui.open('mercury');
|
|
279
|
+
|
|
280
|
+
await ui.type('octo-builder');
|
|
281
|
+
await ui.send();
|
|
282
|
+
await ui.type('second-builder');
|
|
283
|
+
await ui.send();
|
|
284
|
+
|
|
285
|
+
assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder', 'second-builder']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── every refusal the route can give ─────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
test('an invitee with no platform account is named honestly, and the step survives it', async () => {
|
|
291
|
+
const ui = boot({ answers: [refused(404, 'account_not_found', 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.')] });
|
|
292
|
+
await ui.open('mercury');
|
|
293
|
+
|
|
294
|
+
await ui.type('nobody-here');
|
|
295
|
+
await ui.send();
|
|
296
|
+
|
|
297
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
298
|
+
assert.match(ui.msg().textContent, /hasn’t signed in here yet/i);
|
|
299
|
+
assert.equal(ui.box().hidden, false, 'the step stays — a failed invite is re-tryable');
|
|
300
|
+
assert.equal(ui.rows().length, 0, 'and nothing is drawn as pending that is not');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('someone already on the project is said plainly, not as a failure', async () => {
|
|
304
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { invited: false, alreadyMember: true } }] });
|
|
305
|
+
await ui.open('mercury');
|
|
306
|
+
|
|
307
|
+
await ui.type('octo-builder');
|
|
308
|
+
await ui.send();
|
|
309
|
+
|
|
310
|
+
assert.equal(ui.msg().className, 'projMsg ok');
|
|
311
|
+
assert.match(ui.msg().textContent, /already on this project/i);
|
|
312
|
+
assert.equal(ui.rows().length, 0, 'an existing member is not a pending invite');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a project the map has not caught up with says exactly that, and stays re-tryable', async () => {
|
|
316
|
+
// The catalog row is projected at create (ADR 0246), so this is a moment, not
|
|
317
|
+
// a dead end — the copy has to say "in a moment", never "no such project".
|
|
318
|
+
const ui = boot({ answers: [refused(404, 'unknown_project')] });
|
|
319
|
+
await ui.open('mercury');
|
|
320
|
+
|
|
321
|
+
await ui.type('octo-builder');
|
|
322
|
+
await ui.send();
|
|
323
|
+
|
|
324
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
325
|
+
assert.match(ui.msg().textContent, /isn’t on the map yet/i);
|
|
326
|
+
assert.match(ui.msg().textContent, /try again/i);
|
|
327
|
+
assert.equal(ui.box().hidden, false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a 403 is NEVER read as "the project is there but you may not" — it says only what it knows', async () => {
|
|
331
|
+
// ADR 0248: the refusal is identical for a real project, a stealth one, one
|
|
332
|
+
// that does not exist and a malformed body. A page that words it as "you lack
|
|
333
|
+
// permission on THIS project" turns an equalised refusal back into an oracle.
|
|
334
|
+
const ui = boot({ answers: [refused(403, 'permission_forbidden', 'requires project.curate')] });
|
|
335
|
+
await ui.open('mercury');
|
|
336
|
+
|
|
337
|
+
await ui.type('octo-builder');
|
|
338
|
+
await ui.send();
|
|
339
|
+
|
|
340
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
341
|
+
const said = ui.msg().textContent;
|
|
342
|
+
assert.match(said, /can’t invite/i);
|
|
343
|
+
assert.doesNotMatch(said, /this project (is|exists)/i, 'it may not assert the project exists');
|
|
344
|
+
assert.doesNotMatch(said, /project\.curate/, 'and it does not recite the atom at an owner');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('an expired session offers a way back in rather than a dead end', async () => {
|
|
348
|
+
const ui = boot({ answers: [refused(401, 'unauthenticated')] });
|
|
349
|
+
await ui.open('mercury');
|
|
350
|
+
|
|
351
|
+
await ui.type('octo-builder');
|
|
352
|
+
await ui.send();
|
|
353
|
+
|
|
354
|
+
assert.match(ui.msg().innerHTML, /sign in again/i);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('an unreachable platform is said as a network problem, not as a refusal', async () => {
|
|
358
|
+
const ui = boot({ answers: [{ reject: true }] });
|
|
359
|
+
await ui.open('mercury');
|
|
360
|
+
|
|
361
|
+
await ui.type('octo-builder');
|
|
362
|
+
await ui.send();
|
|
363
|
+
|
|
364
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
365
|
+
assert.match(ui.msg().textContent, /reach the platform/i);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('an unknown refusal falls through to the server’s own words rather than an invented reason', async () => {
|
|
369
|
+
const ui = boot({ answers: [refused(429, 'rate_limited', 'Too many invitations right now — give it a minute.')] });
|
|
370
|
+
await ui.open('mercury');
|
|
371
|
+
|
|
372
|
+
await ui.type('octo-builder');
|
|
373
|
+
await ui.send();
|
|
374
|
+
|
|
375
|
+
assert.match(ui.msg().textContent, /Too many invitations right now/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('inviteRefusal is a pure function over the route’s own vocabulary', () => {
|
|
379
|
+
const f = boot().sandbox.inviteRefusal;
|
|
380
|
+
assert.match(f('account_not_found', 404, ''), /signed in here yet/i);
|
|
381
|
+
assert.match(f('unknown_project', 404, ''), /map yet/i);
|
|
382
|
+
assert.match(f('permission_forbidden', 403, ''), /can’t invite/i);
|
|
383
|
+
assert.equal(f('whatever', 500, 'the server said this'), 'the server said this');
|
|
384
|
+
assert.match(f('whatever', 500, ''), /couldn’t be sent/i);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ── skip ─────────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
test('Skip advances with ZERO calls — nothing is sent and nothing is claimed', async () => {
|
|
390
|
+
const ui = boot();
|
|
391
|
+
await ui.open('mercury');
|
|
392
|
+
await ui.type('octo-builder'); // even a typed name is not a filed invite
|
|
393
|
+
|
|
394
|
+
await ui.skip();
|
|
395
|
+
|
|
396
|
+
assert.equal(ui.requests.length, 0, 'a skip sends nothing');
|
|
397
|
+
assert.equal(ui.box().hidden, true, 'and the step steps aside');
|
|
398
|
+
assert.equal(ui.rows().length, 0);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('a skip moves focus on rather than stranding it inside the box it just hid', async () => {
|
|
402
|
+
const ui = boot();
|
|
403
|
+
await ui.open('mercury');
|
|
404
|
+
|
|
405
|
+
await ui.skip();
|
|
406
|
+
|
|
407
|
+
assert.deepEqual(ui.focused, ['doneStepsHead']);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('a NEW project starts the step clean — the last one’s pending lines do not follow it', async () => {
|
|
411
|
+
const ui = boot();
|
|
412
|
+
await ui.open('mercury');
|
|
413
|
+
await ui.type('octo-builder');
|
|
414
|
+
await ui.send();
|
|
415
|
+
assert.equal(ui.rows().length, 1);
|
|
416
|
+
|
|
417
|
+
await ui.open('venus');
|
|
418
|
+
|
|
419
|
+
assert.equal(ui.rows().length, 0, 'the invites belonged to the project they were filed on');
|
|
420
|
+
assert.equal(ui.box().hidden, false, 'and the step reopens for the new one, skip or no skip');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ── the project's own page: the same widget, mounted again ───────────────────
|
|
424
|
+
|
|
425
|
+
test('the project’s own page mounts the SAME widget on the SAME route', async () => {
|
|
426
|
+
const ui = boot({ mount: 'mInvite' });
|
|
427
|
+
|
|
428
|
+
await ui.open('mercury');
|
|
429
|
+
await ui.type('octo-builder');
|
|
430
|
+
await ui.send();
|
|
431
|
+
|
|
432
|
+
assert.deepEqual(JSON.parse(ui.requests[0].body), { client_id: 'mercury', github_login: 'octo-builder' });
|
|
433
|
+
assert.deepEqual(ui.rows().map((r) => r.login), ['octo-builder']);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('the two mounts keep their own lines — an invite on one is not drawn on the other', async () => {
|
|
437
|
+
const ui = boot();
|
|
438
|
+
await ui.open('mercury');
|
|
439
|
+
await ui.type('octo-builder');
|
|
440
|
+
await ui.send();
|
|
441
|
+
|
|
442
|
+
assert.equal(parseRows(ui.sandbox.$('mInviteList').innerHTML).length, 0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── the call sites (source pins) ─────────────────────────────────────────────
|
|
446
|
+
// The two questions this widget deliberately does NOT answer — "is anyone signed
|
|
447
|
+
// in" and "is this their project" — are answered by the caller, which hands over
|
|
448
|
+
// a slug or nothing. So the pins are on the calls.
|
|
449
|
+
|
|
450
|
+
test('the wizard opens the step only for a signed-in owner of a project that already exists', () => {
|
|
451
|
+
assert.match(SCRIPT, /setInviteSlug\('wizInvite', \(me && created && created\.id && created\.slug\) \? created\.slug : ''\);/,
|
|
452
|
+
'the created record IS the proof: no /me, no created id, no slug ⇒ no step');
|
|
453
|
+
assert.ok(SCRIPT.indexOf("setInviteSlug('wizInvite'") > SCRIPT.indexOf('function renderDone()'),
|
|
454
|
+
'and it is the done panel that opens it — the panel that only exists after Create');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('the project page opens the step from its own manage read, which the API already own-scopes', () => {
|
|
458
|
+
assert.match(SCRIPT, /setInviteSlug\('mInvite', inst\.slug \|\| ''\);/,
|
|
459
|
+
'renderManage hands over the slug it was given');
|
|
460
|
+
assert.ok(SCRIPT.indexOf("setInviteSlug('mInvite'") > SCRIPT.indexOf('function renderManage(inst)'),
|
|
461
|
+
'inside renderManage — which the per-id read 404s for anyone who is not the owner');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ── the markup + the sheet ───────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
test('the wizard’s invite step ships as real markup on the done panel, above the human steps', () => {
|
|
467
|
+
const panel = (HUB.match(/<section class="panel" id="panel-done"[\s\S]*?<\/section>/) || [''])[0];
|
|
468
|
+
assert.ok(panel, '#panel-done ships as real markup');
|
|
469
|
+
assert.match(panel, /<div class="inviteBox" id="wizInvite" hidden>/, 'the step is a real element the script only shows');
|
|
470
|
+
assert.match(panel, /id="wizInviteWho"/, 'the one field');
|
|
471
|
+
assert.match(panel, /data-invite-send="wizInvite"/, 'the sender');
|
|
472
|
+
assert.match(panel, /data-invite-skip="wizInvite"/, 'and the skip is a CONTROL, not "leave it and move on"');
|
|
473
|
+
assert.match(panel, /Optional/, 'the panel says so before it asks');
|
|
474
|
+
assert.ok(panel.indexOf('id="wizInvite"') < panel.indexOf('id="doneStepsHead"'),
|
|
475
|
+
'it stands before the steps only the owner can play');
|
|
476
|
+
assert.match(panel, /<p class="samePath" id="wizInviteLater"><\/p>/, 'and the way back afterwards is a slot beside the modules one');
|
|
477
|
+
assert.match(SCRIPT, /\$\('wizInviteLater'\)\.innerHTML = 'You can invite builders any time from ' \+ projectPageLink\('the project’s own page'\) \+ '\.';/,
|
|
478
|
+
'filled with one sentence naming THIS project, the doneModules idiom');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('the project’s own page carries the invite line beside its extras', () => {
|
|
482
|
+
assert.match(HUB, /<div class="card mSec" id="mInvite">\s*<h2>Invite a builder<\/h2>/, 'its own section, the mSec idiom');
|
|
483
|
+
assert.ok(HUB.indexOf('id="mInvite"') > HUB.indexOf('id="mModules"'), 'and it stands beside Its extras');
|
|
484
|
+
const sec = (HUB.match(/<div class="card mSec" id="mInvite">[\s\S]*?\n <\/div>/) || [''])[0];
|
|
485
|
+
assert.match(sec, /id="mInviteWho"/, 'the same one field');
|
|
486
|
+
assert.match(sec, /data-invite-send="mInvite"/, 'the same sender');
|
|
487
|
+
assert.doesNotMatch(sec, /data-invite-skip/, 'nothing to skip here — the project already exists');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('the field row reuses the page’s own input+button idiom, and the sheet owns the new class (task 1002346)', () => {
|
|
491
|
+
assert.match(HUB, /<div class="attach">\s*<label class="vh" for="wizInviteWho">/, 'the wizard row is .attach, the page\'s existing pair');
|
|
492
|
+
assert.match(HUB, /<div class="attach">\s*<label class="vh" for="mInviteWho">/, 'and so is the manage row');
|
|
493
|
+
assert.match(COSMOS, /#view-new \.attach input\[type=text\]\{/, 'the wizard\'s hairline field is corrected for the flex row in the sheet');
|
|
494
|
+
assert.match(COSMOS, /\.inviteList\{/, 'the pending list is styled in cosmos.css, not in the page');
|
|
495
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /inviteList/, 'no styles for it in the page head');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('the manage view has a rendered state for the invite line', () => {
|
|
499
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
500
|
+
assert.ok(states['in-manage-invite'], 'the section has a kit state');
|
|
501
|
+
assert.deepEqual(states['in-manage-invite'].expect.visible, ['#mInvite', '#mInvite h2', '#mInviteWho', '#mInvite [data-invite-send]']);
|
|
502
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -2109,7 +2109,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2109
2109
|
assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
|
|
2110
2110
|
assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
|
|
2111
2111
|
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="6">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
|
|
2112
|
-
assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
|
|
2112
|
+
assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
|
|
2113
2113
|
// what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
|
|
2114
2114
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
2115
2115
|
assert.match(SCRIPT, /hosting_shape: 'standalone',/, 'the shape is unchanged');
|
|
@@ -496,8 +496,8 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
|
|
|
496
496
|
'and it is named by id whenever the flow has one');
|
|
497
497
|
// every done-panel pointer that names a CONTROL goes the same way; the ones
|
|
498
498
|
// that only say "watch it there" still name the list, which does show it
|
|
499
|
-
assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length,
|
|
500
|
-
'the helper and its
|
|
499
|
+
assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length, 8,
|
|
500
|
+
'the helper and its seven callers: the locked step, the domain_required snag, the domainless standup, the snagged retry, the torn-down revive, the extras pointer (task 1002342), and the invite pointer (task 1002346)');
|
|
501
501
|
for (const dead of ['Retry it \\(and see the technical details\\) from <a href="\\?view=mine">',
|
|
502
502
|
'Bring it back any time from <a href="\\?view=mine">']) {
|
|
503
503
|
assert.doesNotMatch(SCRIPT, new RegExp(dead), 'a control the list no longer has must not be pointed at there');
|