@bongos/core 1.19.952 → 1.19.954

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 (50) hide show
  1. package/.bongos-core.json +65 -55
  2. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +80 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/copy-inventory.md +32 -24
  5. package/docs/copy-registry.json +100 -28
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +3 -3
  13. package/modules/hall-ui/public/deploy.js +97 -5
  14. package/modules/hall-ui/public/diagrams.html +1 -1
  15. package/modules/hall-ui/public/drachmae.html +1 -1
  16. package/modules/hall-ui/public/gate.html +1 -1
  17. package/modules/hall-ui/public/goal-map.html +1 -1
  18. package/modules/hall-ui/public/goals.html +1 -1
  19. package/modules/hall-ui/public/government.html +1 -1
  20. package/modules/hall-ui/public/idea.html +1 -1
  21. package/modules/hall-ui/public/ideas.html +1 -1
  22. package/modules/hall-ui/public/index.html +1 -1
  23. package/modules/hall-ui/public/modules.html +1 -1
  24. package/modules/hall-ui/public/people.html +1 -1
  25. package/modules/hall-ui/public/primer.html +1 -1
  26. package/modules/hall-ui/public/profile.html +1 -1
  27. package/modules/hall-ui/public/project-settings.css +16 -0
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.html +1 -1
  33. package/modules/hall-ui/public/settings.html +1 -1
  34. package/modules/hall-ui/public/shell.js +12 -3
  35. package/modules/hall-ui/public/sky.html +1 -1
  36. package/modules/hall-ui/public/studio.html +1 -1
  37. package/modules/hall-ui/public/style.css +6 -0
  38. package/modules/hall-ui/public/task.html +1 -1
  39. package/modules/hall-ui/public/thinking.html +1 -1
  40. package/modules/hall-ui/public/watch.html +1 -1
  41. package/modules/hall-ui/public/work.html +1 -1
  42. package/modules/provisioning/core-upgrade.js +35 -3
  43. package/modules/provisioning/migrations/provisioning_022_last_core_move_index.sql +25 -0
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/src/module-api.js +1 -1
  47. package/tests/core_update_banner.mjs +30 -2
  48. package/tests/core_upgrade_door.mjs +50 -0
  49. package/tests/deploy_page_projects.mjs +131 -7
  50. package/tests/nav_permission_atoms.mjs +1 -1
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.952'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.954'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -202,7 +202,7 @@ test('a real request through the assembled router returns the verdict, not a 500
202
202
 
203
203
  // ── the banner ────────────────────────────────────────────────────────────────
204
204
 
205
- function boot({ permissions, update, status = 200, dismissed = null, provisioning = true } = {}) {
205
+ function boot({ permissions, update, status = 200, dismissed = null, provisioning = true, pathname = '/settings' } = {}) {
206
206
  const els = new Map();
207
207
  const inserted = [];
208
208
  const stored = {};
@@ -227,7 +227,7 @@ function boot({ permissions, update, status = 200, dismissed = null, provisionin
227
227
  addEventListener() {}, dispatchEvent() {},
228
228
  get cookie() { return ''; }, set cookie(_v) {},
229
229
  };
230
- const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
230
+ const location = { hostname: 'h', origin: 'https://h', pathname, hash: '', assign() {}, replace() {} };
231
231
  const windowObj = {
232
232
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
233
233
  BongosClient: {
@@ -334,3 +334,31 @@ test('the banner renders from the projection /me ACTUALLY sends, not one written
334
334
  'gates on is outside NAV_PERMISSION_ATOMS and the feature is dead for everyone (task 1004118)',
335
335
  );
336
336
  });
337
+
338
+ // ── the banner must not offer the page you are on (task 1004145) ──────────────
339
+ // The owner pressed this on /deploy and reported "I clicked update, but I didn't see
340
+ // anything happen". Nothing had: it is an <a href="/deploy"> styled as a button and
341
+ // labelled with the verb, so on /deploy it navigates to the current page and does
342
+ // nothing at all. No request was ever made — verified against the live box, which had
343
+ // no core-upgrade POST in three hours and no queued intent from that press.
344
+
345
+ test('on /deploy the banner points at the control instead of linking to /deploy', async () => {
346
+ const b = boot({ pathname: '/deploy', permissions: { 'core.pin.move': true }, update: BEHIND });
347
+ await new Promise((r) => setTimeout(r, 0));
348
+ const html = b.banner().innerHTML;
349
+ assert.doesNotMatch(html, /href="\/deploy"/, 'no link to the page already open');
350
+ assert.match(html, /control below/i, 'it says where the real control is');
351
+ assert.match(html, /1\.19\.871/, 'and still announces the release');
352
+ });
353
+
354
+ test('everywhere else the banner still offers the link', async () => {
355
+ const b = boot({ pathname: '/settings', permissions: { 'core.pin.move': true }, update: BEHIND });
356
+ await new Promise((r) => setTimeout(r, 0));
357
+ assert.match(b.banner().innerHTML, /href="\/deploy"/, 'the action is real from any other page');
358
+ });
359
+
360
+ test('a trailing slash is still /deploy', async () => {
361
+ const b = boot({ pathname: '/deploy/', permissions: { 'core.pin.move': true }, update: BEHIND });
362
+ await new Promise((r) => setTimeout(r, 0));
363
+ assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
364
+ });
@@ -697,5 +697,55 @@ await t('every strict body schema in this module declares each field its handler
697
697
  'under strict, a field the schema does not name is a 400 BEFORE the handler runs, so reading it is unreachable code and a broken endpoint');
698
698
  });
699
699
 
700
+
701
+ console.log('\nthe last move, whatever became of it (task 1004145):');
702
+
703
+ const SNAP = { served_version: '1.19.900', available: ['1.19.900', '1.19.949'], recommended: '1.19.949', observed_at: new Date().toISOString() };
704
+
705
+ await t('a FAILED move is visible — the payload is no longer identical to before it', () => {
706
+ // open_intent is `state IN ('pending','running')` by the index's own definition, so an
707
+ // errored intent is not open. Before last_move, the payload after a rolled-back
708
+ // upgrade matched the payload before it exactly, and the owner's only signal was a
709
+ // number that never changed — indistinguishable from never having pressed.
710
+ const failed = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'working tree not clean — commit/stash first, or pass --force.' };
711
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, failed);
712
+ assert.equal(p.open_intent, null, 'it is not an OPEN intent');
713
+ assert.equal(p.last_move.state, 'error');
714
+ assert.equal(p.last_move.target_version, '1.19.949');
715
+ assert.match(p.last_move.error, /working tree not clean/, 'and the reason travels');
716
+ });
717
+
718
+ await t('a SUCCESSFUL move is attributable, not merely finished', () => {
719
+ const done = { action: 'core-upgrade', state: 'done', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: null };
720
+ const p = CU.previewFromSnapshot({ ...ACTIVE }, { ...SNAP, served_version: '1.19.949', recommended: null }, null, done);
721
+ assert.equal(p.last_move.state, 'done');
722
+ assert.equal(p.last_move.target_version, p.served_version, 'the move names the version the box now serves');
723
+ assert.equal(p.last_move.error, null);
724
+ });
725
+
726
+ await t('a long refusal is truncated — it reaches a DOM', () => {
727
+ // The dirty-tree refusal carries a whole `git status`; test4's ran to dozens of lines.
728
+ const huge = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x'.repeat(5000) };
729
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, huge);
730
+ assert.ok(p.last_move.error.length <= 600, `bounded — got ${p.last_move.error.length}`);
731
+ });
732
+
733
+ await t('no move ever made is null, not an invented success', () => {
734
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, null);
735
+ assert.equal(p.last_move, null);
736
+ });
737
+
738
+ await t('lastCoreMove reads MOVES only — a preview refusal is not a failed deploy', async () => {
739
+ // Folding core-upgrade-preflight in here would let a dry run's refusal render as
740
+ // "the move did not land". The preview reports itself through `preflight`.
741
+ const seen = [];
742
+ const pool = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
743
+ await CU.lastCoreMove(pool, 41);
744
+ assert.match(seen[0].sql, /action = 'core-upgrade'/, 'scoped to the move action');
745
+ assert.doesNotMatch(seen[0].sql, /preflight/, 'and never widened to the preview');
746
+ assert.doesNotMatch(seen[0].sql, /state IN/, 'and not filtered to open states — the point is the terminal ones');
747
+ assert.deepEqual(seen[0].params, [41]);
748
+ });
749
+
700
750
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
701
751
  if (failed) process.exit(1);
