@bongos/core 1.23.11 → 1.23.12

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.
@@ -78,6 +78,7 @@ test('no top block: one visually hidden h1, then one section per sub-page, only
78
78
  assert.match(MAIN, /<ol class="commRows" id="boardList" data-unit="\{\{currency\}\}" aria-labelledby="boardH"><\/ol>/, 'the unit is the instance’s own word, stamped by the server');
79
79
  assert.match(MAIN, /<p class="projMsg err" id="boardFailed" role="alert" hidden><\/p>/);
80
80
  assert.match(MAIN, /<form class="mineJoinRow commFind" id="findForm" role="search"/);
81
+ for (const id of ['boardFor', 'findFor']) assert.match(MAIN, new RegExp(`<div class="commFor" id="${id}" hidden></div>`), `${id}: the Recruit door’s line waits for GET /my-projects (task 1002292)`);
81
82
  assert.match(MAIN, /<h2 id="recruitH">Open to recruiters<\/h2>/, 'the hide flag is "hidden", never "private" (ADR 0337)');
82
83
  assert.match(MAIN, /<a class="btn" id="recruitSignin" href="\/api\/bongos\/auth\/web\/start\?return=\/community\/recruiters&amp;repos=1">/);
83
84
  for (const id of ['recruitDoor', 'recruitClosed', 'recruitFailed', 'recruitEmpty']) assert.match(MAIN, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
@@ -100,11 +101,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
100
101
  assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
101
102
  });
102
103
 
