@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.
Files changed (109) hide show
  1. package/.bongos-core.json +164 -109
  2. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
  3. package/docs/copy-inventory.md +242 -223
  4. package/docs/copy-registry.json +559 -358
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +18 -6
  7. package/docs/page-readings.json +4399 -4315
  8. package/docs/recipes/ui-look-before-you-ship.md +1 -0
  9. package/modules/autonomy/fence.js +16 -1
  10. package/modules/government/protected-surfaces.json +2 -0
  11. package/modules/hall-ui/public/ai-studio.html +1 -1
  12. package/modules/hall-ui/public/atlas.html +1 -1
  13. package/modules/hall-ui/public/blockers.html +1 -1
  14. package/modules/hall-ui/public/board-room.html +1 -1
  15. package/modules/hall-ui/public/city.html +1 -1
  16. package/modules/hall-ui/public/collab.html +1 -1
  17. package/modules/hall-ui/public/commands.html +1 -1
  18. package/modules/hall-ui/public/copy-desk.html +1 -1
  19. package/modules/hall-ui/public/core-move-notice.js +9 -6
  20. package/modules/hall-ui/public/deploy-handoff.js +203 -0
  21. package/modules/hall-ui/public/deploy.html +4 -2
  22. package/modules/hall-ui/public/deploy.js +7 -1
  23. package/modules/hall-ui/public/diagrams.html +1 -1
  24. package/modules/hall-ui/public/drachmae.html +1 -1
  25. package/modules/hall-ui/public/fleet.html +1 -1
  26. package/modules/hall-ui/public/gate.html +1 -1
  27. package/modules/hall-ui/public/goal-map.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/market-watch-item.html +1 -1
  34. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  35. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  36. package/modules/hall-ui/public/market-watch.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/my-agents.css +72 -1
  39. package/modules/hall-ui/public/my-agents.html +31 -7
  40. package/modules/hall-ui/public/my-agents.js +63 -54
  41. package/modules/hall-ui/public/my-agents.states.json +18 -0
  42. package/modules/hall-ui/public/primer.html +1 -1
  43. package/modules/hall-ui/public/profile.html +1 -1
  44. package/modules/hall-ui/public/project-settings.html +1 -1
  45. package/modules/hall-ui/public/ranks.html +1 -1
  46. package/modules/hall-ui/public/roadmap.html +1 -1
  47. package/modules/hall-ui/public/roster.html +1 -1
  48. package/modules/hall-ui/public/sessions.html +1 -1
  49. package/modules/hall-ui/public/settings-software-update.js +22 -8
  50. package/modules/hall-ui/public/settings.css +2 -132
  51. package/modules/hall-ui/public/settings.html +15 -53
  52. package/modules/hall-ui/public/shell.js +25 -73
  53. package/modules/hall-ui/public/speciality-tools.css +140 -0
  54. package/modules/hall-ui/public/{settings-specialities.js → speciality-tools.js} +83 -6
  55. package/modules/hall-ui/public/street.html +1 -1
  56. package/modules/hall-ui/public/studio.html +1 -1
  57. package/modules/hall-ui/public/style.css +119 -39
  58. package/modules/hall-ui/public/task.html +1 -1
  59. package/modules/hall-ui/public/thinking.html +1 -1
  60. package/modules/hall-ui/public/tweak-editor.html +1 -1
  61. package/modules/hall-ui/public/update-pill.js +623 -0
  62. package/modules/hall-ui/public/watch.html +1 -1
  63. package/modules/hall-ui/public/work.html +1 -1
  64. package/modules/hall-ui/records/my-agents.md +4 -2
  65. package/modules/hall-ui/records/panel-family.md +2 -0
  66. package/modules/hall-ui/records/update-pill.md +12 -0
  67. package/modules/lifecycle/github-push.js +1 -0
  68. package/modules/lifecycle/routes/tasks.js +2 -2
  69. package/modules/lifecycle/runner-self-guard.js +122 -0
  70. package/modules/lifecycle/runner-self.js +154 -0
  71. package/modules/public-landing/public/account.html +3 -3
  72. package/modules/public-landing/public/assets/cosmos.css +45 -0
  73. package/modules/public-landing/public/community.html +4 -4
  74. package/modules/public-landing/public/connections.html +1 -1
  75. package/modules/public-landing/public/guild.html +1 -1
  76. package/modules/public-landing/public/invitations.html +4 -4
  77. package/modules/public-landing/public/projects.html +26 -26
  78. package/modules/status-ui/public/index.html +2 -2
  79. package/modules/status-ui/public/index.states.json +12 -0
  80. package/modules/status-ui/public/status.js +10 -2
  81. package/modules/status-ui/public/style.css +57 -0
  82. package/modules/ui-design/kit/README.md +1 -1
  83. package/modules/ui-design/kit/serve.js +8 -1
  84. package/package-lock.json +2 -2
  85. package/package.json +1 -1
  86. package/release-notes.json +20 -0
  87. package/scripts/bongos/autobongos-loop.js +4 -0
  88. package/scripts/bongos/autobongos-run.js +21 -4
  89. package/scripts/bongos/autobongos-tweak-lane.js +5 -3
  90. package/scripts/bongos/ship-flow.js +20 -0
  91. package/scripts/hall-preview/server.js +4 -0
  92. package/src/module-api.js +1 -1
  93. package/tests/autobongos_host_access.mjs +5 -3
  94. package/tests/autobongos_self_modification.mjs +420 -0
  95. package/tests/core_update_banner.mjs +6 -92
  96. package/tests/core_upgrade_owner_door.mjs +4 -56
  97. package/tests/deploy_handoff.mjs +206 -0
  98. package/tests/government_protected_surfaces.mjs +15 -0
  99. package/tests/hall_loading.mjs +3 -1
  100. package/tests/hall_my_agents.mjs +104 -2
  101. package/tests/helpers.mjs +161 -0
  102. package/tests/hub_connections_page.mjs +1 -1
  103. package/tests/hub_invitations_page.mjs +1 -1
  104. package/tests/loading_mark.mjs +203 -0
  105. package/tests/nav_permission_atoms.mjs +3 -2
  106. package/tests/software_update.mjs +6 -2
  107. package/tests/speciality_offer_ui.mjs +182 -149
  108. package/tests/speciality_walkthrough_ui.mjs +180 -161
  109. package/tests/update_pill.mjs +431 -0
