@bongos/core 1.21.66 → 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.
Files changed (49) hide show
  1. package/.bongos-core.json +111 -41
  2. package/docs/copy-inventory.md +414 -386
  3. package/docs/copy-registry.json +815 -537
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +45 -12
  7. package/docs/page-readings.json +959 -907
  8. package/modules/platform-identity/routes/my-projects.js +1 -1
  9. package/modules/public-landing/public/account.html +1 -1
  10. package/modules/public-landing/public/assets/cosmos.css +12 -10
  11. package/modules/public-landing/public/contact.html +1 -1
  12. package/modules/public-landing/public/guild.html +1 -1
  13. package/modules/public-landing/public/index-cli.states.json +20 -0
  14. package/modules/public-landing/public/index.html +68 -1
  15. package/modules/public-landing/public/index.states.json +4 -4
  16. package/modules/public-landing/public/invitations-empty.states.json +56 -0
  17. package/modules/public-landing/public/invitations-picker.states.json +84 -0
  18. package/modules/public-landing/public/invitations.html +956 -0
  19. package/modules/public-landing/public/invitations.probes.json +430 -0
  20. package/modules/public-landing/public/invitations.states.json +473 -0
  21. package/modules/public-landing/public/privacy.html +1 -1
  22. package/modules/public-landing/public/projects.html +18 -543
  23. package/modules/public-landing/public/projects.probes.json +0 -29
  24. package/modules/public-landing/public/projects.states.json +0 -6
  25. package/modules/public-landing/public/terms.html +1 -1
  26. package/modules/ui-design/kit/fixtures/bongos-cli-latest-sample.json +10 -0
  27. package/modules/ui-design/kit/fixtures/featured-invitations.json +183 -0
  28. package/modules/ui-design/kit/fixtures/guilds-empty.json +1 -0
  29. package/modules/ui-design/kit/fixtures/me-guild-engagements-empty.json +1 -0
  30. package/modules/ui-design/kit/fixtures/me-guild-requests-empty.json +1 -0
  31. package/modules/ui-design/kit/fixtures/my-projects-applied.json +29 -0
  32. package/modules/ui-design/kit/fixtures/my-projects-invites-empty.json +1 -0
  33. package/modules/ui-design/kit/serve.js +5 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +10 -0
  37. package/src/module-api.js +1 -1
  38. package/src/platform-server.js +11 -0
  39. package/tests/hub_account_page.mjs +1 -1
  40. package/tests/hub_engagement_cards.mjs +44 -40
  41. package/tests/hub_guild_cards.mjs +24 -22
  42. package/tests/hub_invitations_page.mjs +414 -0
  43. package/tests/join_relay_outcome.mjs +1 -1
  44. package/tests/landing_legal_pages.mjs +8 -6
  45. package/tests/landing_page.mjs +125 -3
  46. package/tests/my_projects_surface.mjs +14 -6
  47. package/tests/public_landing_projects.mjs +12 -14
  48. package/tests/render_check.mjs +3 -2
  49. package/tests/ui_design_kit.mjs +3 -3