103
- test('the script builds no markup from strings, reads only the three routes, and never re-ranks or reads the ranked tier', () => {
104
+ test('the script builds no markup from strings, reads only the four routes, and never re-ranks or reads the ranked tier', () => {
104
105
  assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
105
- // every path literal in the code: the API base, the three reads, the three sub-pages and the sign-in door
106
+ // every path literal in the code: the API base, the four reads, the three sub-pages, the
107
+ // sign-in door and the hall's invite box the Recruit door opens (task 1002292)
106
108
  const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
107
- assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/scouting']);
109
+ assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/my-projects', '/scouting']);
108
110
  assert.doesNotMatch(CODE, /method:/, 'it only reads');
109
111
  assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
110
112
  assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
@@ -173,7 +175,9 @@ async function boot({ pathname = '/community', me = null, reads = READS() } = {}
173
175
  requests.push({ url, credentials: init.credentials });
174
176
  const a = reads[url.replace(/^\/api\/bongos/, '')];
175
177
  if (!a || a.reject) return Promise.reject(new Error('offline'));
176
- return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) });
178
+ const res = { ok: a.status >= 200 && a.status < 300, status: a.status, json: () => Promise.resolve(a.body) };
179
+ // `slow`: answers on a timer, after every microtask-settled read (the order a slow network gives)
180
+ return a.slow ? new Promise((resolve) => setTimeout(() => resolve(res), 5)) : Promise.resolve(res);
177
181
  };
178
182
  const document = {
179
183
  title: 'Cloud Bongos — Community',
@@ -225,7 +229,8 @@ test('the leaderboard, signed out: the all-time board in the server’s order wi
225
229
  assert.equal(find(bo, 'mineRow__hint').textContent, '1 work shipped · 0 karma');
226
230
  assert.equal(ui.$('boardNote').textContent, 'Credits earned across every project, since each builder started.');
227
231
  const signedIn = await boot({ me: { github_login: 'someone' } });
228
- assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed in, the same one read: viewing is ungated');
232
+ assert.deepEqual(signedIn.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects'], 'signed in, the same board read: viewing is ungated, and the one more read is the Recruit door’s');
233
+ assert.equal(signedIn.$('boardList').textContent, ui.$('boardList').textContent, 'the same rows for every viewer');
229
234
  });
230
235
 
231
236
  test('the window dropdown and a craft are each one read; the pressed craft follows the pick', async () => {
@@ -320,6 +325,114 @@ test('recruiters: a door signed out, a refusal on a 403, rows only from a 200 as
320
325
  assert.equal(failed.$('recruitFailed').textContent, 'This list could not be read. Reload the page to try again.');
321
326
  });
322
327
 
328
+ // ── 2b. the Recruit door (task 1002292; spec D6, owner 2026-10-27) ───────────
329
+ // A project OWNER gets a Recruit link on each row: the hub cannot read a project's
330
+ // `project.recruit` (ADR 0335 D1), so GET /my-projects' `owned` stands in. The link is the
331
+ // project's own hall invite box with the GitHub login filled in (ADR 0353), and the
332
+ // project decides.
333
+
334
+ const OWNED = (...ps) => ({ status: 200, body: {
335
+ owned: ps.map(([client_id, origin, name]) => ({ client_id, name, origin, hall_origin: null, membership_kind: 'owner' })),
336
+ building: [{ client_id: 'zed', name: 'Zed', origin: 'https://zed.example', hall_origin: null, membership_kind: 'member' }],
337
+ applied: [],
338
+ } });
339
+ const recruitOf = (row) => find(row, 'commRecruit');
340
+
341
+ test('recruit: an owner gets a Recruit link on every row but their own, opening their project’s hall invite box with the GitHub login filled in', async () => {
342
+ const reads = READS();
343
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example/x?y=1', 'Mercury']);
344
+ const ui = await boot({ me: { github_login: 'BO' }, reads });
345
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/my-projects']);
346
+ const [ana, bo] = kids(ui.$('boardList'));
347
+ const a = recruitOf(ana);
348
+ assert.equal(a.tag, 'a');
349
+ assert.equal(a.textContent, 'Recruit');
350
+ assert.equal(a.attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the project’s origin and nothing else of the address');
351
+ assert.equal(a.attrs.target, '_blank', 'the board stays open behind it');
352
+ assert.equal(a.attrs.rel, 'noopener');
353
+ assert.equal(a.attrs['aria-label'], 'Recruit ANA to Mercury (opens in a new tab)', 'the new tab is said, not sprung');
354
+ assert.equal(recruitOf(bo).tag, 'span', 'your own row: an invisible twin keeps the credits in line');
355
+ assert.equal(recruitOf(bo).attrs['aria-hidden'], 'true');
356
+ assert.match(ui.$('boardList').className, /\bcanRecruit\b/);
357
+ assert.equal(ui.$('boardFor').hidden, false);
358
+ assert.equal(ui.$('boardFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
359
+ await ui.pick('30d');
360
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=cy', 'a new window’s rows carry it too');
361
+ reads['/community/leaderboard?window=all'].slow = true;
362
+ const late = await boot({ me: { github_login: 'BO' }, reads });
363
+ assert.equal(kids(late.$('boardList')).length, 0, 'the board has not answered yet');
364
+ assert.equal(late.$('boardFor').hidden, false, 'the owner’s line is up before the board');
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ await settle();
367
+ assert.equal(recruitOf(kids(late.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'a board that answers after GET /my-projects draws its doors as it paints');
368
+ delete reads['/community/leaderboard?window=all'].slow;
369
+ reads['/community/leaderboard?window=all'].body.results[0].github_login = 'a b&c';
370
+ const odd = await boot({ me: { github_login: 'x' }, reads });
371
+ assert.equal(recruitOf(kids(odd.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=a%20b%26c', 'the login is encoded into the query');
372
+ });
373
+
374
+ test('recruit: none for a builder who owns no project, signed out, on a failed read, or for a project with no web address; the rows stay the same', async () => {
375
+ const reads = READS();
376
+ reads['/my-projects'] = OWNED();
377
+ const member = await boot({ me: { github_login: 'someone' }, reads });
378
+ assert.equal(member.gets().pop(), '/api/bongos/my-projects');
379
+ assert.ok(kids(member.$('boardList')).every((r) => !recruitOf(r)), 'building on a project is not owning one');
380
+ assert.equal(member.$('boardFor').hidden, true);
381
+ assert.doesNotMatch(member.$('boardList').className, /canRecruit/);
382
+ const out = await boot({ reads });
383
+ assert.deepEqual(out.gets(), ['/api/bongos/community/leaderboard?window=all'], 'signed out, nobody is asked about projects');
384
+ for (const answer of [{ status: 500, body: {} }, { status: 401, body: {} }, { reject: true }, OWNED(['evil', 'javascript:alert(1)', 'Evil'], ['none', null, 'None'])]) {
385
+ reads['/my-projects'] = answer;
386
+ const ui = await boot({ me: { github_login: 'someone' }, reads });
387
+ assert.ok(kids(ui.$('boardList')).every((r) => !recruitOf(r)), 'no door, and the board is drawn all the same');
388
+ assert.equal(kids(ui.$('boardList')).length, 2);
389
+ assert.equal(ui.$('boardFailed').hidden, true, 'a failed projects read is never the board’s failure');
390
+ assert.equal(ui.$('boardFor').hidden, true);
391
+ }
392
+ });
393
+
394
+ test('recruit: several projects are one “Recruit to” choice, and picking one re-points every row', async () => {
395
+ const reads = READS();
396
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury'], ['venus', 'http://venus.example:8080', 'Venus']);
397
+ const ui = await boot({ me: { github_login: 'nobody' }, reads });
398
+ const pick = find(ui.$('boardFor'), 'commForPick');
399
+ assert.equal(pick.tag, 'select');
400
+ assert.equal(pick.id, 'boardForPick');
401
+ assert.deepEqual(kids(pick).map((o) => [o.value, o.textContent]), [['0', 'Mercury'], ['1', 'Venus']]);
402
+ assert.equal(kids(ui.$('boardFor'))[0].attrs.for, 'boardForPick', 'the choice is labelled');
403
+ assert.match(ui.$('boardFor').textContent, /^Recruit to/);
404
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'https://mercury.example/builders/watch?invite=ana', 'the first project, as the server listed them');
405
+ pick.value = '1';
406
+ for (const f of pick._on.change || []) f({});
407
+ await settle();
408
+ const [ana, bo] = kids(ui.$('boardList'));
409
+ assert.equal(recruitOf(ana).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana');
410
+ assert.equal(recruitOf(bo).attrs['aria-label'], 'Recruit bo to Venus (opens in a new tab)');
411
+ assert.equal(find(ui.$('boardFor'), 'commForPick'), pick, 'the choice is not redrawn, so it keeps the focus');
412
+ pick.value = '7';
413
+ for (const f of pick._on.change || []) f({});
414
+ assert.equal(recruitOf(kids(ui.$('boardList'))[0]).attrs.href, 'http://venus.example:8080/builders/watch?invite=ana', 'a value it never offered changes nothing');
415
+ });
416
+
417
+ test('recruit: found builders carry it too; the recruiter list never asks', async () => {
418
+ const reads = READS();
419
+ reads['/my-projects'] = OWNED(['mercury', 'https://mercury.example', 'Mercury']);
420
+ const ui = await boot({ pathname: '/community/find', me: { github_login: 'ph' }, reads });
421
+ assert.deepEqual(ui.gets(), ['/api/bongos/my-projects'], 'nothing searched until asked');
422
+ assert.equal(ui.$('findFor').hidden, false);
423
+ assert.equal(ui.$('findFor').textContent, 'Recruit opens Mercury’s hall with their name filled in, and you send the invite there.');
424
+ assert.equal(ui.$('boardFor').hidden, true, 'only the shown sub-page’s line');
425
+ ui.$('findQ').value = 'pri';
426
+ await ui.submit();
427
+ const [priya, hidden] = kids(ui.$('findList'));
428
+ const acts = find(priya, 'mineRow__actions');
429
+ assert.equal(recruitOf(acts).attrs.href, 'https://mercury.example/builders/watch?invite=priyan', 'by GitHub login, never the handle');
430
+ assert.equal(recruitOf(hidden), null, 'your own row: no door, and no twin in a row that keeps no columns');
431
+ assert.equal(ui.$('findStatus').textContent, '2 builders found.');
432
+ const recruiters = await boot({ pathname: '/community/recruiters', me: { github_login: 'owner' }, reads });
433
+ assert.deepEqual(recruiters.gets(), ['/api/bongos/scouting'], 'the recruiter list draws no Recruit door');
434
+ });
435
+
323
436
  // ── 3. every bar carries the Community menu ──────────────────────────────────
324
437
 
325
438
  const SERVES_A_CHILD = ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'];
@@ -437,11 +550,20 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
437
550
  assert.equal(states.states['out-find'].url, '/community/find');
438
551
  assert.equal(states.states['in-recruiters'].url, '/community/recruiters');
439
552
  assert.ok(states.states['in-recruiters'].expect.visible.includes('#recruitClosed'), 'signed in but not admitted: told, shown no one');
440
- for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter']]) {
553
+ for (const [file, name, fixture] of [['community-empty.states.json', 'community-leaderboard', 'community-leaderboard-empty'], ['community-recruiter.states.json', 'scouting', 'scouting-recruiter'], ['community-owner.states.json', 'my-projects', 'my-projects-owner'], ['community-owner-two.states.json', 'my-projects', 'my-projects-owner-two']]) {
441
554
  assert.equal(json(file).stub.fixtures[name], fixture);
442
555
  assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
443
556
  }
444
557
  assert.equal(json('community-recruiter.states.json').states['in-recruiter'].url, '/community/recruiters');
558
+ // the Recruit door (task 1002292): an owner of one project on the board and the search, and of two with the choice
559
+ assert.ok(json('community-owner.states.json').states['in-owner'].expect.count.some(([sel, n]) => sel === '#boardList a.commRecruit' && n === 6));
560
+ assert.equal(json('community-owner.states.json').states['in-owner-find'].url, '/community/find');
561
+ assert.ok(json('community-owner-two.states.json').states['in-owner-two-picked'].actions.some((a) => a[0] === 'select' && a[1] === '#boardForPick'));
562
+ for (const f of ['my-projects-owner', 'my-projects-owner-two']) {
563
+ const owned = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', f + '.json'), 'utf8')).owned;
564
+ assert.ok(owned.length && owned.every((p) => p.membership_kind === 'owner' && /^https:\/\//.test(p.origin)), `${f}: owned projects with web addresses`);
565
+ }
566
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'my-projects.json'), 'utf8')).owned, [], 'the default builder owns nothing, so every other state draws no door');
445
567
  });
446
568
 
447
569
  function stub(env = {}) {