@@ -32,10 +32,15 @@ const FLEET = [
32
32
  { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'standalone' },
33
33
  ];
34
34
 
35
- function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200 } = {}) {
35
+ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200 } = {}) {
36
36
  const els = new Map();
37
37
  const posts = [];
38
38
  const gets = [];
39
+ // How many times each box has been re-read. A move POLLS, so a test of the move has to
40
+ // be able to change what the server says between reads — otherwise the only reachable
41
+ // assertion is "it asked", never "it noticed".
42
+ const reads = new Map();
43
+ const toasts = [];
39
44
  const makeEl = () => ({
40
45
  _on: {}, _html: '', hidden: false, textContent: '', disabled: false,
41
46
  addEventListener(ev, fn) { this._on[ev] = fn; },
@@ -48,7 +53,13 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
48
53
  const request = async (method, url, opts) => {
49
54
  const p = url.replace('/api/bongos', '');
50
55
  if (method === 'GET') gets.push(p);
51
- if (method === 'POST') { posts.push({ path: p, body: opts && opts.body }); return { ok: true, status: 200, data: { message: 'Queued.' } }; }
56
+ if (method === 'POST') {
57
+ posts.push({ path: p, body: opts && opts.body });
58
+ if (postStatus !== 200) {
59
+ return { ok: false, status: postStatus, data: { error: { code: 'intent_conflict', message: 'A core-upgrade run is currently running for this project.' } } };
60
+ }
61
+ return { ok: true, status: 200, data: { message: 'Queued.' } };
62
+ }
52
63
  if (p === '/provisioning/control-plane') {
53
64
  if (controlPlaneStatus !== 200) {
54
65
  return { ok: false, status: controlPlaneStatus, data: { error: { code: 'control_plane_not_enrolled', message: 'This platform has no deploy door yet. Run enroll-control-plane.js once.' } } };
@@ -66,13 +77,16 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
66
77
  if (!inst || inst.slug === 'unreadable-one') {
67
78
  return { ok: false, status: 500, data: { error: { code: 'core_upgrade_preview_failed', message: 'internal error' } } };
68
79
  }
69
- return { ok: true, status: 200, data: { preview: preview(inst.id, inst.slug, { hosting_shape: inst.hosting_shape }) } };
80
+ const n = (reads.get(inst.id) || 0) + 1;
81
+ reads.set(inst.id, n);
82
+ const over = instanceOver ? instanceOver(inst, n) : {};
83
+ return { ok: true, status: 200, data: { preview: preview(inst.id, inst.slug, { hosting_shape: inst.hosting_shape, ...over }) } };
70
84
  }
71
85
  return { ok: false, status: 404, data: { error: { code: 'not_found' } } };
72
86
  };
73
87
  const windowObj = {
74
88
  BongosClient: { createClient: () => ({ request }) },
75
- OTB: { escapeHtml: (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])), toast() {} },
89
+ OTB: { escapeHtml: (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])), toast(m) { toasts.push(String(m)); } },
76
90
  location: { assign() {} },
77
91
  };