@@ -0,0 +1,414 @@
1
+ // tests/hub_invitations_page.mjs — the hub's /invitations page (task 1004276; the
2
+ // owner's calls of 2026-09-25, ADR 0337 D5).
3
+ //
4
+ // What asks a builder IN — a project's own invite, a guild's invite, a guild's
5
+ // engagement with a project, a request to join a guild they own — and applying to a
6
+ // project by its address left /projects?view=mine for a page of their own. The
7
+ // cards' behaviour moved with them and stays pinned where it was:
8
+ // tests/hub_engagement_cards.mjs and tests/hub_guild_cards.mjs now read this page.
9
+ // What THIS file pins is the page around them:
10
+ // • the markup: the hub's bar with Invitations as the one current place, the
11
+ // signed-out door, the four lists in their order, the empty line, the join field
12
+ // whose button reads "Apply to join" (the owner's word for the join action)
13
+ // • the page RUN in a vm: signed out it shows the door and reads nothing; signed in
14
+ // it reads every list once; the join field applies (a bare host as https, the
15
+ // note trimmed), says the relay's answer, clears on "requested", and an expired
16
+ // session offers a way back to THIS page, built from elements
17
+ // • the route: served on the hub's apex, stamped; never on a child, the builders
18
+ // host or the status host
19
+ // • the kit: a states file for the filled page, one for the empty page, a probes file
20
+ //
21
+ // DB-free. Run: node --test tests/hub_invitations_page.mjs
22
+
23
+ import assert from 'node:assert/strict';
24
+ import { test, before, after } from 'node:test';
25
+ import http from 'node:http';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import vm from 'node:vm';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createRequire } from 'node:module';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
35
+ const PAGE = fs.readFileSync(path.join(LANDING, 'invitations.html'), 'utf8').replace(/\r\n/g, '\n');
36
+ const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
37
+ const STYLE = PAGE.split('<style>')[1].split('</style>')[0].replace(/\/\*[\s\S]*?\*\//g, ''); // minus comments, which name what they rule out
38
+ const MAIN = PAGE.split('<main class="acct" id="invt">')[1].split('</main>')[0];
39
+
40
+ // ── 1. the markup ────────────────────────────────────────────────────────────
41
+
42
+ test('the page wears the hub’s bar, with Invitations as the one current place; dark only, no page :root, not indexed', () => {
43
+ assert.match(PAGE, /<html lang="en" data-mode="dark">/);
44
+ assert.match(PAGE, /<meta name="robots" content="noindex">/);
45
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/, 'the rows and cards are cosmos.css’s own rules');
46
+ assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>/, 'the bar runs on the shared asset');
47
+ assert.doesNotMatch(STYLE, /:root/, 'the pack’s injected theme owns the tokens');
48
+ assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/invitations" aria-current="page">Invitations']);
49
+ assert.match(PAGE, /<a class="skip vh" href="#invt">Skip to content<\/a>/);
50
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/invitations&amp;repos=1">Sign in<\/a>/);
51
+ // the footer is the account page's, platform sentence and all: the one apex-only region
52
+ assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, 1);
53
+ assert.ok(PAGE.indexOf('<!--apex-only:start-->') > PAGE.indexOf('<footer class="foot">'), 'and it is the footer’s');
54
+ });
55
+
56
+ test('the body: a door, a loading line, the four lists in order under one heading, the empty line, and the join field', () => {
57
+ assert.match(MAIN, /<h1>invitations<\/h1>/);
58
+ assert.match(MAIN, /<section class="aDoor" id="door" hidden aria-labelledby="door-h">[\s\S]*?href="\/api\/bongos\/auth\/web\/start\?return=\/invitations&amp;repos=1"/, 'signed out, the door returns here');
59
+ assert.match(MAIN, /<p class="loading" id="loading">/);
60
+ const section = (MAIN.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
61
+ assert.ok(section, 'the invitations section ships as markup, never hidden on its own: it is the page');
62
+ assert.match(section, /<p class="mineEmpty" id="invitesNone" hidden>[^<]+<\/p>/, 'one empty line, down until the reads have answered');
63
+ const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
64
+ assert.ok(order.every((i) => i > 0), 'each list is real markup the script only fills');
65
+ assert.deepEqual([...order].sort((a, b) => a - b), order);
66
+ assert.match(MAIN, /<div class="mineExtra" id="invtBody" hidden>/, 'nothing but the door shows until who is signed in is known');
67
+ assert.match(MAIN, /<button id="joinBtn" type="button" class="btn small">Apply to join<\/button>/, '"Apply to join", the map card’s word');
68
+ assert.match(MAIN, /<textarea id="joinNote" class="mineJoinNote" rows="2" maxlength="400"/, 'the applicant note (task 1002330) stays optional and capped');
69
+ assert.match(MAIN, /its owner sees your application in their own queue/);
70
+ assert.doesNotMatch(PAGE, /[Rr]equest to join/, 'the join action reads "apply" everywhere');
71
+ });
72
+
73
+ test('the script builds no markup from strings and never calls the retired accept flip (ADR 0335, 0353)', () => {
74
+ assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
75
+ assert.doesNotMatch(SCRIPT, /invites\/[^'"]*\/accept/);
76
+ assert.match(SCRIPT, /API \+ '\/my-projects\/invites\/' \+ encodeURIComponent\(inv\.client_id\) \+ '\/decline'/, 'decline only clears the hub’s own row');
77
+ assert.match(SCRIPT, /open\.textContent = 'Open project →';/, 'an invite is a way INTO the project');
78
+ assert.doesNotMatch(SCRIPT, /appliedOrigins|mineOrigins|buildingList/, 'no state of /projects came along');
79
+ });
80
+
81
+ // ── 2. the page, run ─────────────────────────────────────────────────────────
82
+
83
+ function el(tag) {
84
+ const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, value: '', attrs: {}, _on: {}, parent: null };
85
+ Object.defineProperty(n, 'textContent', {
86
+ get() { return n._text + n.children.map((c) => c.textContent).join(''); },
87
+ set(v) { n._text = String(v); n.children = []; },
88
+ });
89
+ n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
90
+ n.remove = () => { if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); };
91
+ n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
92
+ n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
93
+ n.removeAttribute = (k) => { delete n.attrs[k]; };
94
+ Object.defineProperty(n, 'firstChild', { get() { return n.children[0] || null; } });
95
+ n.removeChild = (c) => { n.children = n.children.filter((x) => x !== c); return c; };
96
+ n.querySelectorAll = (sel) => {
97
+ const m = /^\[(\w[\w-]*)="([^"]*)"\]$/.exec(sel);
98
+ const out = [];
99
+ const walk = (x) => { for (const c of x.children) { if (m && c.attrs && c.attrs[m[1]] === m[2]) out.push(c); walk(c); } };
100
+ walk(n);
101
+ return out;
102
+ };
103
+ n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
104
+ n.focus = () => { n.focused = true; };
105
+ n.querySelector = (sel) => {
106
+ const cls = sel.replace(/^\./, '');
107
+ const walk = (x) => { for (const c of x.children) { if (String(c.className).split(' ').includes(cls)) return c; const d = walk(c); if (d) return d; } return null; };
108
+ return walk(n);
109
+ };
110
+ return n;
111
+ }
112
+ const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
113
+ const EMPTY = (url) => {
114
+ if (url.endsWith('/my-projects/invites')) return { ok: true, status: 200, body: { invites: [] } };
115
+ if (url.endsWith('/me/guild-engagements')) return { ok: true, status: 200, body: { engagements: [] } };
116
+ if (url.endsWith('/me/guild-requests')) return { ok: true, status: 200, body: { incoming: [], owner_incoming: [] } };
117
+ return null;
118
+ };
119
+
120
+ async function boot({ me, answers = [], reads = {}, host = 'cloudbongos.com' } = {}) {
121
+ const ids = new Map();
122
+ for (const id of [...PAGE.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])) {
123
+ const n = el('x');
124
+ const tag = PAGE.match(new RegExp(`<(\\w+)[^>]* id="${id}"[^>]*>`));
125
+ n.hidden = !!(tag && / hidden[ >]/.test(tag[0]));
126
+ ids.set(id, n);
127
+ }
128
+ const requests = [];
129
+ const queued = answers.slice();
130
+ const fetch = (url, init = {}) => {
131
+ requests.push({ url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : undefined, credentials: init.credentials });
132
+ const a = EMPTY(url) || reads[url.replace(/^\/api\/bongos/, '')] || queued.shift() || { ok: true, status: 200, body: {} };
133
+ if (a.reject) return Promise.reject(new Error('offline'));
134
+ return Promise.resolve({ ok: a.ok, status: a.status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
135
+ };
136
+ const document = { activeElement: null, getElementById: (id) => ids.get(id) || null, createElement: el, createTextNode: (t) => ({ tag: '#text', textContent: String(t), children: [] }) };
137
+ const window = { location: { host }, HubBar: { paint: () => {}, ready: Promise.resolve(me || null) } };
138
+ const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, encodeURIComponent };
139
+ vm.createContext(sandbox);
140
+ vm.runInContext(SCRIPT, sandbox, { filename: 'invitations.html' });
141
+ await settle();
142
+ const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
143
+ const fire = async (n, type, ev = {}) => {
144
+ if (type === 'focus') document.activeElement = n;
145
+ if (type === 'blur') document.activeElement = null;
146
+ const e = { preventDefault() { e.prevented = true; }, ...ev };
147
+ for (const f of n._on[type] || []) f(e);
148
+ await settle();
149
+ return e;
150
+ };
151
+ return { ids, requests, click, fire, document, $: (id) => ids.get(id) };
152
+ }
153
+
154
+ test('signed out: the door, nothing read, no lists', async () => {
155
+ const ui = await boot({ me: null });
156
+ assert.equal(ui.$('door').hidden, false);
157
+ assert.equal(ui.$('invtBody').hidden, true);
158
+ assert.equal(ui.$('loading').hidden, true);
159
+ assert.deepEqual(ui.requests, [], 'GET /me is the bar’s; this page asks nothing of a stranger');
160
+ });
161
+
162
+ test('signed in: the lists show, every one is read once on the cookie session, and with nothing in them the page says so', async () => {
163
+ const ui = await boot({ me: { github_login: 'octocat' } });
164
+ assert.equal(ui.$('door').hidden, true);
165
+ assert.equal(ui.$('invtBody').hidden, false);
166
+ assert.equal(ui.$('loading').hidden, true);
167
+ assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
168
+ ['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
169
+ for (const r of ui.requests) assert.equal(r.credentials, 'same-origin');
170
+ assert.equal(ui.$('invitesNone').hidden, false, 'every read answered, nothing waiting: one line says so');
171
+ });
172
+
173
+ test('the join field applies: a bare host as https, the note trimmed, the receipt in the relay’s words, and the fields cleared', async () => {
174
+ const ui = await boot({ me: { github_login: 'octocat' }, answers: [{ ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Applied. The project’s owner admits new builders; once approved, sign in to enter.' } }] });
175
+ ui.$('joinOrigin').value = 'grove.cloudbongos.com';
176
+ ui.$('joinNote').value = ' Happy to start on the docs. ';
177
+ const done = ui.click(ui.$('joinBtn'));
178
+ assert.equal(ui.$('joinStatus').textContent, 'Applying…');
179
+ await done;
180
+ const post = ui.requests.find((r) => r.method === 'POST');
181
+ assert.equal(post.url, '/api/bongos/my-projects/join');
182
+ assert.equal(post.credentials, 'same-origin');
183
+ assert.deepEqual(post.body, { origin: 'https://grove.cloudbongos.com', note: 'Happy to start on the docs.' }, 'no guild: the field applies as yourself, alone');
184
+ assert.equal(ui.$('joinStatus').className, 'projMsg ok');
185
+ assert.equal(ui.$('joinStatus').textContent, 'Applied. The project’s owner admits new builders; once approved, sign in to enter.');
186
+ assert.equal(ui.$('joinOrigin').value, '');
187
+ assert.equal(ui.$('joinNote').value, '');
188
+ assert.equal(ui.$('joinBtn').disabled, false);
189
+ });
190
+
191
+ test('the join field: an empty address is a question; a door’s answer is said as its own; an expired session links back HERE; a dead network is said', async () => {
192
+ const ui = await boot({ me: { github_login: 'octocat' }, answers: [
193
+ { ok: true, status: 200, body: { ok: true, status: 'invite_only', message: 'This project is invite-only — it doesn’t take requests.' } },
194
+ { ok: false, status: 401, body: null },
195
+ { reject: true },
196
+ ] });
197
+ await ui.click(ui.$('joinBtn'));
198
+ assert.equal(ui.$('joinStatus').className, 'projMsg err');
199
+ assert.equal(ui.$('joinStatus').textContent, 'Pick a project or enter its address.');
200
+ assert.equal(ui.requests.filter((r) => r.method === 'POST').length, 0);
201
+
202
+ ui.$('joinOrigin').value = 'https://grove.cloudbongos.com';
203
+ await ui.click(ui.$('joinBtn'));
204
+ assert.equal(ui.$('joinStatus').className, 'projMsg', 'the door’s answer, not an error');
205
+ assert.match(ui.$('joinStatus').textContent, /invite-only/);
206
+ assert.equal(ui.$('joinOrigin').value, 'https://grove.cloudbongos.com', 'nothing filed, so nothing cleared');
207
+
208
+ await ui.click(ui.$('joinBtn'));
209
+ const msg = ui.$('joinStatus');
210
+ assert.equal(msg.className, 'projMsg err');
211
+ assert.equal(msg.textContent, 'Your session expired — sign in again.');
212
+ assert.equal(msg.children.find((c) => c.tag === 'a').href, '/api/bongos/auth/web/start?return=/invitations&repos=1');
213
+
214
+ await ui.click(ui.$('joinBtn'));
215
+ assert.equal(msg.textContent, 'Couldn’t reach the platform — check your connection and try again.');
216
+ });
217
+
218
+ // The project picker (owner ask, 2026-10-06: typing an address is tedious). The
219
+ // map feed and the viewer's memberships, read once on the first focus.
220
+ const FEED = { ok: true, status: 200, body: { projects: [
221
+ { name: 'Cloud Bongos', origin: 'https://cloudbongos.com', state: 'public', orb: 'planet' }, // the hub itself
222
+ { name: 'Example World', origin: 'https://example.com', state: 'public', orb: 'planet' }, // builds here
223
+ { name: 'Demo', origin: 'https://demo.cloudbongos.com', state: 'public', orb: 'planet' },
224
+ { name: 'The Grove', origin: 'https://grove.cloudbongos.com', state: 'public', orb: 'planet' },
225
+ { name: 'Tidepool', origin: 'https://tidepool.cloudbongos.com', state: 'public', orb: 'planet' }, // applied
226
+ { name: null, origin: null, state: 'stealth', orb: 'black-hole' }, // anonymous
227
+ ] } };
228
+ const MINE = { ok: true, status: 200, body: { owned: [], building: [{ origin: 'https://example.com' }], applied: [{ origin: 'https://tidepool.cloudbongos.com' }] } };
229
+ const optionsOf = (ui) => ui.$('joinList').children.filter((c) => c.attrs.role === 'option');
230
+
231
+ test('the picker: nothing is read until the field is focused, then the map and the memberships once', async () => {
232
+ const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
233
+ const before = ui.requests.length;
234
+ assert.equal(ui.requests.some((r) => /projects\/featured|\/my-projects$/.test(r.url)), false, 'a visitor who never opens the field never pays for the map');
235
+ await ui.fire(ui.$('joinOrigin'), 'focus');
236
+ await ui.fire(ui.$('joinOrigin'), 'input');
237
+ const reads = ui.requests.slice(before).map((r) => `${r.method} ${r.url}`).sort();
238
+ assert.deepEqual(reads, ['GET /api/bongos/my-projects', 'GET /api/bongos/projects/featured'], 'each read once, however often the field is used');
239
+ for (const r of ui.requests.slice(before)) assert.equal(r.credentials, 'same-origin');
240
+ });
241
+
242
+ test('the picker lists the map minus the hub, an anonymous row and your own projects; one you applied to is Applied and unpickable', async () => {
243
+ const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
244
+ await ui.fire(ui.$('joinOrigin'), 'focus');
245
+ assert.equal(ui.$('joinList').hidden, false);
246
+ assert.equal(ui.$('joinOrigin').attrs['aria-expanded'], 'true');
247
+ const opts = optionsOf(ui);
248
+ assert.deepEqual(opts.map((o) => o.children[0].textContent), ['Demo', 'The Grove', 'Tidepool']);
249
+ assert.deepEqual(opts.map((o) => o.children[1].textContent), ['demo.cloudbongos.com', 'grove.cloudbongos.com', 'tidepool.cloudbongos.com']);
250
+ assert.equal(opts[2].attrs['aria-disabled'], 'true');
251
+ assert.equal(opts[2].children[2].textContent, 'Applied');
252
+ for (const f of opts[2]._on.mousedown) f({ preventDefault() {} });
253
+ assert.equal(ui.$('joinOrigin').value, '', 'an Applied row cannot be picked again');
254
+ });
255
+
256
+ test('the picker narrows by name or address, and the keyboard picks: arrows skip an Applied row, Enter fills the address, Escape closes', async () => {
257
+ const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE } });
258
+ const input = ui.$('joinOrigin');
259
+ await ui.fire(input, 'focus');
260
+ input.value = 'GROVE';
261
+ await ui.fire(input, 'input');
262
+ assert.deepEqual(optionsOf(ui).map((o) => o.children[0].textContent), ['The Grove'], 'a name matches regardless of case');
263
+ input.value = 'https://demo.cloud';
264
+ await ui.fire(input, 'input');
265
+ assert.deepEqual(optionsOf(ui).map((o) => o.children[0].textContent), ['Demo'], 'so does an address, scheme and all');
266
+ input.value = 'nothing like it';
267
+ await ui.fire(input, 'input');
268
+ assert.equal(optionsOf(ui).length, 0);
269
+ assert.match(ui.$('joinList').textContent, /No project on the map matches\. You can still paste its address\./);
270
+
271
+ input.value = '';
272
+ await ui.fire(input, 'input');
273
+ await ui.fire(input, 'keydown', { key: 'ArrowUp' });
274
+ assert.equal(input.attrs['aria-activedescendant'], 'joinOpt1', 'ArrowUp from nothing lands on the last PICKABLE row, past Applied');
275
+ await ui.fire(input, 'keydown', { key: 'ArrowDown' });
276
+ assert.equal(input.attrs['aria-activedescendant'], 'joinOpt0', 'and wraps, skipping Applied again');
277
+ const enter = await ui.fire(input, 'keydown', { key: 'Enter' });
278
+ assert.ok(enter.prevented);
279
+ assert.equal(input.value, 'https://demo.cloudbongos.com');
280
+ assert.equal(ui.$('joinList').hidden, true);
281
+ assert.equal(input.attrs['aria-expanded'], 'false');
282
+ assert.equal(ui.requests.some((r) => r.method === 'POST'), false, 'Enter on an option picks it; it does not apply');
283
+
284
+ await ui.fire(input, 'keydown', { key: 'ArrowDown' });
285
+ assert.equal(ui.$('joinList').hidden, false, 'ArrowDown reopens a closed list');
286
+ await ui.fire(input, 'keydown', { key: 'Escape' });
287
+ assert.equal(ui.$('joinList').hidden, true);
288
+ });
289
+
290
+ test('the picker: a full name typed and never picked applies to that project; a failed map read leaves a plain address field', async () => {
291
+ const ok = { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Applied.' } };
292
+ const ui = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': FEED, '/my-projects': MINE }, answers: [ok] });
293
+ const input = ui.$('joinOrigin');
294
+ await ui.fire(input, 'focus');
295
+ input.value = 'the grove';
296
+ await ui.click(ui.$('joinBtn'));
297
+ assert.deepEqual(ui.requests.find((r) => r.method === 'POST').body, { origin: 'https://grove.cloudbongos.com' });
298
+ await ui.fire(input, 'focus');
299
+ assert.equal(optionsOf(ui).find((o) => o.children[0].textContent === 'The Grove').attrs['aria-disabled'], 'true', 'just applied: the list says so at once');
300
+
301
+ const down = await boot({ me: { github_login: 'octocat' }, reads: { '/projects/featured': { ok: false, status: 503, body: {} } }, answers: [ok] });
302
+ await down.fire(down.$('joinOrigin'), 'focus');
303
+ assert.equal(down.$('joinList').hidden, true, 'no map, no list: the field is the address field it always was');
304
+ down.$('joinOrigin').value = 'grove.cloudbongos.com';
305
+ await down.click(down.$('joinBtn'));
306
+ assert.deepEqual(down.requests.find((r) => r.method === 'POST').body, { origin: 'https://grove.cloudbongos.com' });
307
+ });
308
+
309
+ // The relay exists twice: here, and in projects.html for the map card's Apply to join,
310
+ // which also keeps the map's Applied echo. A page cannot import a page's script, so
311
+ // the copies are held to ONE wire shape instead: both are run and must send the same
312
+ // request and read a 401 the same way. A change to one that the other misses fails here.
313
+ test('the join relay here and the map card’s copy in projects.html send the same request and read a 401 the same way', async () => {
314
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
315
+ const relayOf = (src) => {
316
+ const at = src.indexOf('function postJoinRequest(');
317
+ assert.ok(at > 0, 'postJoinRequest is findable');
318
+ return src.slice(at, src.indexOf('\n }', at) + 4);
319
+ };
320
+ const run = async (src, answer, args) => {
321
+ const sent = [];
322
+ const sandbox = {
323
+ API: '/api/bongos', JSON, Promise, String,
324
+ appliedOrigins: {}, hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').toLowerCase(),
325
+ jsonOrEmpty: (res) => res.json().catch(() => ({})),
326
+ fetch: (url, init) => { sent.push({ url, ...init, body: JSON.parse(init.body) }); return Promise.resolve({ ok: answer.status < 300, status: answer.status, json: () => Promise.resolve(answer.body) }); },
327
+ };
328
+ vm.createContext(sandbox);
329
+ vm.runInContext(relayOf(src), sandbox);
330
+ const out = await sandbox.postJoinRequest(...args);
331
+ // each copy builds its objects in its own vm realm; compare what they SAY, not their prototypes
332
+ return JSON.parse(JSON.stringify({ sent, out }));
333
+ };
334
+ const cases = [
335
+ [{ status: 200, body: { status: 'requested', message: 'Applied.' } }, ['https://grove.example.com', 'hello', null]],
336
+ [{ status: 200, body: { status: 'requested' } }, ['https://grove.example.com', null, 'forge-crew']],
337
+ [{ status: 401, body: null }, ['https://grove.example.com', null, null]],
338
+ [{ status: 409, body: { error: { code: 'guild_engagement_gone' } } }, ['https://grove.example.com', 'n', 'forge-crew']],
339
+ ];
340
+ for (const [answer, args] of cases) {
341
+ const here = await run(SCRIPT, answer, args);
342
+ const map = await run(HUB, answer, args);
343
+ assert.deepEqual(here.sent, map.sent, `the same request for ${JSON.stringify(args)}`);
344
+ assert.deepEqual(here.out, map.out, `the same reading of a ${answer.status}`);
345
+ }
346
+ });
347
+
348
+ test('the picker is a labelled combobox over a listbox, its options built from elements', () => {
349
+ assert.match(MAIN, /<label class="vh" for="joinOrigin">Project name or address<\/label>/);
350
+ assert.match(MAIN, /<input id="joinOrigin" type="text" inputmode="url" [^>]*role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="joinList">/);
351
+ assert.match(MAIN, /<ul id="joinList" class="joinList" role="listbox" aria-label="Projects on the map" hidden><\/ul>/);
352
+ assert.match(SCRIPT, /li\.addEventListener\('mousedown', function \(ev\) \{ ev\.preventDefault\(\); choose\(p\); \}\);/, 'mousedown, so the pick lands before the blur closes the list');
353
+ });
354
+
355
+ // ── 3. the route ─────────────────────────────────────────────────────────────
356
+
357
+ const { buildPlatformApp } = require('../src/platform-server.js');
358
+ const modules = require('../src/modules.js');
359
+ let server;
360
+ let port;
361
+ before(async () => {
362
+ server = http.createServer(buildPlatformApp());
363
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
364
+ port = server.address().port;
365
+ });
366
+ after(async () => {
367
+ modules.setModuleOverrides({});
368
+ await new Promise((resolve) => server.close(resolve));
369
+ });
370
+ function get(pathname, host) {
371
+ return new Promise((resolve, reject) => {
372
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
373
+ let body = '';
374
+ res.on('data', (c) => (body += c));
375
+ res.on('end', () => resolve({ status: res.statusCode, body }));
376
+ }).on('error', reject);
377
+ });
378
+ }
379
+
380
+ test('GET /invitations: served on the hub’s apex, stamped; never on a child, the builders host or the status host', async () => {
381
+ modules.setModuleOverrides({ 'platform-identity': false });
382
+ const child = await get('/invitations', 'game.test.local');
383
+ assert.equal(child.status, 404, 'a child instance has none of the routes this page reads, so no page');
384
+ modules.setModuleOverrides({ 'platform-identity': true });
385
+ const hub = await get('/invitations', 'game.test.local');
386
+ assert.equal(hub.status, 200);
387
+ assert.match(hub.body, /— Invitations<\/title>/);
388
+ assert.match(hub.body, /window\.__BRANDING__=/);
389
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/);
390
+ assert.match(hub.body, /\/assets\/hub-bar\.js\?v=[0-9a-f]{8}/, 'the shared bar asset is content-stamped');
391
+ for (const host of ['builders.test.local', 'status.test.local']) {
392
+ const other = await get('/invitations', host);
393
+ assert.doesNotMatch(other.body, /— Invitations<\/title>/, `${host} must not serve /invitations`);
394
+ }
395
+ });
396
+
397
+ // ── 4. the kit ───────────────────────────────────────────────────────────────
398
+
399
+ test('the kit renders the filled page, the empty page and the door, and probes the join field', () => {
400
+ const read = (f) => JSON.parse(fs.readFileSync(path.join(LANDING, f), 'utf8'));
401
+ const states = read('invitations.states.json');
402
+ assert.equal(states.page, '/invitations');
403
+ assert.deepEqual(states.modes, ['dark']);
404
+ for (const s of ['out', 'in', 'in-join-applied', 'in-profile']) assert.ok(states.states[s], `kit state ${s}`);
405
+ const empty = read('invitations-empty.states.json');
406
+ assert.ok(empty.states['in-empty'] && empty.stub.fixtures['my-projects__invites'] === 'my-projects-invites-empty');
407
+ for (const f of Object.values(empty.stub.fixtures)) {
408
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', f + '.json')), `fixture ${f}`);
409
+ }
410
+ const probes = read('invitations.probes.json').probes.map((p) => p.name);
411
+ assert.ok(probes.some((n) => /signed out/.test(n)) && probes.some((n) => /applied for as https/.test(n)));
412
+ const hubStates = read('projects.states.json').states;
413
+ assert.ok(!Object.keys(hubStates).some((k) => /^in-mine-(engage|guild)/.test(k)), 'the cards’ states moved with the cards');
414
+ });
@@ -17,7 +17,7 @@ test('a filed request (2xx) is "requested"', () => {
17
17
  for (const s of [200, 201]) {
18
18
  const o = joinRelayOutcome(s, { ok: true });
19
19
  assert.equal(o.status, 'requested');
20
- assert.match(o.message, /Requested access/);
20
+ assert.match(o.message, /^Applied\./, 'the receipt reads "applied", the word on every Apply to join button (task 1004276)');
21
21
  }
22
22
  });
