@bongos/core 1.21.164 → 1.21.166

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.
@@ -1,10 +1,10 @@
1
1
  // tests/page_notes_panel.mjs — the hall side of the artist's page feedback doc
2
- // (task 1004496): the palette tool craft-mode.js puts in the top bar, and the
3
- // reader in page-notes.js. The server half is tests/page_notes.mjs.
2
+ // (task 1004496): the docked review panel craft-mode.js puts at the right edge
3
+ // in Artistry (task 1004771: no top-bar button), and the reader in page-notes.js. The server half is tests/page_notes.mjs.
4
4
  //
5
- // Runs the REAL craft-mode.js over a small DOM: the palette shows in Artistry
6
- // alone and leaves when the mode changes, pressing it loads page-notes.js once
7
- // (same ?v as craft-mode.js) and a ?note= link opens the doc in any mode. Then
5
+ // Runs the REAL craft-mode.js over a small DOM: the panel docks in Artistry
6
+ // alone and closes when the mode changes, page-notes.js loads once (same ?v as
7
+ // craft-mode.js) and a ?note= link opens the doc in any mode. Then
8
8
  // the REAL page-notes.js reader: it escapes the doc and shows only this module's
9
9
  // own images, which is what keeps a doc from pulling a picture off another site.
10
10
  import assert from 'node:assert/strict';
@@ -63,7 +63,7 @@ function boot({ session = {}, search = '' } = {}) {
63
63
  // "Loading" page-notes.js: record the tag, then define the API it would define.
64
64
  head.onAppend = (tag) => {
65
65
  loaded.push(tag.src);
66
- window.OTBPageNotes = { open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id) };
66
+ window.OTBPageNotes = { open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id), dock: () => opened.push('dock'), close: () => opened.push('close') };
67
67
  tag.onload();
68
68
  };