78
92
  // The page's poll interval, driven rather than slept through. Injecting the REAL
@@ -81,13 +95,18 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
81
95
  // flaky against a torn-down sandbox.
82
96
  windowObj.__deployPollMs = 0;
83
97
  windowObj.__deployPollTries = 3;
98
+ windowObj.__deployMoveTries = moveTries;
84
99
  const sandbox = { window: windowObj, document: documentObj, console, setTimeout, clearTimeout, queueMicrotask, Date, Set, Map, Promise };
85
100
  sandbox.globalThis = sandbox;
86
101
  vm.createContext(sandbox);
87
102
  vm.runInContext(SRC, sandbox, { filename: 'deploy.js' });
88
103
  documentObj._ready();
89
104
  const settle = async () => { for (let i = 0; i < 40; i++) await new Promise((r) => setTimeout(r, 0)); };
90
- return { els, posts, gets, settle, panel: () => els.get('dp-panel').innerHTML, sub: () => els.get('dp-sub').textContent };
105
+ // A SHORT advance, for observing a state that exists only while the poll is running.
106
+ // `settle` drains the whole loop (POLL_MS is 0 here), so an assertion about the
107
+ // in-flight row has to look before the loop finishes, not after it.
108
+ const tick = async (n = 3) => { for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0)); };
109
+ return { els, posts, gets, reads, toasts, settle, tick, panel: () => els.get('dp-panel').innerHTML, sub: () => els.get('dp-sub').textContent };
91
110
  }