23
23
 
@@ -171,7 +171,8 @@ for (const p of PAGES) {
171
171
  '<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
172
172
  '<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
173
173
  '<div class="bandIn bandProfile">',
174
- '<!--apex-only:start--><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->',
174
+ // Invitations and Account are one apex-only region: both pages exist only on the hub (task 1004276)
175
+ '<!--apex-only:start--><li class="bandCell"><a class="bandT" href="/invitations">Invitations</a></li><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->',
175
176
  '<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
176
177
  ]) {
177
178
  assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
@@ -192,13 +193,13 @@ for (const p of PAGES) {
192
193
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
193
194
  ], 'this page has no views of its own, so every item names /projects in full');
194
195
  assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
195
- ['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
196
+ ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
196
197
  ]);
197
198
  assert.doesNotMatch(chrome(html), /aria-current/, 'no band item is this page');
198
199
  const child = stripApexOnly(html, false);
199
200
  assert.deepEqual(bandLinks(bandOf(child, 'menuProfile')), [
200
201
  ['My projects', '/projects?view=mine'], ['Sign out', '/api/bongos/auth/logout'],
201
- ], 'a child instance has no account page, so it loses the item whole');
202
+ ], 'a child instance has no account or invitations page, so it loses both items whole');
202
203
  });
