@bongos/core 1.24.26 → 1.24.28
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 +164 -109
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
- package/docs/copy-inventory.md +242 -223
- package/docs/copy-registry.json +559 -358
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +18 -6
- package/docs/page-readings.json +4399 -4315
- 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 +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/core-move-notice.js +9 -6
- package/modules/hall-ui/public/deploy-handoff.js +203 -0
- package/modules/hall-ui/public/deploy.html +4 -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 +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.css +72 -1
- package/modules/hall-ui/public/my-agents.html +31 -7
- package/modules/hall-ui/public/my-agents.js +63 -54
- package/modules/hall-ui/public/my-agents.states.json +18 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +22 -8
- package/modules/hall-ui/public/settings.css +2 -132
- package/modules/hall-ui/public/settings.html +15 -53
- package/modules/hall-ui/public/shell.js +25 -73
- package/modules/hall-ui/public/speciality-tools.css +140 -0
- package/modules/hall-ui/public/{settings-specialities.js → speciality-tools.js} +83 -6
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +119 -39
- 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 +1 -1
- package/modules/hall-ui/public/update-pill.js +623 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/my-agents.md +4 -2
- package/modules/hall-ui/records/panel-family.md +2 -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 +20 -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/server.js +4 -0
- 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 +3 -1
- package/tests/hall_my_agents.mjs +104 -2
- package/tests/helpers.mjs +161 -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 +6 -2
- package/tests/speciality_offer_ui.mjs +182 -149
- package/tests/speciality_walkthrough_ui.mjs +180 -161
- package/tests/update_pill.mjs +431 -0
|
@@ -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
|
package/tests/hall_loading.mjs
CHANGED
|
@@ -136,7 +136,9 @@ test('the placeholders the page scripts write wear it too, and a failure stops i
|
|
|
136
136
|
'criterion-uat-ui.js': '<p class="hall-loading hall-loading--inline" role="status">Loading…</p>',
|
|
137
137
|
'watch.js': '<p class="hall-loading" role="status">Reading…</p>',
|
|
138
138
|
'studio.js': '<p class="hall-loading" role="status">Reading the reviews…</p>',
|
|
139
|
-
'
|
|
139
|
+
// the speciality tools, Settings' and My agents' one copy (task 1005052): the offer
|
|
140
|
+
// editor's and the create form's skills picker both write it
|
|
141
|
+
'speciality-tools.js': '<p class="hall-loading hall-loading--inline" role="status">Loading this project’s skills…</p>',
|
|
140
142
|
'settings-software-update.js': '<p class="hall-loading hall-loading--inline" role="status">Checking for a newer version…</p>',
|
|
141
143
|
'goals-page.js': '<div class="hall-loading" role="status">Loading…</div>',
|
|
142
144
|
};
|
package/tests/hall_my_agents.mjs
CHANGED
|
@@ -440,10 +440,11 @@ test('PAGE: in the hall family, with no prototype key', () => {
|
|
|
440
440
|
const html = read('my-agents.html');
|
|
441
441
|
assert.match(html, /<body data-page="my-agents"/);
|
|
442
442
|
const sheets = [...html.matchAll(/href="\/builders\/([a-z-]+\.css)/g)].map((m) => m[1]);
|
|
443
|
-
assert.deepEqual(sheets, ['style.css', 'oversight.css', 'my-agents.css'],
|
|
443
|
+
assert.deepEqual(sheets, ['style.css', 'oversight.css', 'speciality-tools.css', 'my-agents.css'],
|
|
444
|
+
'rides the oversight archetype; the shared speciality sheet (task 1005052) before its own, so the page skin wins');
|
|
444
445
|
const scripts = [...html.matchAll(/src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
445
446
|
assert.deepEqual(scripts.slice(0, 4), ['dom-utils', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
|
|
446
|
-
for (const s of ['gov-living', 'gov-positions', 'my-agents']) assert.ok(scripts.includes(s), `loads ${s}.js`);
|
|
447
|
+
for (const s of ['gov-living', 'gov-positions', 'my-agents', 'speciality-tools']) assert.ok(scripts.includes(s), `loads ${s}.js`);
|
|
447
448
|
assert.doesNotMatch(html, /ma-key|data-proto|Prototype data/, 'nothing on the page is prototype data, so there is no key');
|
|
448
449
|
assert.doesNotMatch(read('my-agents.js'), /governor-overlay/, 'and it reads no overlay');
|
|
449
450
|
assert.match(html, /id="hall-main"/);
|
|
@@ -480,3 +481,104 @@ test('PAGE: no animation loop or timer-driven motion in the page script', () =>
|
|
|
480
481
|
// the one timer takes the saved tick's attribute back off; CSS plays the motion
|
|
481
482
|
assert.equal((code.match(/setTimeout\(/g) || []).length, 1);
|
|
482
483
|
});
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// 6. everything Settings can do here too (task 1005052)
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
const { createPrefRecorder: recorder } = require(path.join(ROOT, 'scripts', 'hall-preview', 'pref-writes.js'));
|
|
490
|
+
|
|
491
|
+
test('DEFAULTS: session cards can go back to following your interaction profile', () => {
|
|
492
|
+
const me = fx('me');
|
|
493
|
+
assert.equal(me.render.override_level, null, 'the fixture viewer follows the profile');
|
|
494
|
+
const r = row(viewOf(), 'render');
|
|
495
|
+
assert.deepEqual(r.options[0], { value: '', label: 'follow my profile', pressed: true }, 'the way back comes first, and is the choice in force');
|
|
496
|
+
assert.deepEqual(r.options.slice(1).map((o) => o.value), me.render.levels.map((l) => l.value), 'then the four levels');
|
|
497
|
+
assert.ok(r.options.slice(1).every((o) => !o.pressed), 'a level is pressed only when it is your own pick');
|
|
498
|
+
assert.equal(r.fact, MyAgents.SETTING.render.words[me.render.derived_level], 'the fact is the level the profile gives');
|
|
499
|
+
assert.equal(r.note, 'follows your profile');
|
|
500
|
+
const pinned = fx('me');
|
|
501
|
+
pinned.render = { ...pinned.render, override_level: 'minimal', level: 'minimal' };
|
|
502
|
+
const p = row(viewOf({ me: pinned }), 'render');
|
|
503
|
+
assert.equal(p.options[0].pressed, false, 'a pinned level is not "follow my profile"');
|
|
504
|
+
assert.deepEqual(p.options.filter((o) => o.pressed).map((o) => o.value), ['minimal']);
|
|
505
|
+
assert.equal(p.fact, 'one line');
|
|
506
|
+
assert.equal(p.note, null);
|
|
507
|
+
assert.deepEqual(MyAgents.writeFor('render', '', { me }), { method: 'PATCH', path: '/me/render', body: { widgets: null }, slice: 'render' }, 'the way back CLEARS the pick (null), it does not pin today\'s level');
|
|
508
|
+
assert.deepEqual(MyAgents.writeFor('render', 'minimal', { me }).body, { widgets: 'minimal' });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('DEFAULTS: the real shapers take the way back for session cards, and the row reads it as chosen', () => {
|
|
512
|
+
const rec = recorder({ log() {} });
|
|
513
|
+
const me = fx('me');
|
|
514
|
+
assert.equal(rec.write('me/render', MyAgents.writeFor('render', 'minimal', { me }).body, { me }).body.override_level, 'minimal');
|
|
515
|
+
const back = rec.write('me/render', MyAgents.writeFor('render', '', { me }).body, { me });
|
|
516
|
+
assert.equal(back.status, 200, 'the real route accepts it');
|
|
517
|
+
assert.equal(back.body.override_level, null);
|
|
518
|
+
assert.equal(back.body.level, back.body.derived_level, 'the cards follow the profile again');
|
|
519
|
+
const after = { ...me, render: back.body };
|
|
520
|
+
assert.equal(row(viewOf({ me: after }), 'render').options[0].pressed, true);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('DEFAULTS: each skill voice can go back to its default model', () => {
|
|
524
|
+
const sk = fx('me__skill-prefs');
|
|
525
|
+
const subs = row(viewOf(), 'skills').skills;
|
|
526
|
+
assert.equal(subs.length, sk.effective.length);
|
|
527
|
+
for (const s of subs) {
|
|
528
|
+
assert.deepEqual(s.options[0], { value: '', label: 'default', pressed: true }, `${s.skill}: the default comes first, and is in force`);
|
|
529
|
+
assert.deepEqual(s.options.slice(1).map((o) => o.value), sk.allowed_models);
|
|
530
|
+
assert.ok(s.options.slice(1).every((o) => !o.pressed), `${s.skill}: no model is pressed you did not pick`);
|
|
531
|
+
assert.equal(s.note, null);
|
|
532
|
+
}
|
|
533
|
+
const own = JSON.parse(JSON.stringify(sk));
|
|
534
|
+
own.effective[0] = { ...own.effective[0], override_model: 'opus', effective_model: 'opus' };
|
|
535
|
+
const s0 = row(viewOf({ skills: own }), 'skills').skills[0];
|
|
536
|
+
assert.equal(s0.options[0].pressed, false);
|
|
537
|
+
assert.deepEqual(s0.options.filter((o) => o.pressed).map((o) => o.value), ['opus']);
|
|
538
|
+
assert.equal(s0.value, 'opus');
|
|
539
|
+
assert.equal(s0.note, 'default: ' + sk.effective[0].default_model, 'with your own pick, the default it replaces is said');
|
|
540
|
+
assert.match(row(viewOf({ skills: own }), 'skills').fact, /1 yours$/);
|
|
541
|
+
assert.deepEqual(MyAgents.writeFor('skill.' + sk.effective[0].skill, '', {}).body, { [sk.effective[0].skill]: null }, 'the way back clears the pick');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('DEFAULTS: the real shapers take the way back for a skill voice', () => {
|
|
545
|
+
const rec = recorder({ log() {} });
|
|
546
|
+
const me = fx('me');
|
|
547
|
+
const sk = fx('me__skill-prefs');
|
|
548
|
+
const skill = sk.effective[0].skill;
|
|
549
|
+
rec.write('me/skill-prefs', { [skill]: 'opus' }, { me, skillFixture: sk });
|
|
550
|
+
const back = rec.write('me/skill-prefs', MyAgents.writeFor('skill.' + skill, '', {}).body, { me, skillFixture: sk });
|
|
551
|
+
assert.equal(back.status, 200);
|
|
552
|
+
assert.equal(skill in back.body.prefs, false, 'the override is gone');
|
|
553
|
+
const e = back.body.effective.find((x) => x.skill === skill);
|
|
554
|
+
assert.equal(e.override_model, null);
|
|
555
|
+
assert.equal(e.effective_model, e.default_model);
|
|
556
|
+
assert.equal(row(viewOf({ skills: back.body }), 'skills').skills[0].options[0].pressed, true);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('PAGE: My agents mounts the SAME speciality component Settings does, in a section of its own', () => {
|
|
560
|
+
const html = read('my-agents.html');
|
|
561
|
+
assert.ok(!fs.existsSync(path.join(HALL, 'settings-specialities.js')), 'one copy: the Settings file was carved into the shared one');
|
|
562
|
+
for (const page of ['my-agents.html', 'settings.html']) assert.match(read(page), /src="\/builders\/speciality-tools\.js/, `${page} loads it`);
|
|
563
|
+
const at = html.indexOf('id="specialities-scroll"');
|
|
564
|
+
assert.ok(at > 0, 'the section is on the page');
|
|
565
|
+
assert.match(html.slice(html.lastIndexOf('<section', at), at), /class="scroll ma-know"/, 'a panel of its own');
|
|
566
|
+
const desk = html.slice(html.indexOf('id="ma-desk"'), html.indexOf('</section>', html.indexOf('id="ma-desk"')));
|
|
567
|
+
assert.doesNotMatch(desk, /specialities-rows/, 'not inside the desk, which my-agents.js redraws on every change and would wipe a form half-filled');
|
|
568
|
+
// tests/speciality_offer_ui.mjs and tests/speciality_walkthrough_ui.mjs drive every tool on this page's markup
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('PAGE: the desk adopts nothing in one tap — taking a speciality on goes through its instructions, read in full', () => {
|
|
572
|
+
const v = viewOf();
|
|
573
|
+
assert.equal(row(v, 'specialities'), undefined, 'no desk row adopts');
|
|
574
|
+
assert.equal(v.groups.some((g) => g.key === 'know'), false, 'the specialities are their own section');
|
|
575
|
+
assert.doesNotMatch(codeOf(read('my-agents.js')), /\/adopt'|data-adopt/, 'my-agents.js posts no adopt of its own');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('PAGE: when a speciality changes, what your agent reads first is read again', () => {
|
|
579
|
+
// speciality-tools.js dispatches it after an adopt, a stop or a skills save
|
|
580
|
+
// (tests/speciality_walkthrough_ui.mjs); the page listens and re-reads /me.
|
|
581
|
+
const code = codeOf(read('my-agents.js'));
|
|
582
|
+
assert.match(code, /document\.addEventListener\('otb:specialities-changed', /);
|
|
583
|
+
assert.match(codeOf(read('speciality-tools.js')), /new CustomEvent\('otb:specialities-changed'\)/);
|
|
584
|
+
});
|
package/tests/helpers.mjs
CHANGED
|
@@ -1015,6 +1015,167 @@ export function bootSkyPage({
|
|
|
1015
1015
|
return out;
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
+
// bootSpecialityTools — boot THE SPECIALITY TOOLS for real on a page's OWN markup
|
|
1019
|
+
// (task 1005052). One component, modules/hall-ui/public/speciality-tools.js, is
|
|
1020
|
+
// mounted by Settings and by My agents, and it finds every host by id. So, on the
|
|
1021
|
+
// bootSkyPage rule, the DOM here is the page's own: its specialities section
|
|
1022
|
+
// (`#specialities-scroll`) is parsed out of `<page>.html` into the mini-DOM above,
|
|
1023
|
+
// an id the page does not carry is null here too, and an id the component renders
|
|
1024
|
+
// itself (the create form's skills picker) exists only once its markup says so. A
|
|
1025
|
+
// host lost from either page fails that page's run.
|
|
1026
|
+
//
|
|
1027
|
+
// The fake API keeps the builder's adoptions as STATE: adopt adds one, abandon
|
|
1028
|
+
// drops it, the walkthrough's PUT sets its enabled skills, so a reload after a
|
|
1029
|
+
// write reads what the write did. Clicks, submits, typing and the details toggle
|
|
1030
|
+
// are dispatched on the real parsed element and bubble to the listener the
|
|
1031
|
+
// component wired, so `closest()` in its handlers walks the real tree.
|
|
1032
|
+
//
|
|
1033
|
+
// const h = await bootSpecialityTools({ page: 'my-agents', catalog, adoptions });
|
|
1034
|
+
// await h.click('#specialities-rows button[data-act="adopt"]');
|
|
1035
|
+
// h.writes() → the non-GET requests, with their bodies
|
|
1036
|
+
// h.text('specialities-rows'), h.toasts, h.events (the DOM events it dispatched)
|
|
1037
|
+
export const SPECIALITY_PAGES = ['settings', 'my-agents'];
|
|
1038
|
+
function specialitySection(page) {
|
|
1039
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
|
1040
|
+
const at = html.indexOf('id="specialities-scroll"');
|
|
1041
|
+
if (at < 0) return '';
|
|
1042
|
+
const start = html.lastIndexOf('<section', at);
|
|
1043
|
+
return html.slice(start, html.indexOf('</section>', at) + '</section>'.length);
|
|
1044
|
+
}
|
|
1045
|
+
export function bootSpecialityTools({
|
|
1046
|
+
page = 'settings', catalog = [], adoptions = [], suggestions = [], skills = {}, installed = [], answer = null,
|
|
1047
|
+
} = {}) {
|
|
1048
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/speciality-tools.js'), 'utf8');
|
|
1049
|
+
let focused = null;
|
|
1050
|
+
const docListeners = new Map();
|
|
1051
|
+
function node(tag, attrs = {}) {
|
|
1052
|
+
const el = makeEl(tag, attrs);
|
|
1053
|
+
el.hidden = 'hidden' in attrs;
|
|
1054
|
+
el.open = 'open' in attrs;
|
|
1055
|
+
el.checked = 'checked' in attrs;
|
|
1056
|
+
el.focus = () => { focused = el; };
|
|
1057
|
+
el.matches = (sel) => matches(el, sel);
|
|
1058
|
+
// `:checked` is the one pseudo-class the component reads (the ticked skills)
|
|
1059
|
+
const all = el.querySelectorAll.bind(el);
|
|
1060
|
+
el.querySelectorAll = (sel) => (String(sel).endsWith(':checked') ? all(String(sel).slice(0, -':checked'.length)).filter((x) => x.checked) : all(sel));
|
|
1061
|
+
if (tag === 'form') {
|
|
1062
|
+
Object.defineProperty(el, 'elements', {
|
|
1063
|
+
get() { const o = {}; for (const f of el.querySelectorAll('input, select, textarea')) if (f.name) o[f.name] = f; return o; },
|
|
1064
|
+
});
|
|
1065
|
+
el.reset = () => { for (const f of el.querySelectorAll('input, select, textarea')) { f._value = null; f.checked = 'checked' in f.attrs; } };
|
|
1066
|
+
}
|
|
1067
|
+
return el;
|
|
1068
|
+
}
|
|
1069
|
+
function parse(host, html) {
|
|
1070
|
+
host.children = []; host._text = '';
|
|
1071
|
+
const stack = [host];
|
|
1072
|
+
const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
|
|
1073
|
+
let last = 0; let m;
|
|
1074
|
+
const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
|
|
1075
|
+
while ((m = re.exec(html))) {
|
|
1076
|
+
addText(html.slice(last, m.index)); last = re.lastIndex;
|
|
1077
|
+
const tag = m[2].toLowerCase();
|
|
1078
|
+
if (m[1]) { for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; } continue; }
|
|
1079
|
+
const el = node(tag, parseAttrs(m[3]));
|
|
1080
|
+
stack[stack.length - 1].appendChild(el);
|
|
1081
|
+
if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
|
|
1082
|
+
}
|
|
1083
|
+
addText(html.slice(last));
|
|
1084
|
+
}
|
|
1085
|
+
const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
|
|
1086
|
+
get() { return this._html === null ? '' : this._html; },
|
|
1087
|
+
set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
|
|
1088
|
+
configurable: true,
|
|
1089
|
+
});
|
|
1090
|
+
const root = node('body', {});
|
|
1091
|
+
patchInnerHtml(root);
|
|
1092
|
+
parse(root, specialitySection(page));
|
|
1093
|
+
walk(root, (c) => { patchInnerHtml(c); return null; });
|
|
1094
|
+
const hostIds = [];
|
|
1095
|
+
walk(root, (c) => { if (c.id) hostIds.push(c.id); return null; });
|
|
1096
|
+
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
1097
|
+
const query = (sel) => {
|
|
1098
|
+
let scope = [root];
|
|
1099
|
+
for (const part of String(sel).trim().split(/\s+/)) {
|
|
1100
|
+
const next = [];
|
|
1101
|
+
for (const s of scope) walk(s, (c) => { if (matches(c, part)) next.push(c); return null; });
|
|
1102
|
+
scope = next;
|
|
1103
|
+
if (!scope.length) return null;
|
|
1104
|
+
}
|
|
1105
|
+
return scope[0];
|
|
1106
|
+
};
|
|
1107
|
+
function dispatch(target, type) {
|
|
1108
|
+
const ev = { type, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
1109
|
+
for (let n = target; n; n = n.parentNode) (n.listeners[type] || []).forEach((fn) => fn(ev));
|
|
1110
|
+
(docListeners.get(type) || []).forEach((fn) => fn(ev));
|
|
1111
|
+
return ev;
|
|
1112
|
+
}
|
|
1113
|
+
const events = [];
|
|
1114
|
+
const documentObj = {
|
|
1115
|
+
readyState: 'complete',
|
|
1116
|
+
getElementById: byId,
|
|
1117
|
+
querySelector: query,
|
|
1118
|
+
addEventListener: (type, fn) => { if (!docListeners.has(type)) docListeners.set(type, []); docListeners.get(type).push(fn); },
|
|
1119
|
+
dispatchEvent: (ev) => { events.push(ev.type); return true; },
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// ── the API: the catalogue fixed, the builder's adoptions as state ─────────
|
|
1123
|
+
const requests = [];
|
|
1124
|
+
const mine = adoptions.map((a) => ({ ...a }));
|
|
1125
|
+
const fetchFn = async (url, opts = {}) => {
|
|
1126
|
+
const method = opts.method || 'GET';
|
|
1127
|
+
const p = String(url).replace('/api/bongos', '');
|
|
1128
|
+
const body = opts.body ? JSON.parse(opts.body) : undefined;
|
|
1129
|
+
requests.push({ method, path: p, body });
|
|
1130
|
+
const custom = answer && answer(method, p, body);
|
|
1131
|
+
let status = 200;
|
|
1132
|
+
let data = {};
|
|
1133
|
+
const id = (/^\/specialities\/(\d+)/.exec(p) || [])[1];
|
|
1134
|
+
if (custom) ({ status, data } = custom);
|
|
1135
|
+
else if (method === 'GET' && p === '/specialities') data = { specialities: catalog };
|
|
1136
|
+
else if (method === 'GET' && p === '/me/specialities') data = { adoptions: mine.map((a) => ({ ...a })) };
|
|
1137
|
+
else if (method === 'GET' && p === '/specialities/offered') data = { suggestions };
|
|
1138
|
+
else if (method === 'GET' && p === '/specialities/installed-skills') data = { skills: installed };
|
|
1139
|
+
else if (method === 'GET' && /\/skills$/.test(p)) { data = skills[id] || {}; status = skills[id] ? 200 : 404; }
|
|
1140
|
+
else if (method === 'POST' && /\/adopt$/.test(p)) { if (!mine.some((a) => String(a.id) === id)) mine.push({ id: Number(id), active: true, enabled_skills: [] }); data = { adoption: { active: true } }; }
|
|
1141
|
+
else if (method === 'POST' && /\/abandon$/.test(p)) { const was = mine.some((a) => String(a.id) === id); mine.splice(0, mine.length, ...mine.filter((a) => String(a.id) !== id)); data = { ok: true, was_active: was }; }
|
|
1142
|
+
else if (method === 'POST' && /\/documents$/.test(p)) { status = 201; data = { document: { id: 1, title: body.title } }; }
|
|
1143
|
+
else if (method === 'PUT' && /\/skills$/.test(p)) { mine.forEach((a) => { if (String(a.id) === id) a.enabled_skills = body.enabled_skills; }); data = { adoption: { enabled_skills: body.enabled_skills } }; }
|
|
1144
|
+
else if (method === 'PATCH') data = { speciality: { id: Number(id) } };
|
|
1145
|
+
else if (method === 'POST' && p === '/specialities') { status = 201; data = { speciality: { id: 13 } }; }
|
|
1146
|
+
else status = 404;
|
|
1147
|
+
return { ok: status < 400, status, json: async () => data };
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
const toasts = [];
|
|
1151
|
+
const out = { page, hostIds, requests, toasts, events, navigated: null };
|
|
1152
|
+
const windowObj = {
|
|
1153
|
+
OTB: { ...realOTB(), toast: (m) => toasts.push(m) },
|
|
1154
|
+
location: { assign: (u) => { out.navigated = u; } },
|
|
1155
|
+
};
|
|
1156
|
+
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
|
|
1157
|
+
const sandbox = { window: windowObj, document: documentObj, fetch: fetchFn, CustomEvent, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, Error, console, setTimeout };
|
|
1158
|
+
vm.createContext(sandbox);
|
|
1159
|
+
vm.runInContext(src, sandbox, { filename: 'speciality-tools.js' });
|
|
1160
|
+
|
|
1161
|
+
const settleAll = async () => { for (let i = 0; i < 12; i++) await settle(); };
|
|
1162
|
+
const must = (sel) => { const el = query(sel); if (!el) throw new Error(`${page}: no ${sel}`); return el; };
|
|
1163
|
+
return Object.assign(out, {
|
|
1164
|
+
ready: settleAll,
|
|
1165
|
+
el: byId,
|
|
1166
|
+
find: query,
|
|
1167
|
+
html: (id) => (byId(id) ? byId(id).innerHTML : ''),
|
|
1168
|
+
text: (id) => (byId(id) ? byId(id).innerHTML.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') : ''),
|
|
1169
|
+
writes: () => requests.filter((r) => r.method !== 'GET'),
|
|
1170
|
+
focused: () => focused,
|
|
1171
|
+
click: async (sel) => { const el = must(sel); dispatch(el, 'click'); await settleAll(); return el; },
|
|
1172
|
+
submit: async (sel) => { const el = must(sel); const ev = dispatch(el, 'submit'); await settleAll(); return ev; },
|
|
1173
|
+
type: async (sel, value) => { const el = must(sel); el.value = value; dispatch(el, 'input'); await settleAll(); return el; },
|
|
1174
|
+
tick: (sel) => { const el = must(sel); el.checked = true; return el; },
|
|
1175
|
+
toggle: async (id) => { const el = byId(id); if (!el) throw new Error(`${page}: no #${id}`); el.open = !el.open; dispatch(el, 'toggle'); await settleAll(); return el; },
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1018
1179
|
// namesFieldKey — does this source USE `key` as a Full Idea field key, as
|
|
1019
1180
|
// opposed to merely containing the word? (task 1004085 / BV2.R06.)
|
|
1020
1181
|
//
|
|
@@ -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');
|