@bongos/core 1.24.57 → 1.24.59

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 (56) hide show
  1. package/.bongos-core.json +77 -67
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -2
  4. package/clients/bongos-client/index.cjs +0 -2
  5. package/clients/bongos-client/index.d.ts +1 -3
  6. package/clients/bongos-client/index.mjs +0 -2
  7. package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +1 -37
  10. package/docs/api-reference.md +2 -3
  11. package/docs/architecture.md +1 -1
  12. package/docs/copy-inventory.md +38 -44
  13. package/docs/copy-registry.json +44 -108
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +5 -5
  16. package/docs/recipes/ops-gotchas.md +2 -2
  17. package/modules/hall-ui/public/deploy-handoff.js +11 -3
  18. package/modules/hall-ui/public/deploy.js +10 -3
  19. package/modules/hall-ui/public/settings-software-update.js +4 -71
  20. package/modules/hall-ui/public/update-pill.js +6 -31
  21. package/modules/status-ui/public/style.css +5 -2
  22. package/modules/ui-design/public/brand/png-sources.json +10 -0
  23. package/modules/ui-design/public/brand.css +6 -0
  24. package/modules/ui-design/public/brand.html +2 -2
  25. package/modules/ui-design/scripts/gen-design-reference.js +2 -3
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/public-docs/index.html +3 -1
  29. package/release-notes.json +16 -0
  30. package/scripts/bongos/design-generators-fresh.js +14 -6
  31. package/scripts/bongos/gen-brand.js +82 -36
  32. package/scripts/bongos/provision-core-upgrade.js +30 -1
  33. package/scripts/bongos/provision-units.js +18 -0
  34. package/scripts/hall-preview/server.js +0 -3
  35. package/scripts/status-mirror-sync.js +4 -1
  36. package/src/bongos/routes/auth.js +4 -9
  37. package/src/bongos/routes/core-update.js +36 -76
  38. package/src/bongos/routes/feedback.js +1 -2
  39. package/src/bongos/serve-internal.js +5 -70
  40. package/src/bongos/sign-in-pages.js +20 -4
  41. package/src/brand-mark.js +10 -1
  42. package/src/error-page.js +11 -10
  43. package/src/module-api.js +1 -1
  44. package/src/theme-markup.js +81 -0
  45. package/tests/brand_mark.mjs +11 -1
  46. package/tests/brand_surfaces.mjs +5 -2
  47. package/tests/deploy_handoff.mjs +23 -2
  48. package/tests/deploy_page_projects.mjs +35 -3
  49. package/tests/design_counts_ratchet.mjs +38 -0
  50. package/tests/design_reference.mjs +1 -1
  51. package/tests/provision_settings_apply.mjs +52 -0
  52. package/tests/software_update.mjs +35 -5
  53. package/tests/update_pill.mjs +9 -5
  54. package/tests/wordmark_and_faces.mjs +111 -0
  55. package/src/bongos/upgrade-request.js +0 -158
  56. package/tests/upgrade_request.mjs +0 -348