203
204
 
204
205
  test(`${p.file}: the bar is not restyled, and its script is the shared asset`, () => {
@@ -211,7 +212,7 @@ for (const p of PAGES) {
211
212
  });
212
213
  }
213
214
 
214
- test('the three doors and the account page carry one bar: identical but for the sign-in return and the current place', () => {
215
+ test('the three doors, the account page and the invitations page carry one bar: identical but for the sign-in return and the current place', () => {
215
216
  const norm = (html, route) => chrome(html)
216
217
  .replace(`return=${route}&amp;`, 'return=ROUTE&amp;')
217
218
  .replace(/href="#\w+">Skip to content/, 'href="#MAIN">Skip to content')
@@ -221,7 +222,8 @@ test('the three doors and the account page carry one bar: identical but for the
221
222
  const doors = PAGES.map((p) => norm(read(p.file), p.route));
222
223
  assert.equal(doors[1], doors[0], 'terms.html and privacy.html');
223
224
  assert.equal(doors[2], doors[0], 'contact.html and privacy.html');
224
- assert.equal(norm(ACCOUNT, '/account'), doors[0], 'account.html and the doors: one bar, so a change to it is a change to all four');
225
+ assert.equal(norm(ACCOUNT, '/account'), doors[0], 'account.html and the doors: one bar, so a change to it is a change to all five');
226
+ assert.equal(norm(read('invitations.html'), '/invitations'), doors[0], 'invitations.html too (task 1004276)');
225
227
  });
226
228
 
227
229
  test('cosmos.css still carries the bar and the bands the doors lean on', () => {
@@ -343,7 +345,7 @@ for (const p of PAGES) {
343
345
  assert.equal(dom.els.get('who').getAttribute('aria-label'), 'Account, octocat');
344
346
  for (const id of ['whoImg', 'bandWhoImg']) assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
345
347
  assert.equal(dom.els.get('mineCell').hidden, false);
346
- assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
348
+ assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Invitations', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
347
349
  });
348
350
  }
349
351
 
@@ -35,6 +35,7 @@ import { test } from 'node:test';
35
35
  import assert from 'node:assert/strict';
36
36
  import fs from 'node:fs';
37
37
  import path from 'node:path';
38
+ import vm from 'node:vm';
38
39
  import { fileURLToPath } from 'node:url';
39
40
  import { createRequire } from 'node:module';
40
41
 
@@ -566,15 +567,16 @@ test('the Projects band deep-links the hub\'s views; About and Docs are footer l
566
567
  assert.doesNotMatch(body.split('<main')[0], /aria-current/, 'the landing is none of those views');
567
568
  // the profile band: the person, My projects, Account, Sign out as the honest anchor.
568
569
  // Account fills the slot that waited for a settings page (task 1004259) and is
569
- // apex-only, because /account is served only where platform-identity runs.
570
+ // apex-only, because /account is served only where platform-identity runs; so is
571
+ // Invitations, in the same region, for /invitations (task 1004276).
570
572
  assert.match(body, /<div class="menuBand" id="menuProfile" data-menu="profile" hidden>/);
571
573
  assert.match(body, /<p class="bandWho" id="bandWho"><\/p>/);
572
574
  assert.match(body, /<a class="bandT" href="\/projects\?view=mine">My projects<\/a>/);
573
- assert.match(body, /<!--apex-only:start--><li class="bandCell"><a class="bandT" href="\/account">Account<\/a><\/li><!--apex-only:end-->/);
575
+ assert.match(body, /<!--apex-only:start--><li class="bandCell"><a class="bandT" href="\/invitations">Invitations<\/a><\/li><li class="bandCell"><a class="bandT" href="\/account">Account<\/a><\/li><!--apex-only:end-->/);
574
576
  assert.doesNotMatch(body, /class="bandSlot"/, 'the slot is filled, not kept beside its item');
575
577
  assert.match(body, /<a class="bandT signout" href="\/api\/bongos\/auth\/logout">Sign out<\/a>/);
576
578
  // a crawler reads every destination without scripting on
577
- for (const copy of ['Sky', 'Table', 'Your projects', 'New project', 'About', 'Docs', 'My projects', 'Account', 'Sign out']) {
579
+ for (const copy of ['Sky', 'Table', 'Your projects', 'New project', 'About', 'Docs', 'My projects', 'Invitations', 'Account', 'Sign out']) {
578
580
  assert.ok(body.includes('>' + copy + '<'), `"${copy}" is only reachable with scripting on`);
579
581
  }
580
582
  // the menu container is .menuBand: .band is this page's own content sections
@@ -614,3 +616,123 @@ test('the retired gate assets (world/assets/clouds|creatures|plates) are gone
614
616
  // the chrome landing's own plates (task 1003298) live at assets/world/ and are untouched by this cleanup
615
617
  assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.jpg')), 'the chrome plate at assets/world/ is untouched (task 1003649 converts it separately)');
616
618
  });
619
+
620
+ // ── the CLI download (task 1004257) ─────────────────────────────────────────
621
+ // A door that only exists where a release does: /downloads/bongos/* mounts only when the
622
+ // instance has config/bongos-cli.json, and cloudbongos.com had none when this shipped, so a
623
+ // baked link would have been a door to a 404. The block is markup, hidden, and the release
624
+ // feed is what opens it. The platform pick is RUN below in a vm, not read.
625
+
626
+ const CLOSE = HTML.replace(/\r\n/g, '\n').split('<section class="band close">')[1].split('</section>')[0];
627
+ const CODE_LF = CODE.replace(/\r\n/g, '\n');
628
+ const CLI_JS = CODE_LF.slice(CODE_LF.indexOf('var CLI_NAMES'), CODE_LF.indexOf('box.hidden = false;\n });') + 'box.hidden = false;\n });'.length);
629
+
630
+ test('the CLI block is markup in the closing band, hidden, with a door that has no target until the feed names one', () => {
631
+ assert.match(CLOSE, /<div class="cli" data-cli hidden>/, 'baked hidden: no release, no door');
632
+ const get = CLOSE.match(/<a class="door door--solo" data-cli-get([^>]*)>/);
633
+ assert.ok(get, 'the download door is a door');
634
+ assert.doesNotMatch(get[1], /href=/, 'no baked href: the page cannot know the release or the platform');
635
+ assert.match(get[1], /aria-describedby="cliFor"/, 'the door is described by the platform and version it picked');
636
+ assert.match(CLOSE, /<p class="label" id="cliFor" data-cli-for><\/p>/);
637
+ assert.match(CLOSE, /<p class="cliAlso" data-cli-also><\/p>/);
638
+ assert.ok(CLOSE.indexOf('data-cli') > CLOSE.indexOf('class="doorbar"'), 'it sits under the two doors, never beside them');
639
+ assert.doesNotMatch(HTML.split('<section class="hero">')[1].split('</section>')[0], /data-cli/, 'the hero keeps its two doors');
640
+ // [hidden] loses to any display rule on the element, so the container must carry none
641
+ assert.doesNotMatch(PAGE_CSS, /\n\.cli\{[^}]*display/, '.cli sets no display, or [hidden] stops hiding it');
642
+ assert.match(PAGE_CSS, /\.cliAlso a\{display:inline-block;min-height:24px;/, 'the platform links clear the 24px floor');
643
+ });
644
+
645
+ test('the CLI feed is the apex release redirect, and every link stays on this origin', () => {
646
+ assert.ok(CODE.includes("json('/downloads/bongos/latest.json')"), 'the page reads the release feed');
647
+ assert.match(CLI_JS, /get\.href = '\/downloads\/bongos\/' \+ pick;/);
648
+ assert.match(CLI_JS, /a\.href = '\/downloads\/bongos\/' \+ k;/);
649
+ assert.doesNotMatch(CLI_JS, /\.url\b/, 'never the release asset URL: the redirect is how a release bump stays one config file');
650
+ assert.doesNotMatch(CLI_JS, /innerHTML/);
651
+ });
652
+
653
+ // run the region in a vm over a DOM stub: the release feed in, the block's state out
654
+ function cliRun({ feed, nav }) {
655
+ const el = (tag) => ({
656
+ tag, href: null, hidden: false, textContent: '', children: [], removed: false,
657
+ appendChild(c) { this.children.push(c); return c; },
658
+ remove() { this.removed = true; },
659
+ });
660
+ const get = el('a');
661
+ const forEl = el('p');
662
+ const also = el('p');
663
+ const box = el('div');
664
+ box.hidden = true;
665
+ box.querySelector = (s) => ({ '[data-cli-get]': get, '[data-cli-for]': forEl, '[data-cli-also]': also })[s];
666
+ let pending;
667
+ const sandbox = {
668
+ navigator: nav,
669
+ document: {
670
+ querySelector: (s) => (s === '[data-cli]' ? box : null),
671
+ createElement: el,
672
+ createTextNode: (t) => ({ tag: '#text', textContent: t }),
673
+ },
674
+ json: (url) => { assert.equal(url, '/downloads/bongos/latest.json'); pending = Promise.resolve(feed); return pending; },
675
+ };
676
+ vm.runInNewContext(CLI_JS + '\nthis.cliTarget = cliTarget;', sandbox);
677
+ return pending.then(() => ({ box, get, forEl, also, cliTarget: sandbox.cliTarget }));
678
+ }
679
+ const RELEASE = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'bongos-cli-latest-sample.json'), 'utf8'));
680
+ const MAC = { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.89 Safari/537.36', platform: 'MacIntel', maxTouchPoints: 0 };
681
+ const IPHONE = { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 };
682
+
683
+ test('with a release, a Mac gets the Apple-silicon door and every other platform beside it', async () => {
684
+ const r = await cliRun({ feed: RELEASE, nav: MAC });
685
+ assert.equal(r.box.hidden, false);
686
+ assert.equal(r.get.removed, false);
687
+ assert.equal(r.get.href, '/downloads/bongos/mac-arm64');
688
+ assert.equal(r.forEl.textContent, 'macOS, Apple silicon · v0.4.2');
689
+ assert.equal(r.also.children[0].textContent, 'Also for');
690
+ assert.deepEqual(r.also.children.slice(1).map((a) => [a.href, a.textContent]), [
691
+ ['/downloads/bongos/mac-x64', 'macOS, Intel'],
692
+ ['/downloads/bongos/linux-x64', 'Linux, x64'],
693
+ ['/downloads/bongos/linux-arm64', 'Linux, ARM64'],
694
+ ['/downloads/bongos/win-x64', 'Windows'],
695
+ ]);
696
+ });
697
+
698
+ test('a phone gets no guess: no door, the whole list', async () => {
699
+ const r = await cliRun({ feed: RELEASE, nav: IPHONE });
700
+ assert.equal(r.box.hidden, false);
701
+ assert.equal(r.get.removed, true, 'a CLI cannot run on the device that is looking');
702
+ assert.equal(r.forEl.textContent, 'Download the CLI · v0.4.2');
703
+ assert.equal(r.also.children[0].textContent, 'For');
704
+ assert.equal(r.also.children.length, 6);
705
+ });
706
+
707
+ test('no release (the feed 404s, or answers nothing usable) leaves the block hidden', async () => {
708
+ for (const feed of [null, {}, { version: '1.0.0' }, { version: '1.0.0', assets: {} }, { assets: 'x' }]) {
709
+ const r = await cliRun({ feed, nav: MAC });
710
+ assert.equal(r.box.hidden, true, JSON.stringify(feed));
711
+ assert.equal(r.get.href, null);
712
+ }
713
+ });
714
+
715
+ test('a target key that is not a plain slug never becomes a link; a malformed version is dropped', async () => {
716
+ const feed = { version: '1.0.0<b>', assets: { '../../etc': {}, 'javascript:x': {}, 'Mac-ARM64': {}, 'linux-x64': {} } };
717
+ const r = await cliRun({ feed, nav: MAC });
718
+ assert.equal(r.get.removed, true, 'no Mac build in this release, so no door for a Mac');
719
+ assert.equal(r.forEl.textContent, 'Download the CLI');
720
+ assert.deepEqual(r.also.children.slice(1).map((a) => a.href), ['/downloads/bongos/linux-x64']);
721
+ });
722
+
723
+ test('the platform pick', async () => {
724
+ const { cliTarget } = await cliRun({ feed: null, nav: MAC });
725
+ const all = ['mac-arm64', 'mac-x64', 'linux-x64', 'linux-arm64', 'win-x64'];
726
+ const ua = (s, extra) => Object.assign({ userAgent: s, platform: '', maxTouchPoints: 0 }, extra);
727
+ assert.equal(cliTarget(MAC, all), 'mac-arm64', 'a Mac: Apple silicon, since a user agent cannot say');
728
+ assert.equal(cliTarget(MAC, ['mac-x64', 'linux-x64']), 'mac-x64', 'a release with only the Intel build still serves a Mac');
729
+ assert.equal(cliTarget(ua('Mozilla/5.0 (Windows NT 10.0; Win64; x64)', { platform: 'Win32' }), all), 'win-x64');
730
+ assert.equal(cliTarget(ua('Mozilla/5.0 (X11; Linux x86_64)', { platform: 'Linux x86_64' }), all), 'linux-x64');
731
+ assert.equal(cliTarget(ua('Mozilla/5.0 (X11; Linux aarch64)', { platform: 'Linux aarch64' }), all), 'linux-arm64');
732
+ assert.equal(cliTarget(ua('Mozilla/5.0 (X11; CrOS x86_64 14541.0.0)'), all), 'linux-x64', 'ChromeOS runs the Linux build');
733
+ assert.equal(cliTarget(ua('x', { userAgentData: { platform: 'macOS', mobile: false } }), all), 'mac-arm64', 'client hints are read first');
734
+ assert.equal(cliTarget(ua('Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile', { platform: 'Linux armv8l' }), all), null, 'Android says Linux and is not');
735
+ assert.equal(cliTarget(IPHONE, all), null);
736
+ assert.equal(cliTarget(Object.assign({}, MAC, { maxTouchPoints: 5 }), all), null, 'an iPad reports a Mac; touch gives it away');
737
+ assert.equal(cliTarget(ua('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), ['mac-arm64']), null, 'nothing built for this platform: no guess');
738
+ });