69
69
  const sandbox = { window, document, Promise, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
@@ -74,36 +74,31 @@ function boot({ session = {}, search = '' } = {}) {
74
74
  }
75
75
  const flush = () => new Promise((r) => setTimeout(r, 0));
76
76
 
77
- test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
77
+ test('THE PANEL: docked at the edge in Artistry alone, with no top-bar button, and closed when the mode changes', async () => {
78
78
  const p = boot({ session: { 'hall-craft': 'artistry' } });
79
- assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette', 'craft-tool-paintbrush'], 'the palette, then the paintbrush (task 1004497)');
80
- // Task 1004686: the palette sits in its split pill (Review + the review-mode chevron).
81
- const pill = p.tools()[0].parentNode;
82
- assert.equal(pill.id, 'craft-review', 'the palette rides the Review split pill');
83
- assert.equal(pill.nextSibling, p.tools()[1], 'the palette comes first');
84
- assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
85
- assert.equal(p.tools()[0].getAttribute('aria-label'), 'Artist review: write feedback on this page', 'one name with the public site\'s button (task 1004499)');
86
- for (const m of ['building', 'governing']) {
87
- p.craft.choose(m);
88
- assert.equal(p.tools().length, 0, `no palette in ${m}`);
89
- }
79
+ await flush();
80
+ assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-paintbrush'], 'the top bar keeps Edit words alone (task 1004771)');
81
+ assert.equal(p.tools()[0].nextSibling, p.sw, 'right before the mode switch');
82
+ assert.deepEqual(p.opened, ['dock'], 'the review panel docks instead');
83
+ for (const m of ['building', 'governing']) p.craft.choose(m);
84
+ assert.deepEqual(p.opened, ['dock', 'close', 'close'], 'and leaves with Artistry');
90
85
  p.craft.choose('ideating');
91
- assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-lightbulb'], 'Ideating swaps them for its lightbulb alone (task 1004498)');
92
- p.craft.choose('artistry');
86
+ assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-lightbulb'], 'Ideating shows its lightbulb alone (task 1004498)');
93
87
  p.craft.choose('artistry');
94
- assert.equal(p.tools().length, 2, 'choosing Artistry twice draws one palette and one paintbrush');
95
- assert.equal(boot().tools().length, 0, 'and none with no mode at all');
88
+ await flush();
89
+ assert.equal(p.opened.filter((x) => x === 'dock').length, 2, 'back in Artistry it docks again');
90
+ const none = boot();
91
+ await flush();
92
+ assert.equal(none.tools().length, 0, 'no tools with no mode at all');
93
+ assert.deepEqual(none.loaded, [], 'and nothing loads');
96
94
  });
97
95
 
98
- test('pressing it loads page-notes.js once, at the same version as craft-mode.js, and opens it', async () => {
96
+ test('the panel script loads once, at the same version as craft-mode.js', async () => {
99
97
  const p = boot({ session: { 'hall-craft': 'artistry' } });
100
- assert.deepEqual(p.loaded, [], 'nothing loads until the palette is pressed');
101
- p.tools()[0].click();
102
98
  await flush();
103
- p.tools()[0].click();
99
+ p.craft.choose('artistry');
104
100
  await flush();
105
101
  assert.deepEqual(p.loaded, ['/builders/page-notes.js?v=2026-10-04-test']);
106
- assert.deepEqual(p.opened, ['open', 'open']);
107
102
  });
108
103
 
109
104
  test('a ?note= link opens that doc on arrival, in any mode', async () => {
@@ -152,6 +147,145 @@ test('colours live in the sheets, and the panel and tool are styled there', () =
152
147
  // the hall pulls it in through style.css, ahead of every rule.
153
148
  const pn = fs.readFileSync(path.join(HALL, 'page-notes.css'), 'utf8');
154
149
  for (const sel of ['.pn {', '.pn__panel {', '.pn__text {', '.pn-doc__img {']) assert.ok(pn.includes(sel) && !css.includes(sel), sel);
150
+ // Task 1004771: the docked, scrimless look is the review panel's alone
151
+ // (.pn--dock). The lightbulb rides plain .pn and keeps its scrim and its
152
+ // scroll lock, so docking the review panel cannot quietly change it.
153
+ assert.match(pn, /html\.pn-open \{ overflow: hidden; \}/, 'the lightbulb keeps its scroll lock');
154
+ assert.match(pn, /\.pn \{[^}]*inset: 0;[^}]*background: var\(--pn-scrim\);/, 'and its scrim');
155
+ assert.match(pn, /\.pn\.pn--dock \{[^}]*background: none;/, 'the docked review panel drops both');
156
+ assert.match(NOTES, /sheet\.className = docked \? 'pn pn--dock' : 'pn';/, 'and only a docked review panel wears it');
157
+ const bulb = fs.readFileSync(path.join(HALL, 'lightbulb.js'), 'utf8');
158
+ assert.doesNotMatch(bulb, /pn--dock/, 'the lightbulb does not');
155
159
  const at = css.indexOf('@import url("page-notes.css");');
156
160
  assert.ok(at !== -1 && at < css.indexOf('{'), 'style.css imports the sheet before any rule');
157
161
  });
162
+
163
+ // ---------- the docked panel, REAL page-notes.js (task 1004771) ----------
164
+ // A small DOM that runs the real sheet: querySelector hands back one cached
165
+ // fake per selector, so the tab, the X and the mode row are real listeners.
166
+ function dockedPanel({ reviewing = null, opened = false } = {}) {
167
+ const calls = [];
168
+ let sheet = null;
169
+ const fake = (name) => {
170
+ const attrs = {}, listeners = {}, classes = new Set();
171
+ const kids = {};
172
+ const el = {
173
+ name, innerHTML: '', parentNode: null, id: '', className: '',
174
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
175
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
176
+ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c),
177
+ toggle: (c, on) => { if (on === undefined) on = !classes.has(c); if (on) classes.add(c); else classes.delete(c); return on; } },
178
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
179
+ fire(t, ev = {}) { (listeners[t] || []).forEach((fn) => fn({ target: el, preventDefault() {}, ...ev })); },
180
+ querySelector(sel) { return (kids[sel] ||= Object.assign(fake(sel), { parentNode: el })); },
181
+ querySelectorAll() { return []; },
182
+ contains(n) { for (let x = n; x; x = x.parentNode) if (x === el) return true; return false; },
183
+ focus() { document.activeElement = el; },
184
+ };
185
+ return el;
186
+ };
187
+ const body = fake('body');
188
+ body.appendChild = (c) => { c.parentNode = body; return c; };
189
+ body.removeChild = (c) => { c.parentNode = null; return c; };
190
+ body.dataset = { page: 'studio', pageTitle: 'Studio' };
191
+ const docKeys = [];
192
+ const document = {
193
+ body, activeElement: null, title: 'Studio — hall', documentElement: fake('html'),
194
+ createElement: () => (sheet = fake('div')),
195
+ addEventListener: (t, fn) => { if (t === 'keydown') docKeys.push(fn); },
196
+ removeEventListener() {},
197
+ };
198
+ const fetch = (url, opts) => {
199
+ calls.push(opts.method + ' ' + url);
200
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ note: { id: 9, status: 'draft', body_md: '' }, images: [], notes: [] }) });
201
+ };
202
+ const craft = {
203
+ reviewing: () => reviewing,
204
+ review: (id) => { reviewing = id; calls.push('review ' + id); },
205
+ stopReview: () => { reviewing = null; calls.push('stop'); },
206
+ experiences: () => [{ id: 'ideating', label: 'Ideator experience', short: 'Ideating' }, { id: 'building', label: 'Building experience', short: 'Building' }],
207
+ iconHtml: () => '<svg></svg>',
208
+ };
209
+ const window = { location: { pathname: '/studio' }, OTBCraftMode: craft };
210
+ const sandbox = { window, document, fetch, setTimeout, clearTimeout, Promise };
211
+ vm.createContext(sandbox);
212
+ vm.runInContext(NOTES, sandbox, { filename: 'page-notes.js' });
213
+ const api = window.OTBPageNotes;
214
+ if (opened) api.open(); else api.dock();
215
+ const press = (attr, value = '') => {
216
+ const btn = fake('button');
217
+ btn.setAttribute(attr, value);
218
+ sheet.fire('click', { target: { closest: () => btn } });
219
+ };
220
+ return { api, calls, document, docKeys, body, press, html: document.documentElement, get sheet() { return sheet; }, collapsed: () => sheet.classList.contains('pn--collapsed') };
221
+ }
222
+
223
+ test('DOCKED: it waits collapsed behind its tab and asks for no draft until it is first shown', async () => {
224
+ const p = dockedPanel();
225
+ await flush();
226
+ assert.ok(p.collapsed(), 'tucked away on arrival');
227
+ assert.equal(p.sheet.parentNode, p.body, 'but on the page, so its tab shows');
228
+ assert.deepEqual(p.calls, [], 'no request while nobody has looked');
229
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'mouse' });
230
+ await flush();
231
+ assert.ok(!p.collapsed(), 'a mouse on the tab opens it');
232
+ assert.ok(p.calls.some((c) => /^POST /.test(c)), 'and the draft opens the first time it is shown');
233
+ const drafts = p.calls.filter((c) => /^POST /.test(c)).length;
234
+ p.sheet.fire('pointerleave', { pointerType: 'mouse' });
235
+ assert.ok(p.collapsed(), 'leaving the panel for the page tucks it away');
236
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'mouse' });
237
+ await flush();
238
+ assert.equal(p.calls.filter((c) => /^POST /.test(c)).length, drafts, 'showing it again opens no second draft');
239
+ });
240
+
241
+ test('DOCKED: a touch never peeks, the tab click toggles, Escape and the X tuck it away and keep the tab', async () => {
242
+ const p = dockedPanel();
243
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'touch' });
244
+ assert.ok(p.collapsed(), 'a touch is not a hover');
245
+ p.press('data-pn-tab');
246
+ await flush();
247
+ assert.ok(!p.collapsed(), 'the tab click opens it (touch and keyboard)');
248
+ assert.equal(p.sheet.querySelector('[data-pn-tab]').getAttribute('aria-expanded'), 'true');
249
+ p.sheet.querySelector('[data-pn-close]').focus();
250
+ p.docKeys.forEach((fn) => fn({ key: 'Escape', preventDefault() {} }));
251
+ assert.ok(p.collapsed(), 'Escape inside the panel tucks it away');
252
+ assert.equal(p.sheet.querySelector('[data-pn-tab]').getAttribute('aria-expanded'), 'false');
253
+ p.press('data-pn-tab');
254
+ p.press('data-pn-close');
255
+ assert.ok(p.collapsed(), 'docked, the X tucks it away too');
256
+ assert.equal(p.sheet.parentNode, p.body, 'and leaves the tab on the page, the only way back in');
257
+ p.api.close();
258
+ assert.equal(p.sheet.parentNode, null, 'leaving Artistry (close) is what removes it');
259
+ });
260
+
261
+ test('THE MODE ROW names each mode in the mode switch word and starts a review on a press', () => {
262
+ const p = dockedPanel();
263
+ assert.match(p.sheet.innerHTML, /<span>Ideating<\/span>/, 'the switch says Ideating, so the row does');
264
+ assert.doesNotMatch(p.sheet.innerHTML, />Ideator</);
265
+ assert.doesNotMatch(p.sheet.innerHTML, /Stop reviewing/, 'no way out of a review that has not started');
266
+ p.press('data-pn-mode', 'building');
267
+ assert.ok(p.calls.includes('review building'));
268
+ assert.match(p.sheet.querySelector('#pn-modes').innerHTML, /data-pn-mode="building" aria-pressed="true"/, 'the row repaints with the live one pressed');
269
+ assert.match(p.sheet.querySelector('#pn-modes').innerHTML, /Stop reviewing/);
270
+ p.press('data-pn-mode', '');
271
+ assert.ok(p.calls.includes('stop'), 'Stop reviewing ends it');
272
+ });
273
+
274
+ test('OPENED, not docked (the public site Artist review button): the modal sheet it always was', async () => {
275
+ const p = dockedPanel({ opened: true });
276
+ await flush();
277
+ assert.equal(p.sheet.className, 'pn', 'no docked look: the scrim and full-viewport sheet stay');
278
+ assert.ok(p.html.classList.contains('pn-open'), 'the page behind is scroll-locked');
279
+ assert.match(p.sheet.innerHTML, /role="dialog" aria-modal="true"/);
280
+ assert.doesNotMatch(p.sheet.innerHTML, /data-pn-tab/, 'no tab');
281
+ assert.ok(p.calls.some((c) => /^POST /.test(c)), 'the draft opens straight away');
282
+ p.sheet.fire('pointerleave', { pointerType: 'mouse' });
283
+ assert.ok(!p.collapsed(), 'a pointer leaving does not tuck a modal sheet');
284
+ p.docKeys.forEach((fn) => fn({ key: 'Escape', preventDefault() {} }));
285
+ assert.equal(p.sheet.parentNode, null, 'Escape closes it');
286
+ assert.ok(!p.html.classList.contains('pn-open'), 'and lifts the scroll lock');
287
+ const q = dockedPanel({ opened: true });
288
+ const s = q.sheet;
289
+ s.fire('click', { target: s });
290
+ assert.equal(s.parentNode, null, 'a click on the scrim closes it');
291
+ });
@@ -0,0 +1,137 @@
1
+ // tests/settings_art_key_absent.mjs — the image-generation key card on a project
2
+ // with no art pipeline (task 1004786).
3
+ //
4
+ // The key routes (/me/art-key/*) left the core with the art-pipeline module, but
5
+ // the Settings card and the art_key need stayed. On cloudbongos.com — which has
6
+ // no art pipeline — every builder past Xenos was told "your free art coverage has
7
+ // ended, add your key", and the card the nudge pointed at could never load, so
8
+ // there was nowhere to add one. Two halves, both pinned here:
9
+ //
10
+ // - builder-needs: with the art pipeline absent (ctx.artPipeline === false) the
11
+ // art_key need is not raised at all — nothing to nag about, nothing to add;
12
+ // - settings.js: a 404 from GET /me/art-key/own means the routes are not
13
+ // installed, so the section stays hidden instead of showing a reload error.
14
+ // A real failure (500) still shows the error.
15
+ //
16
+ // Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
17
+ import { strict as assert } from 'node:assert';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
26
+ const ME = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
27
+ const needs = createRequire(import.meta.url)('../modules/builder-settings/builder-needs.js');
28
+
29
+ let passed = 0, failed = 0;
30
+ async function t(name, fn) {
31
+ try { await fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+
35
+ function makeEl() {
36
+ return {
37
+ hidden: false, textContent: '', value: '', disabled: false, href: '', className: '',
38
+ dataset: {}, style: {}, children: [], _html: '',
39
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
40
+ addEventListener() {}, appendChild(c) { return c; },
41
+ append() {}, prepend() {}, remove() {}, setAttribute() {}, removeAttribute() {}, getAttribute() { return null; },
42
+ querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
43
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
44
+ focus() {},
45
+ };
46
+ }
47
+
48
+ // Boot the real settings.js; GET /me/art-key/own answers with `artKeyStatus`.
49
+ async function boot(artKeyStatus) {
50
+ const els = new Map();
51
+ const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
52
+ // The markup ships both hidden (settings.html), so the stub starts them hidden.
53
+ $('#art-key').hidden = true;
54
+ $('#artkey-banner').hidden = true;
55
+ const builder = { id: 4, github_login: 'b4', rank: 'thetes', preferred_disciplines: ['engineer'] };
56
+ const me = { builder, needs: { items: [needs.artKeyNeed({ rank: 'thetes' })] } };
57
+ const api = {
58
+ async request(method, url) {
59
+ if (method === 'GET' && url === '/api/bongos/me') return { status: 200, ok: true, data: me };
60
+ if (method === 'GET' && url === '/api/bongos/me/art-key/own') {
61
+ return artKeyStatus === 200
62
+ ? { status: 200, ok: true, data: { set: false, storage_configured: true } }
63
+ : { status: artKeyStatus, ok: false, data: null };
64
+ }
65
+ return { status: 404, ok: false, data: null };
66
+ },
67
+ };
68
+ const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
69
+ const document = {
70
+ getElementById: (id) => $(`#${id}`), createElement: () => makeEl(),
71
+ dispatchEvent() {}, addEventListener() {}, querySelector: () => null, querySelectorAll: () => [],
72
+ };
73
+ const window = {
74
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
75
+ BongosClient: { createClient: () => api },
76
+ OTB: {
77
+ $, escapeHtml, toast() {}, disciplines: () => ['engineer'], roleLabel: (d) => d,
78
+ hubOrigin: () => null, settingsPanels: () => [],
79
+ },
80
+ OTBKit: { emptyStateHtml: (m) => m, makeSectionRail: () => ({ refresh() {}, activate() {} }) },
81
+ location: { hash: '', pathname: '/settings', search: '', assign() {} },
82
+ scrollTo() {}, confirm: () => false, addEventListener() {},
83
+ };
84
+ const sandbox = {
85
+ window, document, console: { log() {}, warn() {}, error() {}, info() {} },
86
+ history: { replaceState() {} }, URLSearchParams, setTimeout, clearTimeout,
87
+ };
88
+ sandbox.globalThis = sandbox;
89
+ vm.createContext(sandbox);
90
+ vm.runInContext(SRC, sandbox, { filename: 'settings.js' });
91
+ for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r));
92
+ return $;
93
+ }
94
+
95
+ console.log('\nthe art_key need without an art pipeline:');
96
+
97
+ await t('artPipeline: false raises no art_key need at any rank', () => {
98
+ for (const rank of ['xenos', 'thetes', 'metic', 'archon']) {
99
+ assert.equal(needs.artKeyNeed({ rank, artPipeline: false }), null, rank);
100
+ const r = needs.computeNeeds({ rank, artPipeline: false });
101
+ assert.ok(!r.items.some((n) => n.id === 'art_key'), rank);
102
+ }
103
+ });
104
+
105
+ await t('with the pipeline present (or unstated) the need is unchanged', () => {
106
+ assert.equal(needs.artKeyNeed({ rank: 'thetes', artPipeline: true }).state, 'action_needed');
107
+ assert.equal(needs.artKeyNeed({ rank: 'thetes' }).state, 'action_needed');
108
+ });
109
+
110
+ await t('/me tells computeNeeds whether the art pipeline is installed', () => {
111
+ const call = ME.slice(ME.indexOf('settings.computeNeeds({'), ME.indexOf('isFoundingOwner'));
112
+ assert.match(call, /artPipeline:/);
113
+ });
114
+
115
+ console.log('\nthe Settings card:');
116
+
117
+ await t('routes not installed (404) → the section stays hidden, no reload error', async () => {
118
+ const $ = await boot(404);
119
+ assert.equal($('#art-key').hidden, true);
120
+ assert.equal($('#artkey-banner').hidden, true);
121
+ assert.doesNotMatch($('#artkey-card').innerHTML, /could not be loaded/);
122
+ });
123
+
124
+ await t('a real failure (500) still shows the error', async () => {
125
+ const $ = await boot(500);
126
+ assert.equal($('#art-key').hidden, false);
127
+ assert.match($('#artkey-card').innerHTML, /could not be loaded/);
128
+ });
129
+
130
+ await t('routes present → the key box renders', async () => {
131
+ const $ = await boot(200);
132
+ assert.equal($('#art-key').hidden, false);
133
+ assert.match($('#artkey-card').innerHTML, /id="artkey-input"/);
134
+ });
135
+
136
+ console.log(`\n${passed} passed, ${failed} failed`);
137
+ process.exit(failed ? 1 : 0);