@@ -1,348 +0,0 @@
1
- // tests/upgrade_request.mjs — "Ask for this upgrade" on Settings → Software update
2
- // (task 1004595).
3
- //
4
- // WHAT IS AT RISK. Will's mercury, a project the Cloud Bongos operator runs, said "Update
5
- // ready" and then nothing: no button, no next step. The fix gives its owner one press that
6
- // reaches the operator and then says it is pending. So these tests hold that:
7
- // - the press is offered only on a project the hub's operator runs, never on a
8
- // self-hosted one (there is nobody at the hub to do it);
9
- // - it reaches the operator through the direct feedback door, with a receipt the
10
- // project's PUBLIC redeem route will vouch for (the hub files nothing without it);
11
- // - it is sent once per version, remembered, and the page then shows it pending;
12
- // - nothing in this file touches the network or a database.
13
- //
14
- // Run: node --test tests/upgrade_request.mjs
15
- import assert from 'node:assert/strict';
16
- import { test } from 'node:test';
17
- import crypto from 'node:crypto';
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
- import vm from 'node:vm';
21
- import { fileURLToPath } from 'node:url';
22
- import { createRequire } from 'node:module';
23
-
24
- const require = createRequire(import.meta.url);
25
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
- const ur = require('../src/bongos/upgrade-request.js');
27
- const { makeReceiptStore } = require('../src/bongos/routes/feedback.js');
28
-
29
- const HUB = 'https://cloudbongos.com';
30
- const WILL = { id: '12', github_id: '4242', github_login: 'will-x' };
31
- const T0 = Date.parse('2026-10-06T21:00:00Z');
32
-
33
- /** A hub that records what it was sent and answers `status`. */
34
- function fakeHub(status = 202) {
35
- const sent = [];
36
- const fetchImpl = async (url, init) => { sent.push({ url, init, body: JSON.parse(init.body) }); return { status }; };
37
- return { sent, fetchImpl };
38
- }
39
- /** project_settings in memory. */
40
- function fakeSettings(initial = null) {
41
- const rows = new Map();
42
- if (initial) rows.set(ur.SETTING_KEY, { key: ur.SETTING_KEY, value: JSON.stringify(initial) });
43
- return {
44
- rows,
45
- getSetting: async (k) => rows.get(k) || null,
46
- setSetting: async (k, v, by) => { const r = { key: k, value: String(v), updated_by: by }; rows.set(k, r); return r; },
47
- };
48
- }
49
- function deps({ hub = fakeHub(), settings = fakeSettings(), store = makeReceiptStore(), now = () => T0 } = {}) {
50
- return { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r), now, _hub: hub, _settings: settings, _store: store };
51
- }
52
- const ASK = { host: 'mercury.cloudbongos.com', running: '1.21.20', wanted: '1.21.66', builder: WILL, doorKind: 'cli' };
53
-
54
- // ---- who is offered the press -------------------------------------------------------
55
-
56
- test('offered on a project the hub runs (by its door, or by its name under the hub), never on a self-hosted one', () => {
57
- const base = { offered: '1.21.66', hub: HUB, stored: null };
58
- assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, true, 'Will\'s mercury: no door, hosted name');
59
- assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'hub' }).available, true, 'a hosted project on its own domain, with a hub door');
60
- assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'cli' }).available, false, 'self-hosted: nobody at the hub upgrades it');
61
- assert.equal(ur.askState({ ...base, host: 'cloudbongos.com', doorKind: 'local' }).available, false, 'the platform runs its own door');
62
- assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli', hub: null }).available, false, 'no hub to ask');
63
- assert.equal(ur.askState({ ...base, offered: null, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, false, 'up to date: nothing to ask for');
64
- assert.equal(ur.askState({ ...base, host: 'localhost:4600', doorKind: 'hub' }).available, false, 'a host the hub cannot reach to check the receipt');
65
- });
66
-
67
- // ---- the press itself ---------------------------------------------------------------
68
-
69
- test('a press reaches the operator through the direct feedback door, and is remembered', async () => {
70
- const d = deps();
71
- const out = await ur.requestUpgrade(ASK, d);
72
- assert.equal(out.status, 201);
73
- assert.deepEqual(out.body.request, { version: '1.21.66', requested_at: new Date(T0).toISOString(), by: 'will-x' });
74
-
75
- assert.equal(d._hub.sent.length, 1);
76
- const { url, body, init } = d._hub.sent[0];
77
- assert.equal(url, `${HUB}/api/bongos/sso/feedback/direct`);
78
- assert.equal(init.redirect, 'error');
79
- assert.equal(body.origin, 'https://mercury.cloudbongos.com', 'the hub redeems the receipt at this project');
80
- assert.equal(body.kind, 'bug');
81
- assert.match(body.message.split('\n')[0], /^Upgrade request: mercury\.cloudbongos\.com asks for core 1\.21\.66 \(it runs 1\.21\.20\)$/);
82
- assert.match(body.message, /@will-x pressed "Ask for this upgrade"/);
83
-
84
- // The receipt is the one the hub will redeem: same sender, same text hash.
85
- const receipt = d._store.redeem(body.receipt);
86
- assert.equal(receipt.github_id, '4242');
87
- assert.equal(receipt.github_login, 'will-x');
88
- assert.equal(receipt.sha256, crypto.createHash('sha256').update(body.message, 'utf8').digest('hex'));
89
- assert.equal(receipt.kind, 'bug');
90
-
91
- const stored = ur.parseStored(d._settings.rows.get(ur.SETTING_KEY));
92
- assert.deepEqual(stored, out.body.request, 'remembered, so the page can say it is pending');
93
- assert.equal(d._settings.rows.get(ur.SETTING_KEY).updated_by, '12');
94
- });
95
-
96
- test('pressing again for the same version sends nothing and answers from the stored request', async () => {
97
- const d = deps({ settings: fakeSettings({ version: '1.21.66', requested_at: '2026-09-01T00:00:00Z', by: 'will-x' }) });
98
- const out = await ur.requestUpgrade(ASK, d);
99
- assert.equal(out.status, 200);
100
- assert.equal(out.body.already, true);
101
- assert.equal(out.body.request.requested_at, '2026-09-01T00:00:00.000Z');
102
- assert.equal(d._hub.sent.length, 0);
103
- });
104
-
105
- test('a newer version may be asked for, but not twice inside a day', async () => {
106
- const recent = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 3600e3).toISOString(), by: 'will-x' });
107
- const d1 = deps({ settings: recent });
108
- const out = await ur.requestUpgrade(ASK, d1);
109
- assert.equal(out.status, 429);
110
- assert.equal(out.body.error, 'asked_recently');
111
- assert.match(out.body.message, /already asked for an upgrade today/, 'every refusal carries a sentence the page can show');
112
- assert.equal(out.body.request.version, '1.21.60', 'the refusal carries the pending request, so the page can show it');
113
- assert.equal(d1._hub.sent.length, 0);
114
-
115
- const old = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 2 * 86400e3).toISOString(), by: 'will-x' });
116
- const d2 = deps({ settings: old });
117
- assert.equal((await ur.requestUpgrade(ASK, d2)).status, 201);
118
- assert.equal(d2._hub.sent.length, 1);
119
- });
120
-
121
- test('nothing is sent, and nothing remembered, when there is no one to ask or nothing to ask for', async () => {
122
- for (const over of [{ doorKind: 'cli', host: 'shop.example.org' }, { doorKind: 'local' }, { wanted: null }, { wanted: '1.21.66"><b>' }]) {
123
- const d = deps();
124
- const out = await ur.requestUpgrade({ ...ASK, ...over }, d);
125
- assert.equal(out.status, 409, JSON.stringify(over));
126
- assert.equal(d._hub.sent.length, 0);
127
- assert.equal(d._settings.rows.size, 0);
128
- }
129
- });
130
-
131
- test('a hub that does not take it, or cannot be reached, leaves the request unasked so it can be pressed again', async () => {
132
- for (const hub of [fakeHub(503), fakeHub(400), { sent: [], fetchImpl: async () => { throw new Error('ECONNRESET'); } }]) {
133
- const d = deps({ hub });
134
- const out = await ur.requestUpgrade(ASK, d);
135
- assert.equal(out.status, 502);
136
- assert.equal(out.body.error, 'operator_unreachable');
137
- assert.equal(d._settings.rows.size, 0);
138
- }
139
- const limited = deps({ hub: fakeHub(429) });
140
- const lim = await ur.requestUpgrade(ASK, limited);
141
- assert.equal(lim.status, 429);
142
- assert.match(lim.body.message, /Try again in an hour/);
143
- assert.equal(limited._settings.rows.size, 0);
144
- });
145
-
146
- test('a stored row this file did not write reads as not asked', () => {
147
- for (const value of ['', 'nope', '{}', JSON.stringify({ version: 'x', requested_at: '2026-10-01' }), JSON.stringify({ version: '1.2.3', requested_at: 'never' })]) {
148
- assert.equal(ur.parseStored({ value }), null, value);
149
- }
150
- assert.equal(ur.parseStored({ value: JSON.stringify({ version: '1.2.3', requested_at: '2026-10-01T00:00:00Z', by: '<script>' }) }).by, null);
151
- });
152
-
153
- // ---- the receipt is redeemable at the project's PUBLIC route -------------------------
154
-
155
- test('the receipt the press issues is the one the public redeem route answers (one shared store)', async () => {
156
- const fb = require('../src/bongos/routes/feedback.js');
157
- const express = require('express');
158
- const app = express();
159
- app.use(require('../src/bongos/middleware/error-envelope.js').attachFail);
160
- app.use(fb());
161
- const hub = fakeHub();
162
- const settings = fakeSettings();
163
- const out = await ur.requestUpgrade(ASK, { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting,
164
- issueReceipt: (r) => fb.defaultReceiptStore().issue(r), now: () => T0 });
165
- assert.equal(out.status, 201);
166
- const server = app.listen(0);
167
- await new Promise((r) => server.once('listening', r));
168
- try {
169
- const res = await fetch(`http://127.0.0.1:${server.address().port}/feedback/receipt/${hub.sent[0].body.receipt}`);
170
- assert.equal(res.status, 200);
171
- const body = await res.json();
172
- assert.equal(body.github_id, '4242');
173
- assert.equal(body.sha256, crypto.createHash('sha256').update(hub.sent[0].body.message, 'utf8').digest('hex'));
174
- } finally { server.close(); }
175
- });
176
-
177
- // ---- the route ----------------------------------------------------------------------
178
-
179
- const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
180
-
181
- test('[source] the press is gated on the same atom as the Update button', () => {
182
- const at = ROUTE_SRC.indexOf("'/software-update/request'");
183
- assert.ok(at > 0);
184
- const seg = ROUTE_SRC.slice(at, ROUTE_SRC.indexOf("asyncHandler('POST /software-update/request'"));
185
- assert.match(seg, /auth\.requireBuilder/);
186
- assert.match(seg, /auth\.requirePermission\('core\.pin\.move'\)/);
187
- });
188
-
189
- async function serve(deps, { forwardedHost = 'evil.example' } = {}) {
190
- const auth = require('../src/bongos/auth.js');
191
- const saved = { b: auth.requireBuilder, p: auth.requirePermission };
192
- auth.requireBuilder = (req, _res, next) => { req.builder = { ...WILL }; next(); };
193
- auth.requirePermission = () => (_req, _res, next) => next();
194
- const build = require('../src/bongos/routes/core-update.js');
195
- const express = require('express');
196
- const app = express();
197
- app.use(express.json());
198
- app.use((_req, res, next) => { res.fail = (code, o) => res.status(o.status).json({ error: { code, message: o.message || code, details: o.details } }); next(); });
199
- try { app.use(build(deps)); } finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
200
- const server = app.listen(0);
201
- await new Promise((r) => server.once('listening', r));
202
- const base = `http://127.0.0.1:${server.address().port}`;
203
- const call = async (method, p) => {
204
- const res = await fetch(`${base}${p}`, { method, headers: { 'X-Forwarded-Host': forwardedHost, Host: forwardedHost, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined });
205
- return { status: res.status, body: await res.json() };
206
- };
207
- return { call, close: () => server.close() };
208
- }
209
- const UPDATE = { reading: 'ok', running: '1.21.20', offered: '1.21.66' };
210
-
211
- test('GET then POST then GET: offered, sent once, then shown as pending', async () => {
212
- // A hub that takes a moment, so the two presses below truly overlap.
213
- const hub = fakeHub();
214
- const quick = hub.fetchImpl;
215
- hub.fetchImpl = async (...a) => { await new Promise((r) => setTimeout(r, 40)); return quick(...a); };
216
- const settings = fakeSettings();
217
- const store = makeReceiptStore();
218
- const s = await serve({
219
- softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true,
220
- doorInputs: () => ({ provisioning: false, idp: null }),
221
- askDeps: () => ({ hub: HUB, host: 'mercury.cloudbongos.com', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r) }),
222
- });
223
- try {
224
- let r = await s.call('GET', '/software-update');
225
- assert.equal(r.status, 200);
226
- assert.deepEqual(r.body.door, { kind: 'cli', url: null });
227
- assert.equal(r.body.ask.available, true);
228
- assert.equal(r.body.ask.hub_host, 'cloudbongos.com');
229
- assert.equal(r.body.ask.requested, null);
230
-
231
- const [a, b] = await Promise.all([s.call('POST', '/software-update/request'), s.call('POST', '/software-update/request')]);
232
- assert.deepEqual([a.status, b.status].sort(), [200, 201], 'a double press sends once; the second is answered from the row');
233
- assert.equal(hub.sent.length, 1);
234
- assert.equal(hub.sent[0].body.origin, 'https://mercury.cloudbongos.com',
235
- 'the call-back address is the project\'s own configured host, never the request\'s (X-Forwarded-Host: evil.example here)');
236
-
237
- r = await s.call('GET', '/software-update');
238
- assert.equal(r.body.ask.requested.version, '1.21.66');
239
- assert.equal(r.body.ask.requested.by, 'will-x');
240
- } finally { s.close(); }
241
- });
242
-
243
- test('the version asked for is the server\'s own reading, and a self-hosted project is refused', async () => {
244
- const hub = fakeHub();
245
- const settings = fakeSettings();
246
- const askDeps = () => ({ hub: HUB, host: 'shop.example.org', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: () => 'r'.repeat(24) });
247
- const common = { softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }), askDeps };
248
- // A self-hosted project whose caller claims a hosted name in the headers is still refused.
249
- const self = await serve(common, { forwardedHost: 'mercury.cloudbongos.com' });
250
- try {
251
- const r = await self.call('POST', '/software-update/request');
252
- assert.equal(r.status, 409);
253
- assert.equal(r.body.error.code, 'not_operator_run');
254
- assert.equal((await self.call('GET', '/software-update')).body.ask.available, false);
255
- assert.equal(hub.sent.length, 0);
256
- } finally { self.close(); }
257
- });
258
-
259
- // ---- the panel ----------------------------------------------------------------------
260
-
261
- const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
262
- const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
263
-
264
- function bootPanel(answers) {
265
- const el = (id) => ({ id, hidden: true, disabled: false, textContent: '', _html: '', dataset: {}, handlers: {},
266
- set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
267
- querySelectorAll: () => [], addEventListener(ev, fn) { this.handlers[ev] = fn; } });
268
- const els = new Map(['software-update-scroll', 'su-body', 'su-note', 'settings-groups'].map((id) => [id, el(id)]));
269
- els.get('settings-groups').dataset.active = 'update';
270
- // Buttons drawn into su-body exist only while its html carries their id.
271
- const dynamic = new Map();
272
- const getElementById = (id) => {
273
- if (els.has(id)) return els.get(id);
274
- if (!els.get('su-body').innerHTML.includes(`id="${id}"`)) return null;
275
- const key = `${id}@${els.get('su-body').innerHTML.length}:${els.get('su-body').innerHTML.slice(0, 40)}`;
276
- if (!dynamic.has(key)) dynamic.set(key, el(id));
277
- return dynamic.get(key);
278
- };
279
- const calls = [];
280
- const windowObj = {
281
- BongosClient: { createClient: () => ({ request: async (m, url, opts) => { calls.push({ m, url, opts }); return answers(m, url); } }) },
282
- OTB: { escapeHtml },
283
- location: { assign: () => {} },
284
- };
285
- const sandbox = { window: windowObj, document: { getElementById, addEventListener: () => {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
286
- vm.createContext(sandbox);
287
- vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
288
- const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); };
289
- return { calls, settle, body: () => els.get('su-body').innerHTML, byId: getElementById };
290
- }
291
- const ok = (data, status = 200) => ({ ok: status < 400, status, data });
292
- const PAGE = { ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.66', whats_new: null }, door: { kind: 'cli', url: null }, can_update: true,
293
- ask: { available: true, hub_host: 'cloudbongos.com', requested: null, ask_again_after: null } };
294
-
295
- test('Will\'s page: who upgrades it, and an Ask button, never a server command', async () => {
296
- const b = bootPanel(() => ok(PAGE));
297
- await b.settle();
298
- assert.match(b.body(), /the team at cloudbongos\.com looks after, so they install its updates/);
299
- assert.match(b.body(), /id="su-ask">Ask for this upgrade</);
300
- assert.doesNotMatch(b.body(), /bongos upgrade/);
301
- assert.match(b.body(), /Update ready/);
302
- });
303
-
304
- test('pressing it sends the request and the page then says it is pending', async () => {
305
- const request = { version: '1.21.66', requested_at: '2026-10-06T21:00:00.000Z', by: 'will-x' };
306
- const b = bootPanel((m) => (m === 'POST' ? ok({ ok: true, already: false, request }, 201) : ok(PAGE)));
307
- await b.settle();
308
- await b.byId('su-ask').handlers.click();
309
- await b.settle();
310
- assert.deepEqual(b.calls.map((c) => `${c.m} ${c.url}`), ['GET /api/bongos/software-update', 'POST /api/bongos/software-update/request']);
311
- assert.match(b.body(), /Upgrade requested on 6 October 2026 by @will-x, for version <span class="su-ver">1\.21\.66<\/span>\. The team at cloudbongos\.com has your request/);
312
- assert.match(b.body(), /<span class="su-pill">Requested<\/span>/);
313
- assert.doesNotMatch(b.body(), /id="su-ask"/, 'no second press offered');
314
- });
315
-
316
- test('a page loaded after asking shows it pending; a failed press says why and can be pressed again', async () => {
317
- const asked = { ...PAGE, ask: { ...PAGE.ask, requested: { version: '1.21.66', requested_at: '2026-10-05T10:00:00Z', by: null }, ask_again_after: '2026-10-06T10:00:00Z' } };
318
- const p = bootPanel(() => ok(asked));
319
- await p.settle();
320
- assert.match(p.body(), /Upgrade requested on 5 October 2026, for version/);
321
- assert.doesNotMatch(p.body(), /id="su-ask"/);
322
-
323
- const f = bootPanel((m) => (m === 'POST' ? ok({ error: { code: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } }, 502) : ok(PAGE)));
324
- await f.settle();
325
- const btn = f.byId('su-ask');
326
- await btn.handlers.click();
327
- await f.settle();
328
- assert.equal(btn.disabled, false);
329
- assert.equal(f.byId('su-ask-status').textContent, 'Could not send the request: Could not reach the people who run this project. Try again in a few minutes.');
330
- });
331
-
332
- test('a hosted project with a hub door keeps its Update button and offers asking as the fallback', async () => {
333
- const b = bootPanel(() => ok({ ...PAGE, door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' } }));
334
- await b.settle();
335
- assert.match(b.body(), /id="su-update" href="https:\/\/cloudbongos\.com\/deploy\?project=mercury"/);
336
- assert.match(b.body(), /If that page does not let you in, ask the team at cloudbongos\.com/);
337
- assert.match(b.body(), /class="pbtn pbtn--quiet" id="su-ask"/);
338
- });
339
-
340
- test('no ask for a viewer who may not upgrade, or where the server does not offer it', async () => {
341
- const viewer = bootPanel(() => ok({ ...PAGE, can_update: false }));
342
- await viewer.settle();
343
- assert.doesNotMatch(viewer.body(), /id="su-ask"/);
344
- const self = bootPanel(() => ok({ ...PAGE, ask: { ...PAGE.ask, available: false } }));
345
- await self.settle();
346
- assert.doesNotMatch(self.body(), /id="su-ask"/);
347
- assert.match(self.body(), /<code>bongos upgrade<\/code>/);
348
- });