92
111
 
93
112
  test('the platform AND every live hosted project get a row', async () => {
@@ -189,8 +208,12 @@ test('a move re-reads ONLY the box it moved, not every project again', async ()
189
208
  b.els.get('dp-confirm-32')._on.click();
190
209
  await b.settle();
191
210
  const previewReads = b.gets.filter((g) => g.endsWith('/core-upgrade'));
192
- assert.deepEqual(previewReads, ['/provisioning/instances/32/core-upgrade'],
193
- 'exactly one preview re-read, and it is the moved box');
211
+ // Since task 1004145 a move POLLS, so the count is no longer one — and the count was
212
+ // only ever a proxy for the thing that matters. Asserted directly instead: every
213
+ // re-read is the moved box, and no other box is touched.
214
+ assert.ok(previewReads.length > 0, 'the moved box is re-read');
215
+ assert.deepEqual([...new Set(previewReads)], ['/provisioning/instances/32/core-upgrade'],
216
+ 'and it is the ONLY box re-read — no fan-out across the fleet');
194
217
  assert.ok(!b.gets.includes('/provisioning/fleet'), 'the fleet is not re-listed');
195
218
  });
196
219
 
@@ -290,3 +313,104 @@ test('a verdict already in hand is NOT re-requested', async () => {
290
313
  assert.equal(b.posts.filter((x) => x.body && x.body.dry_run).length, 0, 'no check requested');
291
314
  assert.match(b.panel(), /nothing blocks this move/i, 'the held verdict is shown');
292
315
  });
316
+
317
+ // ── the press has to show something (task 1004145) ────────────────────────────
318
+ // The owner pressed Upgrade and reported "I didn't see anything happen". Two reasons:
319
+ // the banner's Upgrade is a link to /deploy (fixed in shell.js), and move() refreshed
320
+ // once — before the runner's tick — then fell silent for the whole upgrade.
321
+
322
+ test('confirming a move says so IMMEDIATELY, naming the version it is taking', async () => {
323
+ const b = boot();
324
+ await b.settle();
325
+ b.els.get('dp-preview-32')._on.click();
326
+ b.els.get('dp-confirm-32')._on.click();
327
+ await b.tick();
328
+ const html = b.panel();
329
+ assert.match(html, /Moving to core/i, 'the row reports the move, rather than snapping back');
330
+ assert.match(html, /1\.19\.871/, 'and names the target');
331
+ assert.match(html, /restarts as part of this/i, 'and warns what it costs');
332
+ });
333
+
334
+ test('the in-flight state survives the poll — it does not close under the reader', async () => {
335
+ // refreshOne() returns a row to step one, which is right after a move and wrong
336
+ // DURING one; without keepStep the poll wipes its own message on the first interval.
337
+ // Observed after a poll has actually run, but before the bound is exhausted.
338
+ // A long bound so the loop cannot exhaust mid-assertion: the state under test exists
339
+ // only WHILE it polls.
340
+ const b = boot({ moveTries: 500, instanceOver: () => ({ open_intent: { action: 'core-upgrade', state: 'running', target_version: '1.19.871' } }) });
341
+ await b.settle();
342
+ b.els.get('dp-preview-32')._on.click();
343
+ b.els.get('dp-confirm-32')._on.click();
344
+ await b.tick(8);
345
+ assert.ok(b.reads.get('32') >= 1, 'at least one poll has landed');
346
+ assert.match(b.panel(), /Moving to core/i, 'still reporting after a refresh');
347
+ assert.doesNotMatch(b.panel(), /id="dp-preview-32"/, 'and has not reverted to step one');
348
+ });
349
+
350
+ test('when the box lands on the new core, it CONFIRMS it', async () => {
351
+ // The whole ask: "confirmation when it updates". A changed number is not a
352
+ // confirmation — nothing tied it to the press.
353
+ const b = boot({
354
+ instanceOver: (inst, n) => (n >= 2
355
+ ? { served_version: '1.19.871', behind: false, open_intent: null, last_move: { target_version: '1.19.871', state: 'done', at: new Date().toISOString(), error: null } }
356
+ : { open_intent: { action: 'core-upgrade', state: 'running', target_version: '1.19.871' } }),
357
+ });
358
+ await b.settle();
359
+ b.els.get('dp-preview-32')._on.click();
360
+ b.els.get('dp-confirm-32')._on.click();
361
+ await b.settle();
362
+ const html = b.panel();
363
+ assert.match(html, /Now running core 1\.19\.871/i, 'the move is confirmed, not merely finished');
364
+ assert.doesNotMatch(html, /Moving to core/i, 'and the in-flight state is gone');
365
+ });
366
+
367
+ test('a FAILED move is reported, with the reason, instead of looking untouched', async () => {
368
+ // An errored intent is not an OPEN intent, so before last_move existed the payload
369
+ // after a rolled-back upgrade was identical to the payload before it.
370
+ const b = boot({
371
+ instanceOver: (inst, n) => (n >= 2
372
+ ? { open_intent: null, last_move: { target_version: '1.19.871', state: 'error', at: new Date().toISOString(), error: 'working tree not clean — commit/stash first, or pass --force.' } }
373
+ : { open_intent: { action: 'core-upgrade', state: 'running', target_version: '1.19.871' } }),
374
+ });
375
+ await b.settle();
376
+ b.els.get('dp-preview-32')._on.click();
377
+ b.els.get('dp-confirm-32')._on.click();
378
+ await b.settle();
379
+ const html = b.panel();
380
+ assert.match(html, /did not land/i, 'the failure is stated');
381
+ assert.match(html, /working tree not clean/, 'and carries the runner’s own reason');
382
+ assert.match(html, /put back on 1\.19\.800/i, 'and says the project is still running');
383
+ });
384
+
385
+ test('a poll that runs out says it is still working, not that it failed', async () => {
386
+ // MOVE_TRIES is a bound on this page's patience, not on the runner's work.
387
+ const b = boot({ instanceOver: () => ({ open_intent: { action: 'core-upgrade', state: 'running', target_version: '1.19.871' } }) });
388
+ await b.settle();
389
+ b.els.get('dp-preview-32')._on.click();
390
+ b.els.get('dp-confirm-32')._on.click();
391
+ for (let i = 0; i < 6; i++) await b.settle();
392
+ assert.ok(b.toasts.some((t) => /Still working on core 1\.19\.871/i.test(t)),
393
+ `it says where things stand — got ${JSON.stringify(b.toasts)}`);
394
+ assert.doesNotMatch(b.panel(), /did not land/i, 'and never claims a failure it did not see');
395
+ });
396
+
397
+ test('a queue that is REFUSED leaves no in-flight state to wait on', async () => {
398
+ // THIS TEST USED TO PASS WITHOUT CLICKING ANYTHING. Its setup read
399
+ // `b.els.get('dp-confirm-32')._on = el._on` — a self-assignment no-op — and set
400
+ // `b.postFails`, a property the harness never read. So it asserted "no in-flight
401
+ // state" on a row where no move had been attempted, which is true of every row on the
402
+ // page. The grader caught it. It now refuses the POST for real and presses the button.
403
+ const b = boot({ postStatus: 409 });
404
+ await b.settle();
405
+ b.els.get('dp-preview-32')._on.click();
406
+ b.els.get('dp-confirm-32')._on.click();
407
+ await b.settle();
408
+ // Counted on the MOVE specifically: arming the row also POSTs a dry_run pre-flight,
409
+ // so a bare posts.length would have been satisfied by the preview alone — the same
410
+ // class of mistake this test is being fixed for.
411
+ const moves = b.posts.filter((x) => !(x.body && x.body.dry_run));
412
+ assert.equal(moves.length, 1, `the press really happened — posts: ${JSON.stringify(b.posts)}`);
413
+ assert.doesNotMatch(b.panel(), /Moving to core/i, 'nothing was queued, so nothing is watched');
414
+ assert.ok(b.toasts.some((t) => /currently running/i.test(t)), 'and the refusal is reported');
415
+ assert.match(b.panel(), /id="dp-confirm-32"/, 'the button comes back so it can be retried');
416
+ });
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '8a93e0b4ca715b9c';
90
+ const SHELL_SHA = '02de48ca2ae07995';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);