@bongos/core 1.24.25 → 1.24.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +173 -108
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
- package/docs/copy-inventory.md +280 -256
- package/docs/copy-registry.json +572 -306
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -4
- package/docs/page-readings.json +4337 -4313
- package/docs/recipes/ui-look-before-you-ship.md +1 -0
- package/modules/autonomy/fence.js +16 -1
- package/modules/government/protected-surfaces.json +2 -0
- package/modules/hall-ui/public/ai-studio.html +2 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/blockers.js +3 -1
- package/modules/hall-ui/public/board-room.html +3 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +4 -4
- package/modules/hall-ui/public/collab.js +4 -0
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/core-move-notice.js +9 -6
- package/modules/hall-ui/public/criterion-uat-ui.js +1 -1
- package/modules/hall-ui/public/deploy-handoff.js +203 -0
- package/modules/hall-ui/public/deploy.html +6 -2
- package/modules/hall-ui/public/deploy.js +7 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +2 -2
- package/modules/hall-ui/public/fleet.html +3 -1
- package/modules/hall-ui/public/gate.html +3 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals-page.js +1 -1
- package/modules/hall-ui/public/goals.css +3 -2
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/goals.js +1 -1
- package/modules/hall-ui/public/government.html +3 -1
- package/modules/hall-ui/public/hall-widgets.js +9 -9
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -1
- package/modules/hall-ui/public/lightbulb.js +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-rules.html +2 -2
- package/modules/hall-ui/public/market-watch-settings.html +3 -1
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/my-agents.html +3 -3
- package/modules/hall-ui/public/primer.html +2 -2
- package/modules/hall-ui/public/profile.html +6 -6
- package/modules/hall-ui/public/project-settings.html +3 -1
- package/modules/hall-ui/public/ranks.html +4 -4
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +3 -1
- package/modules/hall-ui/public/settings-software-update.js +23 -9
- package/modules/hall-ui/public/settings-specialities.js +1 -1
- package/modules/hall-ui/public/settings.html +10 -6
- package/modules/hall-ui/public/settings.js +3 -0
- package/modules/hall-ui/public/shell.js +25 -73
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/studio.js +3 -3
- package/modules/hall-ui/public/style.css +209 -40
- package/modules/hall-ui/public/task-detail.js +2 -2
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/update-pill.js +623 -0
- package/modules/hall-ui/public/watch.html +3 -1
- package/modules/hall-ui/public/watch.js +1 -1
- package/modules/hall-ui/public/work.html +4 -4
- package/modules/hall-ui/records/loader.md +35 -0
- package/modules/hall-ui/records/update-pill.md +12 -0
- package/modules/lifecycle/github-push.js +1 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/modules/lifecycle/runner-self-guard.js +122 -0
- package/modules/lifecycle/runner-self.js +154 -0
- package/modules/public-landing/public/account.html +3 -3
- package/modules/public-landing/public/assets/cosmos.css +45 -0
- package/modules/public-landing/public/community.html +4 -4
- package/modules/public-landing/public/connections.html +1 -1
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +4 -4
- package/modules/public-landing/public/projects.html +26 -26
- package/modules/status-ui/public/index.html +2 -2
- package/modules/status-ui/public/index.states.json +12 -0
- package/modules/status-ui/public/status.js +10 -2
- package/modules/status-ui/public/style.css +57 -0
- package/modules/ui-design/kit/README.md +1 -1
- package/modules/ui-design/kit/serve.js +8 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/bongos/autobongos-loop.js +4 -0
- package/scripts/bongos/autobongos-run.js +21 -4
- package/scripts/bongos/autobongos-tweak-lane.js +5 -3
- package/scripts/bongos/ship-flow.js +20 -0
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +9 -0
- package/src/bongos/apex-hall-redirects.js +53 -0
- package/src/bongos/serve-internal.js +3 -18
- package/src/module-api.js +1 -1
- package/tests/autobongos_host_access.mjs +5 -3
- package/tests/autobongos_self_modification.mjs +420 -0
- package/tests/core_update_banner.mjs +6 -92
- package/tests/core_upgrade_owner_door.mjs +4 -56
- package/tests/deploy_handoff.mjs +206 -0
- package/tests/government_protected_surfaces.mjs +15 -0
- package/tests/hall_loading.mjs +153 -0
- package/tests/hub_connections_page.mjs +1 -1
- package/tests/hub_invitations_page.mjs +1 -1
- package/tests/loading_mark.mjs +203 -0
- package/tests/nav_permission_atoms.mjs +3 -2
- package/tests/software_update.mjs +39 -2
- package/tests/update_pill.mjs +431 -0
|
@@ -237,7 +237,7 @@ test('a real request through the assembled router returns the verdict, not a 500
|
|
|
237
237
|
}
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
// ── the
|
|
240
|
+
// ── the shell's half: who is asked, and how often ───────────────────────────────
|
|
241
241
|
|
|
242
242
|
function boot({ permissions, update, status = 200, dismissed = null, provisioning = true, pathname = '/settings' } = {}) {
|
|
243
243
|
const els = new Map();
|
|
@@ -295,15 +295,6 @@ function boot({ permissions, update, status = 200, dismissed = null, provisionin
|
|
|
295
295
|
|
|
296
296
|
const BEHIND = { running: '1.19.861', latest: '1.19.871', behind: true, reading: 'ok' };
|
|
297
297
|
|
|
298
|
-
test('an owner who is behind sees the banner, naming both versions', async () => {
|
|
299
|
-
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
300
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
301
|
-
const el = b.banner();
|
|
302
|
-
assert.ok(el, 'banner rendered');
|
|
303
|
-
assert.match(el.innerHTML, /1\.19\.871/);
|
|
304
|
-
assert.match(el.innerHTML, /1\.19\.861/);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
298
|
test('a builder without the atom is never even asked about it', async () => {
|
|
308
299
|
const b = boot({ permissions: { 'task.create': true }, update: BEHIND });
|
|
309
300
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -311,33 +302,6 @@ test('a builder without the atom is never even asked about it', async () => {
|
|
|
311
302
|
assert.ok(!b.requested.includes('GET /api/bongos/core-update'), 'no request is made');
|
|
312
303
|
});
|
|
313
304
|
|
|
314
|
-
test('no answer renders no banner — not a false "up to date"', async () => {
|
|
315
|
-
const b = boot({ permissions: { 'core.pin.move': true }, update: { running: '1.19.861', latest: null, behind: false, reading: 'no_answer' } });
|
|
316
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
317
|
-
assert.equal(b.banner(), undefined);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test('a dismissal silences THAT version only', async () => {
|
|
321
|
-
const same = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.871' });
|
|
322
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
323
|
-
assert.equal(same.banner(), undefined, 'the dismissed version stays dismissed');
|
|
324
|
-
|
|
325
|
-
const newer = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.800' });
|
|
326
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
327
|
-
assert.ok(newer.banner(), 'a newer release re-announces itself');
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test('the Upgrade action appears only where the deploy door exists', async () => {
|
|
331
|
-
const on = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: true });
|
|
332
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
333
|
-
assert.match(on.banner().innerHTML, /href="\/deploy"/);
|
|
334
|
-
|
|
335
|
-
const off = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: false });
|
|
336
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
337
|
-
assert.ok(off.banner(), 'a tenant still hears the news');
|
|
338
|
-
assert.doesNotMatch(off.banner().innerHTML, /href="\/deploy"/, 'but is not offered a 404');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
305
|
test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
342
306
|
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
343
307
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -345,61 +309,11 @@ test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
|
345
309
|
assert.equal(first, 1);
|
|
346
310
|
});
|
|
347
311
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// builder on every instance — while this suite stayed green throughout.
|
|
354
|
-
//
|
|
355
|
-
// So this case does NOT write the map. It DERIVES it from me.js's own declaration,
|
|
356
|
-
// the way the server does, and grants everything in it. If the atom the banner needs
|
|
357
|
-
// is not part of that projection, there is nothing here to grant and the banner
|
|
358
|
-
// cannot render — which is the real bug, reproduced as a failure.
|
|
359
|
-
test('the banner renders from the projection /me ACTUALLY sends, not one written by this test', async () => {
|
|
360
|
-
const meSrc = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
|
|
361
|
-
const m = meSrc.match(/NAV_PERMISSION_ATOMS = Object\.freeze\(\[([^\]]*)\]\)/);
|
|
362
|
-
assert.ok(m, 'NAV_PERMISSION_ATOMS is readable');
|
|
363
|
-
const atoms = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
364
|
-
// A builder who holds everything the projection can report — the most generous real
|
|
365
|
-
// answer the server is capable of giving.
|
|
366
|
-
const permissions = Object.fromEntries(atoms.map((a) => [a, true]));
|
|
367
|
-
const b = boot({ permissions, update: BEHIND });
|
|
368
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
369
|
-
assert.ok(
|
|
370
|
-
b.banner(),
|
|
371
|
-
'the banner did not render for a builder holding EVERY atom /me can report — so the atom it ' +
|
|
372
|
-
'gates on is outside NAV_PERMISSION_ATOMS and the feature is dead for everyone (task 1004118)',
|
|
373
|
-
);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
// ── the banner must not offer the page you are on (task 1004145) ──────────────
|
|
377
|
-
// The owner pressed this on /deploy and reported "I clicked update, but I didn't see
|
|
378
|
-
// anything happen". Nothing had: it is an <a href="/deploy"> styled as a button and
|
|
379
|
-
// labelled with the verb, so on /deploy it navigates to the current page and does
|
|
380
|
-
// nothing at all. No request was ever made — verified against the live box, which had
|
|
381
|
-
// no core-upgrade POST in three hours and no queued intent from that press.
|
|
382
|
-
|
|
383
|
-
test('on /deploy the banner points at the control instead of linking to /deploy', async () => {
|
|
384
|
-
const b = boot({ pathname: '/deploy', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
385
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
386
|
-
const html = b.banner().innerHTML;
|
|
387
|
-
assert.doesNotMatch(html, /href="\/deploy"/, 'no link to the page already open');
|
|
388
|
-
assert.match(html, /control below/i, 'it says where the real control is');
|
|
389
|
-
assert.match(html, /1\.19\.871/, 'and still announces the release');
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test('everywhere else the banner still offers the link', async () => {
|
|
393
|
-
const b = boot({ pathname: '/settings', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
394
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
395
|
-
assert.match(b.banner().innerHTML, /href="\/deploy"/, 'the action is real from any other page');
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test('a trailing slash is still /deploy', async () => {
|
|
399
|
-
const b = boot({ pathname: '/deploy/', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
400
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
401
|
-
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
|
|
402
|
-
});
|
|
312
|
+
// The pill (task 1005041) replaced the banner, and its DOM half — said only from an answer,
|
|
313
|
+
// rendered from the projection /me really sends (task 1004118), never a link to the page you
|
|
314
|
+
// are on (task 1004145), never offering a held version (task 1004730) — is pinned against the
|
|
315
|
+
// real shell.js and update-pill.js in tests/update_pill.mjs. What stays here is the verdict,
|
|
316
|
+
// the route, and the shell's half: who is asked, and how often.
|
|
403
317
|
|
|
404
318
|
test('the banner asks with the project\'s stored update rule (task 1004535)', async () => {
|
|
405
319
|
const r2 = createRequire(import.meta.url);
|
|
@@ -430,26 +430,12 @@ function boot({ provisioning, deployUrl, pathname = '/settings', hash = '', held
|
|
|
430
430
|
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
431
431
|
windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions: { 'core.pin.move': true } });
|
|
432
432
|
return {
|
|
433
|
-
banner: () => inserted.find((e) => String(e._html).includes('is available')),
|
|
434
|
-
notice: () => inserted.find((e) => e.id === 'core-update-notice'),
|
|
435
433
|
deployItem: () => els.get('nav-deploy'),
|
|
436
434
|
};
|
|
437
435
|
}
|
|
438
436
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
439
437
|
const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
|
|
440
438
|
|
|
441
|
-
// Task 1004296 moved the hand-off: a hall without a door of its own sends its owner to
|
|
442
|
-
// Settings → Software update, whose Update button carries the hub address (GET
|
|
443
|
-
// /software-update's `door`). The banner and the rail no longer link off-site.
|
|
444
|
-
await t('on a hosted project, the banner\'s Upgrade goes to Settings → Software update, not off-site', async () => {
|
|
445
|
-
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
446
|
-
await settle();
|
|
447
|
-
assert.ok(b.banner(), 'the banner renders');
|
|
448
|
-
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, 'Upgrade opens the Software update panel');
|
|
449
|
-
assert.ok(!b.banner().innerHTML.includes(HUB), 'never the hub address directly, even when an older server sends one');
|
|
450
|
-
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the local /deploy, which this hall no longer has');
|
|
451
|
-
});
|
|
452
|
-
|
|
453
439
|
await t('on a hosted project, the rail\'s Deploy item stays hidden — it is the platform hall\'s page', async () => {
|
|
454
440
|
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
455
441
|
await settle();
|
|
@@ -458,54 +444,16 @@ await t('on a hosted project, the rail\'s Deploy item stays hidden — it is the
|
|
|
458
444
|
assert.equal(item.dataset.module, 'provisioning', 'its module gate stays, so a hall without the door hides it');
|
|
459
445
|
});
|
|
460
446
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, `Software update for ${JSON.stringify(url)}`);
|
|
466
|
-
assert.doesNotMatch(b.banner().innerHTML, /javascript:/);
|
|
467
|
-
}
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
await t('on the Software update panel itself, the banner points rather than linking to the page it is on', async () => {
|
|
471
|
-
const b = boot({ provisioning: false, pathname: '/settings', hash: '#software-update' });
|
|
472
|
-
await settle();
|
|
473
|
-
assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, 'no link to where you already are');
|
|
474
|
-
assert.match(b.banner().innerHTML, /core-update-notice__hint/);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
await t('on the platform, the local door wins: the banner links /deploy and the rail keeps it', async () => {
|
|
447
|
+
// The banner that used to link here is the Update pill now (task 1005041): where its Update now
|
|
448
|
+
// goes on each kind of hall, and the held version it refuses to offer, are pinned against the
|
|
449
|
+
// real pill in tests/update_pill.mjs. What stays here is the rail.
|
|
450
|
+
await t('on the platform, the rail keeps its local /deploy', async () => {
|
|
478
451
|
const b = boot({ provisioning: true, deployUrl: HUB });
|
|
479
452
|
await settle();
|
|
480
|
-
assert.match(b.banner().innerHTML, /href="\/deploy"/);
|
|
481
|
-
assert.ok(!b.banner().innerHTML.includes(HUB));
|
|
482
|
-
assert.ok(!b.banner().innerHTML.includes('software-update'), 'the platform takes its update at its own door');
|
|
483
453
|
const item = b.deployItem();
|
|
484
454
|
assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
|
|
485
455
|
assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
|
|
486
456
|
});
|
|
487
457
|
|
|
488
|
-
// Task 1004730: a version this project already tried and rolled back is not announced as
|
|
489
|
-
// "available" with an Upgrade button. The banner says what happened instead, in the owner's
|
|
490
|
-
// words, and points at Software update for the rest.
|
|
491
|
-
await t('a held version: the banner says it was tried and rolled back, and the project is still fine', async () => {
|
|
492
|
-
const held = { version: '1.19.1033', still_on: '1.19.871', message: 'long form' };
|
|
493
|
-
const b = boot({ provisioning: false, held });
|
|
494
|
-
await settle();
|
|
495
|
-
const n = b.notice();
|
|
496
|
-
assert.ok(n, 'the banner renders');
|
|
497
|
-
assert.doesNotMatch(n.innerHTML, /is available/);
|
|
498
|
-
assert.match(n.innerHTML, /1\.19\.1033<\/strong> was tried on this project and rolled back/);
|
|
499
|
-
assert.match(n.innerHTML, /still on 1\.19\.871 and working/);
|
|
500
|
-
assert.match(n.innerHTML, /href="\/settings#software-update">Details</);
|
|
501
|
-
assert.doesNotMatch(n.innerHTML, />Upgrade</, 'no button to take the version that just failed');
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
await t('a hold on an OLDER version than the one now out leaves the ordinary banner', async () => {
|
|
505
|
-
const b = boot({ provisioning: false, held: { version: '1.19.900', still_on: '1.19.871', message: 'x' } });
|
|
506
|
-
await settle();
|
|
507
|
-
assert.ok(b.banner(), 'the newer release is announced as usual');
|
|
508
|
-
});
|
|
509
|
-
|
|
510
458
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
511
459
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// tests/deploy_handoff.mjs — the hub's half of the in-place update (task 1005041).
|
|
2
|
+
//
|
|
3
|
+
// A hosted project's hall opens the hub's deploy door as a small window and tells it which
|
|
4
|
+
// version to take (update-pill.js). The safety of that rests on three refusals, each pinned
|
|
5
|
+
// here by EXECUTING the real deploy-handoff.js: a message from any window but the opener, or
|
|
6
|
+
// from any origin but the project's registered address, is not heard; a version the hub's own
|
|
7
|
+
// preview would not move to is shown to the owner, never sent; and with no word from the hall
|
|
8
|
+
// the window waits for a press. It never moves anything on load by itself.
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test, afterEach } from 'node:test';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import vm from 'node:vm';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { realOTB } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const require_ = createRequire(import.meta.url);
|
|
20
|
+
const FILE = path.join(ROOT, 'modules/hall-ui/public/deploy-handoff.js');
|
|
21
|
+
const SRC = fs.readFileSync(FILE, 'utf8');
|
|
22
|
+
const H = require_(FILE);
|
|
23
|
+
|
|
24
|
+
const PREVIEW = { instance_id: '14', slug: 'toast', reading: 'ok', behind: true, recommended_version: '1.21.30', viewer_may_move: true, open_intent: null };
|
|
25
|
+
const OPENER = {};
|
|
26
|
+
const GO = { tag: 'bongos-update', step: 'go', to: '1.21.30' };
|
|
27
|
+
const base = { data: GO, source: OPENER, opener: OPENER, origin: 'https://toast.cloudbongos.com', allowed: 'https://toast.cloudbongos.com', preview: PREVIEW };
|
|
28
|
+
|
|
29
|
+
// ── the rule ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
test('the one message that may start the move', () => {
|
|
32
|
+
assert.equal(H.judgeGo(base), null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a message from any window but the one that opened this is not heard', () => {
|
|
36
|
+
assert.equal(H.judgeGo({ ...base, source: {} }), 'not_opener');
|
|
37
|
+
assert.equal(H.judgeGo({ ...base, opener: null }), 'not_opener');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a message from any origin but the project\'s own address is not heard', () => {
|
|
41
|
+
assert.equal(H.judgeGo({ ...base, origin: 'https://evil.example' }), 'wrong_origin');
|
|
42
|
+
assert.equal(H.judgeGo({ ...base, origin: 'https://toast.cloudbongos.com.evil.example' }), 'wrong_origin');
|
|
43
|
+
assert.equal(H.judgeGo({ ...base, origin: 'http://toast.cloudbongos.com' }), 'wrong_origin', 'the scheme counts');
|
|
44
|
+
assert.equal(H.judgeGo({ ...base, allowed: null }), 'wrong_origin', 'a project with no address can be asked by nobody');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('only the version the hub itself would move to is sent', () => {
|
|
48
|
+
assert.equal(H.judgeGo({ ...base, data: { ...GO, to: '1.21.31' } }), 'wrong_version');
|
|
49
|
+
assert.equal(H.judgeGo({ ...base, data: { ...GO, to: undefined } }), 'wrong_version');
|
|
50
|
+
assert.equal(H.judgeGo({ ...base, data: { ...GO, to: '1.21.30 ' } }), 'wrong_version');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('nothing to move, a viewer who may not, or a run already open: refused', () => {
|
|
54
|
+
assert.equal(H.judgeGo({ ...base, preview: { ...PREVIEW, behind: false } }), 'nothing_to_move');
|
|
55
|
+
assert.equal(H.judgeGo({ ...base, preview: { ...PREVIEW, reading: 'no_answer' } }), 'nothing_to_move');
|
|
56
|
+
assert.equal(H.judgeGo({ ...base, preview: { ...PREVIEW, viewer_may_move: false } }), 'not_allowed');
|
|
57
|
+
assert.equal(H.judgeGo({ ...base, preview: { ...PREVIEW, open_intent: { action: 'restart' } } }), 'busy');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('another page\'s message is not this protocol', () => {
|
|
61
|
+
assert.equal(H.judgeGo({ ...base, data: { ...GO, tag: 'other' } }), 'not_go');
|
|
62
|
+
assert.equal(H.judgeGo({ ...base, data: 'go' }), 'not_go');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('the allowed origin is the project\'s registered address, and nothing that is not a host', () => {
|
|
66
|
+
assert.equal(H.projectOrigin({ domain: 'Toast.CloudBongos.com' }, 'https:'), 'https://toast.cloudbongos.com');
|
|
67
|
+
assert.equal(H.projectOrigin({ domain: 'toast.cloudbongos.com' }, 'http:'), 'http://toast.cloudbongos.com', 'http only on a harness served over http');
|
|
68
|
+
for (const domain of [null, '', 'localhost', 'a b.com', 'evil.com/path', 'evil.com?x', 'https://evil.com', '-x.com']) {
|
|
69
|
+
assert.equal(H.projectOrigin({ domain }, 'https:'), null, JSON.stringify(domain));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the window is the handoff only when asked for it', () => {
|
|
74
|
+
assert.equal(H.wanted({ search: '?project=toast&handoff=1' }), true);
|
|
75
|
+
assert.equal(H.wanted({ search: '?handoff=1' }), true);
|
|
76
|
+
assert.equal(H.wanted({ search: '?project=toast' }), false);
|
|
77
|
+
assert.equal(H.wanted({ search: '?handoff=10' }), false);
|
|
78
|
+
assert.equal(H.wanted(null), false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── the page, run for real ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const armed = new Set();
|
|
84
|
+
afterEach(() => { for (const id of armed) clearTimeout(id); armed.clear(); });
|
|
85
|
+
const settle = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
86
|
+
const plain = (x) => JSON.parse(JSON.stringify(x));
|
|
87
|
+
|
|
88
|
+
function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin }); } }, preview = PREVIEW, post = { ok: true, status: 200, data: { ok: true } }, rows = [{ id: '14', slug: 'toast', domain: 'toast.cloudbongos.com' }] } = {}) {
|
|
89
|
+
const told = [];
|
|
90
|
+
const requests = [];
|
|
91
|
+
const listeners = {};
|
|
92
|
+
let closed = false;
|
|
93
|
+
const main = { _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
|
|
94
|
+
const bodyEl = { _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
|
|
95
|
+
const buttons = {};
|
|
96
|
+
const documentObj = {
|
|
97
|
+
documentElement: { setAttribute() {} },
|
|
98
|
+
getElementById(id) {
|
|
99
|
+
if (id === 'hall-main') return main;
|
|
100
|
+
if (id === 'ho-body') return bodyEl;
|
|
101
|
+
if (!bodyEl._html.includes(`id="${id}"`)) return null;
|
|
102
|
+
return (buttons[id] ||= { _on: {}, addEventListener(ev, fn) { this._on[ev] = fn; } });
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
if (opener) opener.postMessage = (msg, origin) => told.push({ msg, origin });
|
|
106
|
+
const windowObj = {
|
|
107
|
+
OTB: realOTB(), document: documentObj, opener, __handoffHearMs: 30,
|
|
108
|
+
location: { protocol: 'https:', pathname: '/deploy', search: '?project=toast&handoff=1', assign() {} },
|
|
109
|
+
addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
|
|
110
|
+
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); armed.add(id); return id; },
|
|
111
|
+
close() { closed = true; },
|
|
112
|
+
};
|
|
113
|
+
const api = {
|
|
114
|
+
request: (method, p, opts) => {
|
|
115
|
+
requests.push({ method, path: p, body: opts && opts.body });
|
|
116
|
+
if (p === '/api/bongos/provisioning/fleet') return Promise.resolve({ ok: true, status: 200, data: { instances: rows } });
|
|
117
|
+
if (method === 'GET' && /core-upgrade$/.test(p)) return Promise.resolve({ ok: true, status: 200, data: { ok: true, preview } });
|
|
118
|
+
if (method === 'POST') return Promise.resolve(post);
|
|
119
|
+
return Promise.resolve({ ok: false, status: 404, data: null });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const sandbox = { window: windowObj, console, Promise, JSON };
|
|
123
|
+
sandbox.globalThis = sandbox;
|
|
124
|
+
vm.createContext(sandbox);
|
|
125
|
+
vm.runInContext(SRC, sandbox, { filename: 'deploy-handoff.js' });
|
|
126
|
+
windowObj.OTBDeployHandoff.run({ api });
|
|
127
|
+
return {
|
|
128
|
+
told, requests, body: bodyEl, buttons, closed: () => closed,
|
|
129
|
+
message: (data, { source = opener, origin = 'https://toast.cloudbongos.com' } = {}) => { for (const fn of listeners.message || []) fn({ data, source, origin }); },
|
|
130
|
+
posts: () => requests.filter((r) => r.method === 'POST'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('it says "ready" only to the project\'s own address, and moves nothing until it hears', async () => {
|
|
135
|
+
const b = boot();
|
|
136
|
+
await settle(5);
|
|
137
|
+
assert.deepEqual(plain(b.told), [{ msg: { tag: 'bongos-update', step: 'ready' }, origin: 'https://toast.cloudbongos.com' }]);
|
|
138
|
+
assert.equal(b.posts().length, 0, 'nothing moves on load');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the go from the project\'s own hall moves it to that version, says so, and closes', async () => {
|
|
142
|
+
const b = boot();
|
|
143
|
+
await settle(5);
|
|
144
|
+
b.message(GO);
|
|
145
|
+
await settle(5);
|
|
146
|
+
assert.equal(b.posts().length, 1);
|
|
147
|
+
assert.equal(b.posts()[0].path, '/api/bongos/provisioning/instances/14/core-upgrade');
|
|
148
|
+
assert.deepEqual(plain(b.posts()[0].body), { to: '1.21.30' });
|
|
149
|
+
assert.deepEqual(plain(b.told[1]), { msg: { tag: 'bongos-update', step: 'started', to: '1.21.30' }, origin: 'https://toast.cloudbongos.com' });
|
|
150
|
+
assert.match(b.body.innerHTML, /The update has started/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a go from another site, or another window, moves nothing', async () => {
|
|
154
|
+
const b = boot();
|
|
155
|
+
await settle(5);
|
|
156
|
+
b.message(GO, { origin: 'https://evil.example' });
|
|
157
|
+
b.message(GO, { source: {} });
|
|
158
|
+
await settle(5);
|
|
159
|
+
assert.equal(b.posts().length, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a go naming another version is shown to the owner, never sent', async () => {
|
|
163
|
+
const b = boot();
|
|
164
|
+
await settle(5);
|
|
165
|
+
b.message({ ...GO, to: '1.21.31' });
|
|
166
|
+
await settle(5);
|
|
167
|
+
assert.equal(b.posts().length, 0);
|
|
168
|
+
assert.match(b.body.innerHTML, /A different version is ready/);
|
|
169
|
+
assert.match(b.body.innerHTML, /Update to 1\.21\.30/, 'the press names the hub\'s own version');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('no word from the hall: one press, naming the version, and only the press moves it', async () => {
|
|
173
|
+
const b = boot();
|
|
174
|
+
await settle(60);
|
|
175
|
+
assert.equal(b.posts().length, 0, 'waiting is not consent');
|
|
176
|
+
assert.match(b.body.innerHTML, /id="ho-go"/);
|
|
177
|
+
b.buttons['ho-go']._on.click();
|
|
178
|
+
await settle(5);
|
|
179
|
+
assert.deepEqual(plain(b.posts()[0].body), { to: '1.21.30' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('opened by hand, with no hall behind it: the press, never an automatic move', async () => {
|
|
183
|
+
const b = boot({ opener: null });
|
|
184
|
+
await settle(5);
|
|
185
|
+
assert.equal(b.posts().length, 0);
|
|
186
|
+
assert.match(b.body.innerHTML, /Update to 1\.21\.30/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a refusal is said in the server\'s words, here and to the hall', async () => {
|
|
190
|
+
const b = boot({ post: { ok: false, status: 409, data: { error: { code: 'intent_conflict', message: 'A restart is running for this project.' } } } });
|
|
191
|
+
await settle(5);
|
|
192
|
+
b.message(GO);
|
|
193
|
+
await settle(5);
|
|
194
|
+
assert.match(b.body.innerHTML, /A restart is running for this project\./);
|
|
195
|
+
assert.deepEqual(plain(b.told[1]), { msg: { tag: 'bongos-update', step: 'refused', message: 'A restart is running for this project.' }, origin: 'https://toast.cloudbongos.com' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a project already current, or one with no address, says so and moves nothing', async () => {
|
|
199
|
+
const cur = boot({ preview: { ...PREVIEW, behind: false } });
|
|
200
|
+
await settle(5);
|
|
201
|
+
assert.match(cur.body.innerHTML, /already runs the newest version/);
|
|
202
|
+
const none = boot({ rows: [{ id: '14', slug: 'toast', domain: null }] });
|
|
203
|
+
await settle(60);
|
|
204
|
+
assert.equal(none.told.length, 0, 'nobody to say "ready" to');
|
|
205
|
+
assert.match(none.body.innerHTML, /id="ho-go"/, 'the press is still there');
|
|
206
|
+
});
|
|
@@ -328,6 +328,21 @@ const ADDED_SINCE_R101 = [
|
|
|
328
328
|
why: 'split out of the protected ship.js: the ship-mode step every ship runs '
|
|
329
329
|
+ 'before it lands, so an edit here could change how work reaches main.',
|
|
330
330
|
},
|
|
331
|
+
// task 1004546: the Autobongos self-edit wall. The grade of task 1004540 refused a
|
|
332
|
+
// version whose guard lived in unprotected shared files, so it moved into these two
|
|
333
|
+
// small ones and they sit beside the rest of the publish pipeline.
|
|
334
|
+
{
|
|
335
|
+
after: 'modules/lifecycle/publish-reconciler.js',
|
|
336
|
+
glob: 'modules/lifecycle/runner-self.js',
|
|
337
|
+
why: 'the one list of files an unattended runner may never change, and the shape of '
|
|
338
|
+
+ "a runner's claim; an edit here could let a runner loosen its own fence.",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
after: 'modules/lifecycle/runner-self.js',
|
|
342
|
+
glob: 'modules/lifecycle/runner-self-guard.js',
|
|
343
|
+
why: "the server's refusal to publish a runner's change to its own supervisor, safety "
|
|
344
|
+
+ 'checks or kill switch, read against the pull request\'s real files.',
|
|
345
|
+
},
|
|
331
346
|
];
|
|
332
347
|
|
|
333
348
|
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/hall_loading.mjs — the hall's one loader, the Bongos mark (task 1005043, goal 1000095).
|
|
2
|
+
//
|
|
3
|
+
// Owner ask, 2026-11-03: a smooth load screen with the Bongos logo anywhere the
|
|
4
|
+
// hall waits for data, in the spirit of monday.com's Vibe data loaders. One CSS
|
|
5
|
+
// component in style.css (.hall-loading) draws the mark from shell.js's
|
|
6
|
+
// brand-mark geometry; a page opts in by putting the class on the placeholder it
|
|
7
|
+
// already had. These cases read the real sheets and pages: the loader keeps
|
|
8
|
+
// DESIGN.md's motion and colour rules, its rest frame is the logo, and no hall
|
|
9
|
+
// page is left with a bare "Loading…" line or a blank wait.
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
18
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
19
|
+
const STYLE = read('style.css');
|
|
20
|
+
const SHELL = read('shell.js');
|
|
21
|
+
|
|
22
|
+
// The loader's section of style.css, from its banner to the next rule family.
|
|
23
|
+
const START = STYLE.indexOf('/* ---- the loader (task 1005043)');
|
|
24
|
+
const LOADER = STYLE.slice(START, STYLE.indexOf('/* collapsible card body', START));
|
|
25
|
+
|
|
26
|
+
function keyframes(name) {
|
|
27
|
+
const m = STYLE.match(new RegExp(`@keyframes ${name} \\{`));
|
|
28
|
+
assert.ok(m, `@keyframes ${name} exists`);
|
|
29
|
+
let depth = 1, i = m.index + m[0].length;
|
|
30
|
+
for (; i < STYLE.length && depth; i++) depth += STYLE[i] === '{' ? 1 : STYLE[i] === '}' ? -1 : 0;
|
|
31
|
+
return STYLE.slice(m.index + m[0].length, i - 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('the loader is drawn from the Bongos mark, in three sizes', () => {
|
|
35
|
+
assert.ok(START > 0 && LOADER.length > 500, 'the loader section is found in style.css');
|
|
36
|
+
for (const sel of ['.hall-loading {', '.hall-loading::before {', '.hall-loading::after {', '.hall-loading--page {', '.hall-loading--inline {']) {
|
|
37
|
+
assert.ok(LOADER.includes(sel), `${sel} is declared`);
|
|
38
|
+
}
|
|
39
|
+
// The same ring arc, core and dot the shell's brand mark draws, so the loader IS the logo.
|
|
40
|
+
const ring = SHELL.match(/<path class="mr" d="([^"]+)"/)[1];
|
|
41
|
+
assert.ok(LOADER.includes(`d='${ring}'`), 'the ring is the brand mark\'s arc');
|
|
42
|
+
assert.ok(SHELL.includes('<circle class="mp" cx="100" cy="100" r="55"'), 'the brand mark\'s core');
|
|
43
|
+
assert.ok(LOADER.includes("circle cx='100' cy='100' r='55'"), 'is the loader\'s core');
|
|
44
|
+
assert.ok(SHELL.includes('<circle cx="156" cy="46" r="12" fill="#fb9569"/>'), 'the brand mark\'s dot');
|
|
45
|
+
assert.ok(LOADER.includes("circle cx='156' cy='46' r='12'"), 'is the loader\'s dot');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('it moves in CSS only, on transform and filter only, and rests on the logo', () => {
|
|
49
|
+
const frames = { 'hall-loading-arrive': keyframes('hall-loading-arrive'), 'hall-loading-turn': keyframes('hall-loading-turn'), 'hall-loading-breathe': keyframes('hall-loading-breathe') };
|
|
50
|
+
for (const [name, body] of Object.entries(frames)) {
|
|
51
|
+
const props = [...body.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
|
|
52
|
+
assert.ok(props.length, `${name} animates something`);
|
|
53
|
+
for (const prop of props) assert.ok(['transform', 'filter'].includes(prop), `${name} animates ${prop}, not transform or filter`);
|
|
54
|
+
}
|
|
55
|
+
// The rest frame: the turn has no `from`, so 0deg (the logo's own pose, dot top right)
|
|
56
|
+
// is where it starts and where the Kill Switch leaves it; the arrival and the breath
|
|
57
|
+
// have no `to`, so their resting value is the element as styled, fully shown.
|
|
58
|
+
assert.match(frames['hall-loading-turn'], /^\s*to \{ transform: rotate\(1turn\); \}\s*$/);
|
|
59
|
+
assert.match(frames['hall-loading-arrive'], /^\s*from \{/);
|
|
60
|
+
assert.match(frames['hall-loading-breathe'], /^\s*from \{/);
|
|
61
|
+
// The Kill Switch stops every animation, pseudo-elements included.
|
|
62
|
+
assert.match(STYLE, /@media \(prefers-reduced-motion: reduce\) \{\s*\*, \*::before, \*::after \{\s*animation: none !important;/);
|
|
63
|
+
// No motion authored in JavaScript: nothing in the hall drives the loader from script.
|
|
64
|
+
for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.js'))) {
|
|
65
|
+
assert.doesNotMatch(read(f), /hall-loading[^'"\n]*['"]\)?\.(animate|style)\b/, `${f} does not animate the loader`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('every colour is a token; the black in the masks is alpha, not paint', () => {
|
|
70
|
+
const css = LOADER.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
71
|
+
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'no colour literal');
|
|
72
|
+
const paints = [...css.matchAll(/(?:background|color):\s*([^;]+);/g)].map((m) => m[1].trim());
|
|
73
|
+
assert.ok(paints.length >= 3, 'the ink, the dot and the caption are painted');
|
|
74
|
+
for (const p of paints) assert.match(p, /^(var\(--[a-z-]+\)|color-mix\(in srgb, var\(--ink\) \d+%, var\(--bg-card\)\))$/, `${p} is a token or a mix of one`);
|
|
75
|
+
// The one accent is the dot: the live thing on a waiting page (the One Warm Thing Rule).
|
|
76
|
+
assert.equal((css.match(/var\(--accent\)/g) || []).length, 1, 'the accent paints the dot alone');
|
|
77
|
+
const masks = [...css.matchAll(/url\("data:image\/svg\+xml,([^"]+)"\)/g)].map((m) => decodeURIComponent(m[1]));
|
|
78
|
+
assert.equal(masks.length, 2, 'two masks: the ring and core, and the dot');
|
|
79
|
+
for (const m of masks) assert.doesNotMatch(m.replace(/stroke='black'|fill='none'/g, ''), /fill='|stroke='|#/, 'a mask carries no colour of its own');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the page loaders step aside by themselves, and never show without :has()', () => {
|
|
83
|
+
assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
|
|
84
|
+
assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
|
|
85
|
+
// A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
|
|
86
|
+
for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'market-watch-settings', 'project-settings', 'sessions']) {
|
|
87
|
+
const html = read(`${page}.html`);
|
|
88
|
+
const main = html.slice(html.indexOf('<main id="hall-main">'));
|
|
89
|
+
const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
|
|
90
|
+
assert.match(first, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, `${page} opens its main on the page loader`);
|
|
91
|
+
const after = first.slice(first.indexOf('</p>') + 4);
|
|
92
|
+
const kids = [...after.matchAll(/^ {8}<(section|div|p|nav)\b([^>]*)>/gm)];
|
|
93
|
+
assert.ok(kids.length >= 2, `${page} has sections after the loader`);
|
|
94
|
+
for (const [, tag, attrs] of kids) assert.match(attrs, /\bhidden\b/, `${page}: a <${tag}> after the loader starts hidden, or the loader would never show`);
|
|
95
|
+
}
|
|
96
|
+
assert.match(read('index.html'), /<section class="view" id="view-home"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Home waits on its zones');
|
|
97
|
+
assert.match(read('watch.html'), /<main id="hall-main">\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Watch waits on its panes');
|
|
98
|
+
// Settings' layout is never [hidden] while it loads, so its loader is put away by hideLayout().
|
|
99
|
+
assert.match(read('settings.html'), /id="settings-groups"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page" id="settings-loading" role="status">/);
|
|
100
|
+
assert.match(read('settings.js'), /function hideLayout\(hide\) \{[\s\S]*?getElementById\('settings-loading'\)[\s\S]*?loading\.hidden = true;/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('no hall page waits on a bare line of text any more', () => {
|
|
104
|
+
// A placeholder an element's own script rewrites IN PLACE keeps its plain words:
|
|
105
|
+
// the class would stay behind on the finished sentence. Each is named with why.
|
|
106
|
+
const IN_PLACE = new Set([
|
|
107
|
+
'atlas.html#caption', // the map's caption, rewritten by atlas.js
|
|
108
|
+
'sessions.html#ledger-status', // the ledger's status line, rewritten by sessions.js
|
|
109
|
+
'goals.html#goal-deps-card-lede', // the dependency card's lede, rewritten by goals-page.js
|
|
110
|
+
'goals.html#goal-deps-lede',
|
|
111
|
+
'profile.html#activity-placeholder', // reused for the signed-out notice by profile.js
|
|
112
|
+
]);
|
|
113
|
+
const WAIT = /<(p|div|li|span|td)\b([^>]*)>\s*(?:Loading|Reading|Opening|Checking)\b[^<]*…\s*<\/\1>|<(p|div|li|span|td)\b([^>]*)>…<\/\4>/g;
|
|
114
|
+
const misses = [];
|
|
115
|
+
for (const f of fs.readdirSync(HALL).filter((n) => n.endsWith('.html'))) {
|
|
116
|
+
for (const m of read(f).matchAll(WAIT)) {
|
|
117
|
+
const attrs = m[2] || m[5] || '';
|
|
118
|
+
if (/page-head__sub/.test(attrs)) continue; // the page head is hidden on every page but Home (task 1004836)
|
|
119
|
+
const id = (attrs.match(/\bid="([^"]+)"/) || [])[1];
|
|
120
|
+
if (id && IN_PLACE.has(`${f}#${id}`)) continue;
|
|
121
|
+
if (!/\bhall-loading\b/.test(attrs)) misses.push(`${f}: ${m[0].slice(0, 90)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
assert.deepEqual(misses, [], 'every waiting placeholder wears the loader');
|
|
125
|
+
// The table placeholder rows keep their cell, with the loader inside it.
|
|
126
|
+
assert.match(read('work.html'), /<td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
|
|
127
|
+
assert.match(read('hall-widgets.js'), /<td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the placeholders the page scripts write wear it too, and a failure stops it', () => {
|
|
131
|
+
const WRITTEN = {
|
|
132
|
+
'blockers.js': '<li class="hall-loading hall-loading--page" role="status">Loading…</li>',
|
|
133
|
+
'goals.js': '<div class="hall-loading" role="status">Loading your goals…</div>',
|
|
134
|
+
'lightbulb.js': '<p class="hall-loading" role="status">Loading…</p>',
|
|
135
|
+
'task-detail.js': '<p class="hall-loading hall-loading--inline" role="status">Loading versions…</p>',
|
|
136
|
+
'criterion-uat-ui.js': '<p class="hall-loading hall-loading--inline" role="status">Loading…</p>',
|
|
137
|
+
'watch.js': '<p class="hall-loading" role="status">Reading…</p>',
|
|
138
|
+
'studio.js': '<p class="hall-loading" role="status">Reading the reviews…</p>',
|
|
139
|
+
'settings-specialities.js': '<p class="hall-loading hall-loading--inline" role="status">Loading this project’s skills…</p>',
|
|
140
|
+
'settings-software-update.js': '<p class="hall-loading hall-loading--inline" role="status">Checking for a newer version…</p>',
|
|
141
|
+
'goals-page.js': '<div class="hall-loading" role="status">Loading…</div>',
|
|
142
|
+
};
|
|
143
|
+
for (const [f, markup] of Object.entries(WRITTEN)) assert.ok(read(f).includes(markup), `${f} writes the loader`);
|
|
144
|
+
// Blockers' first paint replaces the list wholesale, so its loader cannot outlive the rows.
|
|
145
|
+
assert.match(read('blockers.js'), /listEl\.innerHTML = rows\.length/);
|
|
146
|
+
// Collab used to be blank on a failed read; with loaders in its lists it must stop them.
|
|
147
|
+
const collab = read('collab.js');
|
|
148
|
+
const failure = collab.slice(collab.lastIndexOf('load().catch('));
|
|
149
|
+
assert.match(failure, /\['for-me', 'mine', 'all'\]\.forEach\(\(k\) => \{ const host = \$\(`#cb-\$\{k\}`\); if \(host\) host\.innerHTML = ''; \}\);/);
|
|
150
|
+
// The skeleton bars and the goal list's rings breathe in the loader's beat.
|
|
151
|
+
assert.match(STYLE, /\.kit-skel \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
|
|
152
|
+
assert.match(read('goals.css'), /\.gr-skel__ring \{[^}]*animation: hall-loading-breathe \.8s ease-in-out infinite alternate-reverse;/);
|
|
153
|
+
});
|
|
@@ -77,7 +77,7 @@ test('the body: a door, a loading line, the inactive and unreadable lines, then
|
|
|
77
77
|
assert.match(MAIN, /<h1>connections<\/h1>/);
|
|
78
78
|
assert.match(MAIN, /it grants no rank and no access to anything/, 'a connection is never authority (ADR 0016, ADR 0202)');
|
|
79
79
|
assert.match(MAIN, /<section class="aDoor" id="door" hidden aria-labelledby="door-h">[\s\S]*?href="\/api\/bongos\/auth\/web\/start\?return=\/connections&repos=1"/, 'signed out, the door returns here');
|
|
80
|
-
assert.match(MAIN, /<p class="loading" id="loading">/);
|
|
80
|
+
assert.match(MAIN, /<p class="loading loading-mark loading-mark--page" id="loading" role="status">/); // the Bongos-mark loader (task 1005053)
|
|
81
81
|
assert.match(MAIN, /<p class="mineEmpty" id="inactive" hidden>/);
|
|
82
82
|
assert.match(MAIN, /<p class="projMsg err" id="readFailed" role="alert" hidden>/);
|
|
83
83
|
assert.match(MAIN, /<p class="projMsg" id="connMsg" role="status" aria-live="polite" hidden><\/p>/);
|
|
@@ -56,7 +56,7 @@ test('the page wears the hub’s bar, with Invitations as the one current place;
|
|
|
56
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
57
|
assert.match(MAIN, /<h1>invitations<\/h1>/);
|
|
58
58
|
assert.match(MAIN, /<section class="aDoor" id="door" hidden aria-labelledby="door-h">[\s\S]*?href="\/api\/bongos\/auth\/web\/start\?return=\/invitations&repos=1"/, 'signed out, the door returns here');
|
|
59
|
-
assert.match(MAIN, /<p class="loading" id="loading">/);
|
|
59
|
+
assert.match(MAIN, /<p class="loading loading-mark loading-mark--page" id="loading" role="status">/); // the Bongos-mark loader (task 1005053)
|
|
60
60
|
const section = (MAIN.match(/<section id="mineInvites" aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
61
61
|
assert.ok(section, 'the invitations section ships as markup, never hidden on its own: it is the page');
|
|
62
62
|
assert.match(section, /<p class="mineEmpty" id="invitesNone" hidden>[^<]+<\/p>/, 'one empty line, down until the reads have answered');
|