@@ -0,0 +1,203 @@
1
+ // tests/loading_mark.mjs — the Bongos-mark loader on the apex pages and the status page
2
+ // (task 1005053, goal 1000095; the hall's own is task 1005043).
3
+ //
4
+ // Owner ask, 2026-11-03: a smooth load screen with the Bongos logo anywhere the
5
+ // software waits for data, in the spirit of monday.com's Vibe data loaders. The
6
+ // apex pages (cosmos.css) and the status page (its own sheet) each carry the same
7
+ // CSS component, .loading-mark, in their own tokens; a page opts in by putting the
8
+ // class on the placeholder it already had, whose words stay as the caption. These
9
+ // cases read the real sheets and pages, per surface: the loader keeps DESIGN.md's
10
+ // motion and colour rules, its rest frame is the logo, and no waiting placeholder
11
+ // is left as a bare line of text.
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
21
+ const STATUS = path.join(ROOT, 'modules', 'status-ui', 'public');
22
+ const read = (dir, f) => fs.readFileSync(path.join(dir, f), 'utf8').replace(/\r\n/g, '\n');
23
+
24
+ const COSMOS = read(LANDING, 'assets/cosmos.css');
25
+ const STATUS_CSS = read(STATUS, 'style.css');
26
+ const APEX_PAGES = ['account', 'community', 'connections', 'guild', 'invitations', 'projects'];
27
+
28
+ // The loader's section of a sheet: from its banner to the last of its keyframes.
29
+ function section(css, banner) {
30
+ const start = css.indexOf(banner);
31
+ assert.ok(start > -1, `the loader's banner is in the sheet: ${banner}`);
32
+ const end = css.indexOf('@keyframes loading-mark-breathe', start);
33
+ assert.ok(end > start, 'the loader section ends on its breath');
34
+ return css.slice(start, css.indexOf('}', css.indexOf('}', end) + 1) + 1);
35
+ }
36
+ const SURFACES = {
37
+ apex: { css: COSMOS, loader: section(COSMOS, '/* ── the loader (task 1005053') },
38
+ status: { css: STATUS_CSS, loader: section(STATUS_CSS, '/* ---------- The loader (task 1005053') },
39
+ };
40
+ const flat = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ');
41
+ const masksOf = (css) => [...css.matchAll(/url\("data:image\/svg\+xml,([^"]+)"\)/g)].map((m) => decodeURIComponent(m[1]));
42
+ function keyframes(css, name) {
43
+ const m = css.match(new RegExp(`@keyframes ${name} ?\\{`));
44
+ assert.ok(m, `@keyframes ${name} exists`);
45
+ let depth = 1, i = m.index + m[0].length;
46
+ for (; i < css.length && depth; i++) depth += css[i] === '{' ? 1 : css[i] === '}' ? -1 : 0;
47
+ return css.slice(m.index + m[0].length, i - 1);
48
+ }
49
+
50
+ test('the apex loader is the bar\'s own Bongos mark, and the status loader is the same mark', () => {
51
+ // The apex bar draws the mark inline (account.html's .bongosMark); the loader's
52
+ // two masks carry exactly its arc, core and dot, so the loader IS the logo.
53
+ const bar = read(LANDING, 'account.html');
54
+ const arc = bar.match(/<path class="mr" d="([^"]+)"/)[1];
55
+ assert.match(bar, /<circle class="mp" cx="100" cy="100" r="55"/);
56
+ assert.match(bar, /<circle cx="156" cy="46" r="12" style="fill:var\(--accent\)"\/>/);
57
+ const [ring, dot] = masksOf(SURFACES.apex.loader);
58
+ assert.ok(ring.includes(`d='${arc}'`), 'the ring is the bar mark\'s arc');
59
+ assert.ok(ring.includes("<circle cx='100' cy='100' r='55'/>"), 'the core is the bar mark\'s core');
60
+ assert.ok(dot.includes("<circle cx='156' cy='46' r='12'/>"), 'the dot is the bar mark\'s dot');
61
+ // The status page has no Bongos mark of its own: its loader carries the apex's two
62
+ // masks byte for byte, so the two surfaces cannot drift into two logos.
63
+ assert.deepEqual(masksOf(SURFACES.status.loader), masksOf(SURFACES.apex.loader));
64
+ });
65
+
66
+ for (const [name, { css, loader }] of Object.entries(SURFACES)) {
67
+ test(`${name}: it moves in CSS only, on transform and filter only, and rests on the logo`, () => {
68
+ for (const frame of ['loading-mark-arrive', 'loading-mark-turn', 'loading-mark-breathe']) {
69
+ const body = keyframes(css, frame);
70
+ const props = [...body.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
71
+ assert.ok(props.length, `${frame} animates something`);
72
+ for (const p of props) assert.ok(['transform', 'filter'].includes(p), `${frame} animates ${p}, not transform or filter`);
73
+ }
74
+ // The turn has no `from`, so 0deg (the logo's own pose, the dot top right) is where
75
+ // it starts and where the Kill Switch leaves it; the arrival and the breath have no
76
+ // `to`, so their resting value is the element as styled, fully shown.
77
+ assert.match(keyframes(css, 'loading-mark-turn'), /^\s*to ?\{ ?transform: ?rotate\(1turn\); ?\}\s*$/);
78
+ assert.match(keyframes(css, 'loading-mark-arrive'), /^\s*from ?\{/);
79
+ assert.match(keyframes(css, 'loading-mark-breathe'), /^\s*from ?\{/);
80
+ // It arrives 240ms late, so a read that answers faster never shows it.
81
+ assert.match(flat(loader), /\.loading-mark ?\{[^}]*animation: ?loading-mark-arrive 500ms cubic-bezier\(\.16, ?1, ?\.3, ?1\) 240ms both;/);
82
+ // The Kill Switch stops every animation, the pseudo-elements included.
83
+ assert.match(flat(css), /@media \(prefers-reduced-motion: ?reduce\) ?\{ ?\*, ?\*::before, ?\*::after ?\{ ?animation: ?none !important; ?transition: ?none !important; ?\} ?\}/);
84
+ });
85
+
86
+ test(`${name}: every colour is a token; the black in the masks is alpha, not paint`, () => {
87
+ const css = flat(loader);
88
+ assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'no colour literal');
89
+ const paints = [...css.matchAll(/(?:background|color): ?([^;]+);/g)].map((m) => m[1].trim());
90
+ assert.equal(paints.length, 3, 'the ink, the dot and the caption are painted');
91
+ for (const p of paints) assert.match(p, /^(var\(--[a-z-]+\)|color-mix\(in srgb, ?var\(--ink\) 30%, ?var\(--bg-card\)\))$/, `${p} is a token or a mix of one`);
92
+ // The one accent is the dot: the live thing on a waiting page (the One Warm Thing Rule).
93
+ assert.equal((css.match(/var\(--accent\)/g) || []).length, 1, 'the accent paints the dot alone');
94
+ const masks = masksOf(loader);
95
+ assert.equal(masks.length, 2, 'two masks: the ring and core, and the dot');
96
+ for (const m of masks) assert.doesNotMatch(m.replace(/stroke='black'|fill='none'/g, ''), /fill='|stroke='|#/, 'a mask carries no colour of its own');
97
+ });
98
+
99
+ test(`${name}: the dot shares the ring's grid cell, so no page padding can knock it off`, () => {
100
+ const css = flat(loader);
101
+ assert.match(css, /\.loading-mark ?\{[^}]*display: ?grid;/);
102
+ assert.match(css, /\.loading-mark::before, ?\.loading-mark::after ?\{[^}]*grid-area: ?1 ?\/ ?1;/);
103
+ assert.doesNotMatch(css, /position: ?absolute|top: ?|left: ?/, 'nothing is positioned by hand');
104
+ });
105
+ }
106
+
107
+ test('apex: three sizes, and a hidden placeholder stays hidden', () => {
108
+ const css = flat(SURFACES.apex.loader);
109
+ assert.match(css, /\.loading-mark--page ?\{[^}]*--lm-size: ?64px;/);
110
+ assert.match(css, /\.loading-mark--inline ?\{[^}]*--lm-size: ?16px;[^}]*grid-template-columns: ?var\(--lm-size\) minmax\(0, ?1fr\);/);
111
+ // The loader is display: grid, which outranks the browser's own [hidden]; the
112
+ // apex pages put a placeholder away with .hidden, so cosmos's rule must win.
113
+ assert.match(COSMOS, /\[hidden\]\{display:none !important;\}/);
114
+ });
115
+
116
+ test('apex: every waiting placeholder wears the loader, in the markup and as the scripts write it', () => {
117
+ // A status line rewritten IN PLACE (its words become the answer) keeps its plain
118
+ // words: the loader belongs to a placeholder that is hidden or replaced when the
119
+ // data lands. Each one is named here with why.
120
+ const IN_PLACE = new Set([
121
+ "community.html:say('Searching…')", // the search's status line, rewritten with the count
122
+ "projects.html:hint('h-renderKey', 'Checking with Render…')", // the Render key's hint, rewritten with the answer
123
+ "projects.html:say('Reading your organisations from GitHub…')", // the org picker's status line
124
+ "projects.html:say('Checking with GitHub…')", // the same line
125
+ "projects.html:inviteSay(id, 'Finding your project’s invite page…'", // the invite box's message, after a press
126
+ "projects.html:inviteSay(id, 'Finding the guild’s members…'", // the same, for a guild
127
+ ]);
128
+ const WAIT_WORD = '(?:Loading|Reading|Opening|Checking|Finding|Searching|Looking|Fetching)';
129
+ const ELEMENT = new RegExp(`<(p|div|li|span|td)\\b([^>]*)>\\s*${WAIT_WORD}\\b[^<]*…\\s*<\\/\\1>`, 'g');
130
+ const LITERAL = new RegExp(`[a-zA-Z]+\\((?:id, )?'${WAIT_WORD}[^']*…'`, 'g');
131
+ const misses = [];
132
+ for (const page of APEX_PAGES) {
133
+ const html = read(LANDING, `${page}.html`);
134
+ for (const m of html.matchAll(ELEMENT)) {
135
+ if (!/\bloading-mark\b/.test(m[2])) misses.push(`${page}.html: ${m[0].slice(0, 100)}`);
136
+ }
137
+ for (const line of html.split('\n')) {
138
+ for (const m of line.matchAll(LITERAL)) {
139
+ if (/loading-mark/.test(line) || /pickNote\('[^']*', true\)/.test(line)) continue;
140
+ const key = `${page}.html:${m[0]}`;
141
+ if (!IN_PLACE.has(key) && !IN_PLACE.has(`${key})`)) misses.push(key);
142
+ }
143
+ }
144
+ }
145
+ assert.deepEqual(misses, [], 'every waiting placeholder wears the loader');
146
+ });
147
+
148
+ test('apex: each page waits with the size that fits it', () => {
149
+ const html = Object.fromEntries(APEX_PAGES.map((p) => [p, read(LANDING, `${p}.html`)]));
150
+ // a page with nothing else to show yet: the page size
151
+ for (const [page, words] of [['account', 'Reading your account…'], ['connections', 'Reading your connections…'], ['guild', 'Reading this guild…'], ['invitations', 'Reading your invitations…']]) {
152
+ assert.ok(html[page].includes(`<p class="loading loading-mark loading-mark--page" id="loading" role="status">${words}</p>`), `${page} waits on the page loader`);
153
+ }
154
+ assert.ok(html.projects.includes('<div class="loading loading-mark loading-mark--page" id="loading" role="status">Reading your projects…</div>'));
155
+ assert.ok(html.projects.includes('<div class="loading loading-mark loading-mark--page" id="mLoading" role="status">Reading the project…</div>'));
156
+ // a section of a page: the default
157
+ for (const id of ['homeLoading', 'boardLoading', 'weekLoading', 'recruitLoading']) {
158
+ assert.match(html.community, new RegExp(`<p class="commMsg loading-mark" id="${id}" role="status">`), `community's ${id}`);
159
+ }
160
+ // a line in a band or a list: inline. The manage page's thirteen sections each wait on
161
+ // their own read, so a mark the height of the line keeps the page from jumping.
162
+ assert.equal((html.projects.match(/><p class="finePrint loading-mark loading-mark--inline" role="status">(?:Reading the project…|Reading the project’s settings…|Checking the repository on GitHub…)<\/p><\/div>/g) || []).length, 14);
163
+ assert.ok(html.account.includes('<p class="aWhat loading-mark loading-mark--inline" id="waysLoading" role="status">'));
164
+ assert.ok(html.account.includes('<p class="aWhat loading-mark loading-mark--inline" id="buildingLoading" role="status">'));
165
+ assert.ok(html.projects.includes('<div id="ghBusy" class="hint loading-mark loading-mark--inline" role="status" hidden>'));
166
+ // the scripts write them back the same way, and the picker's first open wears it too
167
+ for (const call of ['repoVisMsg(', 'visMsg(', 'joinMsg(', 'doorMsg(', 'envMsg(']) {
168
+ assert.ok(html.projects.includes(`${call}'<p class="finePrint loading-mark loading-mark--inline" role="status">`), `${call} writes the loader`);
169
+ }
170
+ for (const call of ['visRuns(', 'joinRuns(']) {
171
+ assert.ok(html.projects.includes(`${call}'<span class="loading-mark loading-mark--inline" role="status">Checking what the project is running…</span>');`), `${call} writes the loader`);
172
+ }
173
+ assert.match(html.projects, /wait\.className = 'finePrint loading-mark loading-mark--inline'; wait\.setAttribute\('role', 'status'\);/);
174
+ assert.match(html.invitations, /li\.className = 'joinList__note' \+ \(waiting \? ' loading-mark loading-mark--inline' : ''\);/);
175
+ });
176
+
177
+ test('status: its two waiting tablets wear the loader, and a failed or empty read stops it', () => {
178
+ const html = read(STATUS, 'index.html');
179
+ assert.ok(html.includes('<div id="versions" class="versions">\n <p class="empty loading-mark" role="status">The masons are at their tools…</p>'));
180
+ assert.ok(html.includes('<div id="world-snapshot" class="world-snapshot">\n <p class="empty loading-mark" role="status">The cartographers are still at their survey…</p>'));
181
+ // the header's "Tablet being consulted…" is rewritten in place before any read
182
+ // starts (stampRefreshTime), so it is never a wait and keeps its plain words
183
+ assert.match(html, /<span class="herm__updated" id="last-updated">Tablet being consulted…<\/span>/);
184
+ // The Kill Switch is the last thing in the status sheet, so nothing can outrank it.
185
+ assert.match(STATUS_CSS.trimEnd(), /@media \(prefers-reduced-motion: reduce\) \{\n {2}\*, \*::before, \*::after \{ animation: none !important; transition: none !important; \}\n\}$/);
186
+
187
+ const js = read(STATUS, 'status.js');
188
+ // the three ways a tablet can end its wait without data each stop the loader
189
+ assert.match(js, /\} catch \(_\) \{\n {4}stopWaiting\(els\.worldSnapshot\);\n {4}return;\n {2}\}/);
190
+ assert.match(js, /if \(snapshots\.length === 0\) \{ stopWaiting\(els\.worldSnapshot\); return; \}/);
191
+ assert.match(js, /loadVersions\(\)\.catch\(\(e\) => \{ stopWaiting\(els\.versions\); console\.error\('versions', e\); \}\)/);
192
+ // ...and stopWaiting itself, run: it takes the class off and leaves the words alone.
193
+ const src = js.match(/function stopWaiting\(el\) \{[\s\S]*?\n\}/)[0];
194
+ const removed = [];
195
+ const wait = { textContent: 'The masons are at their tools…', classList: { remove: (c) => removed.push(c) } };
196
+ const host = { querySelectorAll: (sel) => (sel === '.loading-mark' ? [wait] : []) };
197
+ const ctx = vm.createContext({});
198
+ vm.runInContext(`${src}; this.stopWaiting = stopWaiting;`, ctx);
199
+ ctx.stopWaiting(host);
200
+ ctx.stopWaiting(null);
201
+ assert.deepEqual(removed, ['loading-mark']);
202
+ assert.equal(wait.textContent, 'The masons are at their tools…');
203
+ });
@@ -93,8 +93,9 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
93
93
  // says a held core version was tried and rolled back; task 1004731: the one-time
94
94
  // "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
95
95
  // task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
96
- // Market Watch settings nav item and its fuel glyph.
97
- const SHELL_SHA = '1a23b1cf13567eff';
96
+ // Market Watch settings nav item and its fuel glyph; task 1005041: the Update pill replaced the
97
+ // core-update banner.
98
+ const SHELL_SHA = '9e32caf8aa17dd36';
98
99
 
99
100
  test('shell.js has not changed without its cache-bust being bumped', () => {
100
101
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -345,11 +345,15 @@ test('read only when its group opens — most visits to Settings are for somethi
345
345
 
346
346
  const answerWith = (over) => ok({ ...FIXTURE, ...over, update: { ...FIXTURE.update, ...(over.update || {}) } });
347
347
 
348
+ // task 1005041: the button runs the Update pill's in-place flow, so it no longer reads as a
349
+ // trip off-site; its href stays the hub's door only for a page whose pill script did not load,
350
+ // and the hint names the hub whose small window opens for a moment.
348
351
  test('a hosted project\'s button names the hub it opens; a self-hosted one names the command', async () => {
349
352
  const hub = bootPanel(() => answerWith({ door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' } }));
350
353
  await hub.settle();
351
- assert.match(hub.body(), /href="https:\/\/cloudbongos\.com\/deploy\?project=mercury">Update to [\d.]+ ↗</);
352
- assert.match(hub.body(), /on cloudbongos\.com:/);
354
+ assert.match(hub.body(), /href="https:\/\/cloudbongos\.com\/deploy\?project=mercury">Update to [\d.]+</);
355
+ assert.doesNotMatch(hub.body(), /↗/, 'it updates in place; it is not a link off-site any more');
356
+ assert.match(hub.body(), /A small window from cloudbongos\.com opens for a moment/);
353
357
 
354
358
  const cli = bootPanel(() => answerWith({ door: { kind: 'cli', url: null } }));
355
359
  await cli.settle();
@@ -1,27 +1,30 @@
1
- // tests/speciality_offer_ui.mjs — a speciality's OWNER chooses what it offers,
2
- // from the hall (task 1004404; ADR 0310 §1).
1
+ // tests/speciality_offer_ui.mjs — the OWNER's side of the speciality tools:
2
+ // creating one, training it, and choosing what it offers (task 1004404; ADR 0310
3
+ // §1; on My agents too since task 1005052).
3
4
  //
4
- // Before this only the API could set a speciality's offered skills, so nothing
5
- // made in the hall offered any and the adoption walkthrough never appeared. These
6
- // pin that the owner can choose on create AND afterwards, that each choice shows
7
- // the skill's plain description, that only installed skills are choices, and that
8
- // a skill removed since is named rather than silently kept.
5
+ // Before task 1004404 only the API could set a speciality's offered skills, so
6
+ // nothing made in the hall offered any and the adoption walkthrough never
7
+ // appeared. These pin that the owner can choose on create AND afterwards, that
8
+ // each choice shows the skill's plain description, that only installed skills are
9
+ // choices, and that a skill removed since is named rather than silently kept —
10
+ // and that training adds a document in one request, refused inline when a field
11
+ // is empty.
9
12
  //
10
- // Like tests/speciality_walkthrough_ui.mjs this EXECUTES the real
11
- // modules/hall-ui/public/settings-specialities.js in a vm under a minimal DOM
12
- // stub against a fake API, and drives the delegated handlers the page wires.
13
+ // TWO PAGES, ONE COMPONENT (task 1005052). modules/hall-ui/public/speciality-tools.js
14
+ // is mounted by Settings and by My agents, and it renders its own create form, so
15
+ // neither page carries a second copy of that markup. Every case runs once per
16
+ // page, booted on THAT page's own markup (tests/helpers.mjs bootSpecialityTools),
17
+ // so a host lost from either page fails that page's run.
13
18
  //
14
19
  // Run: node tests/speciality_offer_ui.mjs
15
20
  import assert from 'node:assert/strict';
16
21
  import fs from 'node:fs';
17
22
  import path from 'node:path';
18
- import vm from 'node:vm';
19
23
  import { fileURLToPath } from 'node:url';
20
- import { makeRunner } from './helpers.mjs';
24
+ import { makeRunner, bootSpecialityTools, SPECIALITY_PAGES } from './helpers.mjs';
21
25
 
22
26
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
- const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-specialities.js'), 'utf8');
24
- const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
27
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/speciality-tools.js'), 'utf8');
25
28
  const { test, summary } = makeRunner();
26
29
 
27
30
  const INSTALLED = [
@@ -33,153 +36,183 @@ const INSTALLED = [
33
36
  const mine = (over = {}) => ({ id: 12, name: 'Pricing', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', adoptable: true, mine: true, skills: ['recall', 'retired-skill'], ...over });
34
37
  const theirs = (over = {}) => ({ id: 9, name: 'Economics', discipline: 'ideator', summary: '', contract_md: '', visibility: 'public', adoptable: true, mine: false, skills: ['recall'], ...over });
35
38
 
36
- // The checked boxes a form would submit, read off the rendered markup.
39
+ // The ticked boxes in rendered markup.
37
40
  const checkedIn = (html) => [...html.matchAll(/<input type="checkbox" name="skill" value="([^"]+)" checked/g)].map((m) => m[1]);
38
- const fakeForm = (dataset, values) => ({
39
- dataset,
40
- querySelectorAll: (sel) => (sel === 'input[name="skill"]:checked' ? values.map((value) => ({ value })) : []),
41
- });
42
41
 
43
- async function boot({ catalog = [mine(), theirs()] } = {}) {
44
- const requests = [];
45
- const toasts = [];
46
- const els = new Map();
47
- const handlers = new Map();
48
- const makeEl = (id) => ({
49
- id, hidden: id === 'specialities-scroll', innerHTML: '', textContent: '', elements: {}, dataset: {}, open: false,
50
- addEventListener(type, fn) { handlers.set(`${id}:${type}`, fn); },
51
- });
52
- for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status', 'speciality-create', 'speciality-create-offer', 'speciality-create-skills']) els.set(id, makeEl(id));
53
- // The create form's fields, as settings.html declares them.
54
- const create = els.get('speciality-create');
55
- const field = (value) => ({ value, addEventListener() {} });
56
- create.elements = { name: field(''), discipline: field(''), summary: field(''), contract_md: field(''), visibility: field('private'), sellable: { checked: false, disabled: false, addEventListener() {} } };
57
- create.reset = () => { create.elements.name.value = ''; };
58
- create.querySelectorAll = () => [];
59
-
60
- const fetch = async (url, opts = {}) => {
61
- const method = opts.method || 'GET';
62
- const p = url.replace('/api/bongos', '');
63
- requests.push({ method, path: p, body: opts.body ? JSON.parse(opts.body) : undefined });
64
- let body = {};
65
- if (method === 'GET' && p === '/specialities') body = { specialities: catalog };
66
- else if (method === 'GET' && p === '/me/specialities') body = { adoptions: [] };
67
- else if (method === 'GET' && p === '/specialities/offered') body = { suggestions: [] };
68
- else if (method === 'GET' && p === '/specialities/installed-skills') body = { skills: INSTALLED };
69
- else if (method === 'PATCH') body = { speciality: { id: 12 } };
70
- else if (method === 'POST' && p === '/specialities') body = { speciality: { id: 13 } };
71
- return { ok: true, status: 200, json: async () => body };
72
- };
73
-
74
- const document = { readyState: 'complete', getElementById: (id) => els.get(id) || null, addEventListener() {}, querySelector: () => null };
75
- const window = {
76
- OTB: {
77
- escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
78
- $: () => null, toast: (m) => toasts.push(m), roleAxisLabel: () => 'Role',
79
- },
80
- location: { assign() {} },
81
- };
82
- vm.runInNewContext(SRC, { window, document, fetch, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, console });
83
- const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); };
84
- await settle();
85
-
86
- const rows = els.get('specialities-rows');
87
- async function click(act, id) {
88
- const btn = { dataset: { act, id: String(id) } };
89
- handlers.get('specialities-rows:click')({ target: { closest: (sel) => (sel === 'button[data-act]' ? btn : null) } });
90
- await settle();
91
- }
92
- async function submitOffer(id, values) {
93
- const form = fakeForm({ id: String(id) }, values);
94
- handlers.get('specialities-rows:submit')({ target: { closest: (sel) => (sel === 'form.speciality-offer' ? form : null) }, preventDefault() {} });
95
- await settle();
96
- }
97
- return { els, rows, requests, toasts, click, submitOffer, handlers, create, settle };
42
+ async function boot(page, { catalog = [mine(), theirs()], ...rest } = {}) {
43
+ const h = bootSpecialityTools({ page, catalog, installed: INSTALLED, ...rest });
44
+ await h.ready();
45
+ return h;
98
46
  }
99
-
100
- const writes = (h) => h.requests.filter((r) => r.method !== 'GET');
47
+ const ROWS = '#specialities-rows';
48
+ const OFFER = `${ROWS} form.speciality-offer`;
49
+ const writes = (h) => h.writes();
101
50
  const installedReads = (h) => h.requests.filter((r) => r.path === '/specialities/installed-skills').length;
51
+ // every element id the component looks up
52
+ const HOSTS = [...new Set([...SRC.matchAll(/\bel\('([a-z-]+)'\)/g)].map((m) => m[1]))];
102
53
 
103
- await test('only a speciality you own has an "Offered skills" control', async () => {
104
- const h = await boot();
105
- const buttons = [...h.rows.innerHTML.matchAll(/data-act="offer" data-id="(\d+)"/g)].map((m) => m[1]);
106
- assert.deepEqual(buttons, ['12']);
107
- });
108
-
109
- await test('opening it lists every installed skill with its plain description and cost, the current offer ticked', async () => {
110
- const h = await boot();
111
- assert.equal(installedReads(h), 0, 'nothing is fetched until the owner asks');
112
- await h.click('offer', 12);
113
- assert.equal(installedReads(h), 1);
114
- const html = h.rows.innerHTML;
115
- for (const k of INSTALLED) {
116
- assert.ok(html.includes(`value="${k.name}"`), `${k.name} is a choice`);
117
- assert.ok(html.includes(k.does), `${k.name} shows what it does`);
118
- assert.ok(html.includes(k.cost), `${k.name} shows what it costs`);
54
+ await test('the component names the hosts it reads (sanity: the scan below has something to check)', () => {
55
+ for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status', 'speciality-create', 'speciality-create-offer', 'speciality-create-skills']) {
56
+ assert.ok(HOSTS.includes(id), `speciality-tools.js reads #${id}`);
119
57
  }
120
- assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
121
- assert.match(html, /Offering a skill turns nothing on/);
122
58
  });
123
59
 
124
- await test('a skill it offers that the instance no longer has is named, not offered as a choice', async () => {
125
- const h = await boot();
126
- await h.click('offer', 12);
127
- assert.ok(!h.rows.innerHTML.includes('value="retired-skill"'), 'not a checkbox');
128
- assert.match(h.rows.innerHTML, /No longer on this project, so saving drops it from the offer: <code>retired-skill<\/code>/);
129
- });
60
+ for (const page of SPECIALITY_PAGES) {
61
+ await test(`${page}: the page carries every host the component reads, and no second copy of the create form`, async () => {
62
+ const h = await boot(page);
63
+ for (const id of HOSTS) assert.ok(h.el(id), `${page}.html: #${id} is there once the component has run`);
64
+ assert.ok(h.hostIds.includes('speciality-create'), `${page}.html declares the create form's host`);
65
+ const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8');
66
+ const form = html.slice(html.indexOf('<form id="speciality-create"'), html.indexOf('</form>', html.indexOf('<form id="speciality-create"')));
67
+ assert.doesNotMatch(form, /<(input|select|textarea)\b/, `${page}.html: the create form is EMPTY — the component renders it, so there is one copy`);
68
+ assert.ok(html.includes('/builders/speciality-tools.js'), `${page}.html loads the component`);
69
+ assert.ok(html.includes('/builders/speciality-tools.css'), `${page}.html loads its sheet`);
70
+ });
130
71
 
131
- await test('saving sends exactly the ticked skills as one PATCH, and nothing is written before', async () => {
132
- const h = await boot();
133
- await h.click('offer', 12);
134
- assert.equal(writes(h).length, 0);
135
- await h.submitOffer(12, ['recall', 'status']);
136
- const w = writes(h);
137
- assert.equal(w.length, 1);
138
- assert.equal(w[0].method, 'PATCH');
139
- assert.equal(w[0].path, '/specialities/12');
140
- assert.deepEqual(w[0].body, { skills: ['recall', 'status'] });
141
- assert.ok(h.toasts.some((m) => /offers 2 skills/.test(m)));
142
- assert.ok(!/class="speciality-offer"/.test(h.rows.innerHTML), 'the editor closes on save');
143
- });
72
+ await test(`${page}: the create form the component renders carries every field, and the skills picker INSIDE the form it submits with`, async () => {
73
+ const h = await boot(page);
74
+ const form = h.el('speciality-create');
75
+ assert.deepEqual(Object.keys(form.elements).sort(), ['contract_md', 'discipline', 'name', 'sellable', 'summary', 'visibility']);
76
+ assert.deepEqual(form.elements.discipline.querySelectorAll('option').map((o) => o.attrs.value), ['', 'engineer', 'ideator', 'artist', 'ui']);
77
+ assert.deepEqual(form.elements.visibility.querySelectorAll('option').map((o) => o.attrs.value), ['private', 'project', 'public']);
78
+ assert.ok(form.querySelector('details#speciality-create-offer'), 'the picker is inside the form');
79
+ assert.ok(form.querySelector('#speciality-create-skills'), 'with the host its choices fill');
80
+ });
144
81
 
145
- await test('offering nothing is a choice too', async () => {
146
- const h = await boot();
147
- await h.click('offer', 12);
148
- await h.submitOffer(12, []);
149
- assert.deepEqual(writes(h)[0].body, { skills: [] });
150
- });
82
+ await test(`${page}: only a speciality you own has "Train" and "Offered skills"`, async () => {
83
+ const h = await boot(page);
84
+ for (const act of ['offer', 'train']) {
85
+ const ids = [...h.html('specialities-rows').matchAll(new RegExp(`data-act="${act}" data-id="(\\d+)"`, 'g'))].map((m) => m[1]);
86
+ assert.deepEqual(ids, ['12'], act);
87
+ }
88
+ });
151
89
 
152
- await test('the installed list is read once per page, however often the editor opens', async () => {
153
- const h = await boot();
154
- await h.click('offer', 12);
155
- await h.click('offer', 12); // close
156
- await h.click('offer', 12); // open again
157
- assert.equal(installedReads(h), 1);
158
- });
90
+ await test(`${page}: opening "Offered skills" lists every installed skill with its plain description and cost, the current offer ticked`, async () => {
91
+ const h = await boot(page);
92
+ assert.equal(installedReads(h), 0, 'nothing is fetched until the owner asks');
93
+ await h.click(`${ROWS} button[data-act="offer"]`);
94
+ assert.equal(installedReads(h), 1);
95
+ const html = h.html('specialities-rows');
96
+ for (const k of INSTALLED) {
97
+ assert.ok(html.includes(`value="${k.name}"`), `${k.name} is a choice`);
98
+ assert.ok(html.includes(k.does), `${k.name} shows what it does`);
99
+ assert.ok(html.includes(k.cost), `${k.name} shows what it costs`);
100
+ }
101
+ assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
102
+ assert.match(html, /Offering a skill turns nothing on/);
103
+ });
159
104
 
160
- await test('the create form offers the same choices, filled on first open, and sends them with the new speciality', async () => {
161
- const h = await boot();
162
- const box = h.els.get('speciality-create-offer');
163
- const host = h.els.get('speciality-create-skills');
164
- box.open = true;
165
- h.handlers.get('speciality-create-offer:toggle')();
166
- await h.settle();
167
- for (const k of INSTALLED) assert.ok(host.innerHTML.includes(`value="${k.name}"`) && host.innerHTML.includes(k.does));
168
- assert.deepEqual(checkedIn(host.innerHTML), [], 'a new speciality starts offering nothing');
169
- h.create.elements.name.value = 'New one';
170
- h.create.elements.discipline.value = 'engineer';
171
- h.create.querySelectorAll = (sel) => (sel === 'input[name="skill"]:checked' ? [{ value: 'status' }] : []);
172
- h.handlers.get('speciality-create:submit')({ preventDefault() {} });
173
- await h.settle();
174
- const post = writes(h).find((r) => r.method === 'POST' && r.path === '/specialities');
175
- assert.ok(post, 'created');
176
- assert.deepEqual(post.body.skills, ['status']);
177
- });
105
+ await test(`${page}: a skill it offers that the instance no longer has is named, not offered as a choice`, async () => {
106
+ const h = await boot(page);
107
+ await h.click(`${ROWS} button[data-act="offer"]`);
108
+ assert.ok(!h.html('specialities-rows').includes('value="retired-skill"'), 'not a checkbox');
109
+ assert.match(h.html('specialities-rows'), /No longer on this project, so saving drops it from the offer: <code>retired-skill<\/code>/);
110
+ });
178
111
 
179
- await test('settings.html carries the create form\'s picker inside the form it submits with', () => {
180
- const form = HTML.slice(HTML.indexOf('<form id="speciality-create"'), HTML.indexOf('</form>', HTML.indexOf('<form id="speciality-create"')));
181
- assert.match(form, /<details id="speciality-create-offer"/);
182
- assert.match(form, /id="speciality-create-skills"/);
183
- });
112
+ await test(`${page}: "Find a skill" narrows the list as you type`, async () => {
113
+ const h = await boot(page);
114
+ await h.click(`${ROWS} button[data-act="offer"]`);
115
+ await h.type(`${OFFER} .speciality-offer__filter`, 'version');
116
+ const shown = h.find(`${OFFER} .speciality-offer__list`).children.filter((li) => !li.hidden).map((li) => li.querySelector('input').attrs.value);
117
+ assert.deepEqual(shown, ['status'], 'matched on the plain description, not only the name');
118
+ });
119
+
120
+ await test(`${page}: saving sends exactly the ticked skills as one PATCH, and nothing is written before`, async () => {
121
+ const h = await boot(page);
122
+ await h.click(`${ROWS} button[data-act="offer"]`);
123
+ assert.equal(writes(h).length, 0);
124
+ h.tick(`${OFFER} input[value="status"]`);
125
+ const ev = await h.submit(OFFER);
126
+ assert.equal(ev.defaultPrevented, true, 'the page does not navigate');
127
+ const w = writes(h);
128
+ assert.equal(w.length, 1);
129
+ assert.equal(w[0].method, 'PATCH');
130
+ assert.equal(w[0].path, '/specialities/12');
131
+ assert.deepEqual(w[0].body, { skills: ['recall', 'status'] });
132
+ assert.ok(h.toasts.some((m) => /offers 2 skills/.test(m)));
133
+ assert.ok(!/class="speciality-offer"/.test(h.html('specialities-rows')), 'the editor closes on save');
134
+ });
135
+
136
+ await test(`${page}: offering nothing is a choice too`, async () => {
137
+ const h = await boot(page);
138
+ await h.click(`${ROWS} button[data-act="offer"]`);
139
+ h.find(`${OFFER} input[value="recall"]`).checked = false;
140
+ await h.submit(OFFER);
141
+ assert.deepEqual(writes(h)[0].body, { skills: [] });
142
+ });
143
+
144
+ await test(`${page}: the installed list is read once per page, however often the editor opens`, async () => {
145
+ const h = await boot(page);
146
+ await h.click(`${ROWS} button[data-act="offer"]`);
147
+ await h.click(`${ROWS} button[data-act="offer"]`); // close
148
+ await h.click(`${ROWS} button[data-act="offer"]`); // open again
149
+ assert.equal(installedReads(h), 1);
150
+ });
151
+
152
+ await test(`${page}: training adds a document to your own speciality in one POST, and the form closes`, async () => {
153
+ const h = await boot(page);
154
+ await h.click(`${ROWS} button[data-act="train"]`);
155
+ const form = h.find(`${ROWS} form.speciality-train`);
156
+ assert.ok(form, 'Train opens the form in its card');
157
+ assert.match(form.textContent, /indexed\s+for recall, not pasted into every session/);
158
+ form.elements.title.value = ' How we price a swap ';
159
+ form.elements.body_md.value = 'Start from the spread.';
160
+ await h.submit(`${ROWS} form.speciality-train`);
161
+ assert.deepEqual(writes(h).map((r) => [r.method, r.path, r.body]), [['POST', '/specialities/12/documents', { title: 'How we price a swap', body_md: 'Start from the spread.' }]]);
162
+ assert.ok(h.toasts.includes('Added.'));
163
+ assert.equal(h.find(`${ROWS} form.speciality-train`), null, 'the form closes on save');
164
+ });
165
+
166
+ await test(`${page}: a document with no title or no body is refused inline, before any request`, async () => {
167
+ const h = await boot(page);
168
+ await h.click(`${ROWS} button[data-act="train"]`);
169
+ h.find(`${ROWS} form.speciality-train`).elements.title.value = 'Only a title';
170
+ await h.submit(`${ROWS} form.speciality-train`);
171
+ assert.equal(writes(h).length, 0);
172
+ assert.equal(h.el('specialities-status').textContent, 'A document needs both a title and some content.');
173
+ });
174
+
175
+ await test(`${page}: a document it already knows says so`, async () => {
176
+ const h = await boot(page, { answer: (m, p) => (m === 'POST' && /\/documents$/.test(p) ? { status: 200, data: { skipped: true, reason: 'already_trained' } } : null) });
177
+ await h.click(`${ROWS} button[data-act="train"]`);
178
+ const form = h.find(`${ROWS} form.speciality-train`);
179
+ form.elements.title.value = 'T';
180
+ form.elements.body_md.value = 'B';
181
+ await h.submit(`${ROWS} form.speciality-train`);
182
+ assert.ok(h.toasts.includes('It already knew that one.'));
183
+ });
184
+
185
+ await test(`${page}: the create form offers the same choices, filled on first open, and sends them with the new speciality`, async () => {
186
+ const h = await boot(page);
187
+ const host = h.el('speciality-create-skills');
188
+ await h.toggle('speciality-create-offer');
189
+ for (const k of INSTALLED) assert.ok(host.innerHTML.includes(`value="${k.name}"`) && host.innerHTML.includes(k.does));
190
+ assert.deepEqual(checkedIn(host.innerHTML), [], 'a new speciality starts offering nothing');
191
+ const form = h.el('speciality-create');
192
+ form.elements.name.value = 'New one';
193
+ form.elements.discipline.value = 'engineer';
194
+ h.tick('#speciality-create-skills input[value="status"]');
195
+ await h.submit('#speciality-create');
196
+ const post = writes(h).find((r) => r.method === 'POST' && r.path === '/specialities');
197
+ assert.ok(post, 'created');
198
+ assert.deepEqual(post.body, { name: 'New one', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', sellable: false, skills: ['status'] });
199
+ assert.ok(h.toasts.includes('Speciality created.'));
200
+ });
201
+
202
+ await test(`${page}: create refuses a nameless or roleless speciality inline, and a private one cannot be for sale`, async () => {
203
+ const h = await boot(page);
204
+ const form = h.el('speciality-create');
205
+ await h.submit('#speciality-create');
206
+ assert.equal(h.el('specialities-status').textContent, 'Give it a name.');
207
+ form.elements.name.value = 'X';
208
+ await h.submit('#speciality-create');
209
+ assert.match(h.el('specialities-status').textContent, /^Pick which role it sits under\.$/);
210
+ assert.equal(writes(h).length, 0);
211
+ assert.equal(form.elements.sellable.disabled, true, 'private (the default) cannot be sold');
212
+ form.elements.visibility.value = 'public';
213
+ form.elements.visibility.fire('change', {});
214
+ assert.equal(form.elements.sellable.disabled, false);
215
+ });
216
+ }
184
217
 
185
218
  summary();