@bongos/core 1.24.75 → 1.24.77
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 +101 -41
- package/.claude/skills/blocker-review/SKILL.md +10 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +30 -0
- package/docs/api/openapi.json +5 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +43 -35
- package/docs/copy-registry.json +129 -57
- package/docs/module-api-changelog.md +4 -2
- package/docs/packs/engineer.md +2 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +8 -8
- package/modules/autonomy/gauge.js +25 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +56 -5
- package/modules/hall-ui/public/home-city.js +7 -2
- package/modules/hall-ui/public/home-idea-box.css +33 -0
- package/modules/hall-ui/public/home-idea-box.js +106 -0
- package/modules/hall-ui/public/index.html +2 -0
- package/modules/hall-ui/public/project-settings.css +15 -0
- package/modules/hall-ui/records/home-city.md +21 -0
- package/modules/ideas/routes/blockers.js +47 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +19 -1
- package/scripts/bongos/autobongos-bootstrap.ps1 +169 -0
- package/scripts/bongos/autobongos-bootstrap.sh +151 -0
- package/scripts/bongos/autobongos-host.js +869 -0
- package/scripts/bongos/autobongos-run.js +7 -1
- package/scripts/bongos/autonomy-gauge.js +11 -3
- package/scripts/bongos/blocker-recheck.js +111 -0
- package/scripts/bongos/cli-lib.js +5 -1
- package/scripts/bongos/government-walk.js +1 -1
- package/scripts/gds/autobongos-bootstrap.ps1 +8 -0
- package/scripts/gds/autobongos-bootstrap.sh +8 -0
- package/scripts/gds/autobongos-host.js +7 -0
- package/scripts/gds/blocker-recheck.js +7 -0
- package/scripts/hall-preview/server.js +1 -0
- package/src/instance-config.js +22 -3
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +619 -0
- package/tests/autobongos_loop.mjs +11 -0
- package/tests/autonomy_gauge.mjs +43 -0
- package/tests/cli_sessions.mjs +16 -0
- package/tests/deploy_page_projects.mjs +49 -0
- package/tests/government_agent_asks.mjs +92 -2
- package/tests/home_idea_box.mjs +178 -0
package/tests/autonomy_gauge.mjs
CHANGED
|
@@ -343,6 +343,49 @@ test('evaluate on a missing file is UNKNOWN — the CLI never defaults to go', (
|
|
|
343
343
|
});
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
+
// ── a device with no gauge at all (task 1005095) ────────────────────────────────
|
|
347
|
+
test('a caller that asks for it gets GO on a device with NO gauge file, marked as that kind of go', () => {
|
|
348
|
+
const d = decideCapacity({ gauge: null, now: NOW, config: { absentMeansGo: true } });
|
|
349
|
+
assert.equal(d.go, true, 'a PC set up five minutes ago has no gauge and never will; it must still start work');
|
|
350
|
+
assert.equal(d.unknown, false);
|
|
351
|
+
assert.equal(d.absent, true);
|
|
352
|
+
assert.equal(d.derived, true, 'so the log never mistakes it for a measured reading');
|
|
353
|
+
assert.match(d.reason, /no usage gauge on this device/);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('absentMeansGo opens NOTHING but the missing file — every reading that cannot be vouched for still holds', () => {
|
|
357
|
+
for (const [label, g] of [
|
|
358
|
+
['not an object', 'nonsense'],
|
|
359
|
+
['no written_at', { five_hour: { used_percentage: 1, resets_at: NOW + HOUR } }],
|
|
360
|
+
['neither window', { written_at: NOW }],
|
|
361
|
+
['unparseable numbers', { written_at: NOW, five_hour: { used_percentage: 'lots', resets_at: 'soon' } }],
|
|
362
|
+
['dated in the future', { ...gaugeAt(0), written_at: NOW + 600 }],
|
|
363
|
+
['aged out inside a window still running', gaugeAt(4 * HOUR)],
|
|
364
|
+
]) {
|
|
365
|
+
const d = decideCapacity({ gauge: g, now: NOW, config: { absentMeansGo: true } });
|
|
366
|
+
assert.equal(d.go, false, `${label} must still hold`);
|
|
367
|
+
assert.equal(d.unknown, true, `${label} must still read as unknown`);
|
|
368
|
+
}
|
|
369
|
+
// …and a real reading that says stop is obeyed exactly as before.
|
|
370
|
+
const spent = decideCapacity({ gauge: gaugeAt(30, { five_hour: { used_percentage: 99, resets_at: NOW + HOUR } }), now: NOW, config: { absentMeansGo: true } });
|
|
371
|
+
assert.equal(spent.go, false);
|
|
372
|
+
assert.equal(spent.unknown, false);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('the supervisor passes absentMeansGo through evaluate; the bare CLI call does not', () => {
|
|
376
|
+
inTempGauge(() => {
|
|
377
|
+
const now = Math.floor(Date.now() / 1000);
|
|
378
|
+
assert.equal(cli.evaluate(now, { absentMeansGo: true }).decision.go, true);
|
|
379
|
+
assert.equal(cli.evaluate(now).decision.go, false);
|
|
380
|
+
// A file that is THERE but torn is a bad reading, not a missing gauge.
|
|
381
|
+
fs.mkdirSync(path.dirname(cli.gaugePath()), { recursive: true });
|
|
382
|
+
fs.writeFileSync(cli.gaugePath(), '{"written_at": 17');
|
|
383
|
+
const torn = cli.evaluate(now, { absentMeansGo: true }).decision;
|
|
384
|
+
assert.equal(torn.go, false, 'a half-written gauge file must hold, even for the supervisor');
|
|
385
|
+
assert.equal(torn.unknown, true);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
346
389
|
// --- a reading whose window has RESET is retired, not stale (task 1004178) ---
|
|
347
390
|
//
|
|
348
391
|
// An unattended host cannot refresh the gauge (see modules/autonomy/CLAUDE.md
|
package/tests/cli_sessions.mjs
CHANGED
|
@@ -142,6 +142,22 @@ test('the store anchor is FIXED, not brand-derived — that is the whole fix', (
|
|
|
142
142
|
assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test('BONGOS_CONFIG_ROOT moves the store with it, still at the fixed dir name (task 1005095)', () => {
|
|
146
|
+
// An Autobongos runner keeps its own login under its own root. The store must
|
|
147
|
+
// follow that root, or the runner would file its login into the builder's store.
|
|
148
|
+
const root = path.join(os.tmpdir(), 'cfg-root-probe');
|
|
149
|
+
const prev = process.env.BONGOS_CONFIG_ROOT;
|
|
150
|
+
process.env.BONGOS_CONFIG_ROOT = root;
|
|
151
|
+
try {
|
|
152
|
+
assert.equal(lib.sessionStoreDir(), path.join(path.resolve(root), ic.FALLBACK_DIR, 'instances'));
|
|
153
|
+
assert.equal(ic.configHome(), path.join(path.resolve(root), ic.configDirName()));
|
|
154
|
+
assert.ok(ic.configReadDirs().every((d) => d.startsWith(path.resolve(root) + path.sep)), 'no legacy dir under the home is read as a fallback');
|
|
155
|
+
} finally {
|
|
156
|
+
if (prev === undefined) delete process.env.BONGOS_CONFIG_ROOT; else process.env.BONGOS_CONFIG_ROOT = prev;
|
|
157
|
+
}
|
|
158
|
+
assert.equal(lib.sessionStoreDir(), path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances'), 'and back where it was without it');
|
|
159
|
+
});
|
|
160
|
+
|
|
145
161
|
// ── 1b. The sandbox is really a sandbox ─────────────────────────────────────────────────────
|
|
146
162
|
//
|
|
147
163
|
// These guard the guard (task 1003760). Every test below writes to a session store, so if
|
|
@@ -238,6 +238,55 @@ test('a box with nothing newer offers no button', async () => {
|
|
|
238
238
|
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null }) });
|
|
239
239
|
await b.settle();
|
|
240
240
|
assert.match(b.panel(), /There is nothing to deploy/);
|
|
241
|
+
assert.doesNotMatch(b.panel(), /id="dp-preview-1"/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---- the plain status line and the deploy-now button (task 1005012) ----------------
|
|
245
|
+
//
|
|
246
|
+
// The owner opened this page to put merged work live and found neither a button nor a
|
|
247
|
+
// sentence: the box was already on the newest version, which it had reached by itself on
|
|
248
|
+
// its update rule, and the page said only "nothing to deploy". So the box now LEADS with
|
|
249
|
+
// one plain line — up to date or not — and says how the next version arrives.
|
|
250
|
+
|
|
251
|
+
test('up to date: says so in words, names the version, and says the next one arrives on its own', async () => {
|
|
252
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null, served_version: '1.24.1', update_channel: 'minor' }) });
|
|
253
|
+
await b.settle();
|
|
254
|
+
const html = b.panel();
|
|
255
|
+
assert.match(html, /dp-status dp-status--ok/, 'a status line, in the ok tone');
|
|
256
|
+
assert.match(html, /<strong>Up to date\.<\/strong>/);
|
|
257
|
+
assert.match(html, /1\.24\.1/);
|
|
258
|
+
assert.match(html, /merged work inside core 1\.24\.1 is live/);
|
|
259
|
+
assert.match(html, /arrives on its own/, 'the update rule moves it, so nothing needs pressing');
|
|
260
|
+
assert.match(html, /not live until a published version carries it/, 'and merged work after it is still waiting');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('up to date on Stay put: says a newer version waits for a person, not that it arrives', async () => {
|
|
264
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null, update_channel: 'pinned' }) });
|
|
265
|
+
await b.settle();
|
|
266
|
+
const html = b.panel();
|
|
267
|
+
assert.match(html, /<strong>Up to date\.<\/strong>/);
|
|
268
|
+
assert.doesNotMatch(html, /arrives on its own/);
|
|
269
|
+
assert.match(html, /waits until you change the update rule/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('behind: a plain "not up to date" line and ONE primary deploy-now button', async () => {
|
|
273
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch' }) });
|
|
274
|
+
await b.settle();
|
|
275
|
+
const html = b.panel();
|
|
276
|
+
assert.match(html, /dp-status dp-status--behind/);
|
|
277
|
+
assert.match(html, /<strong>Not up to date\.<\/strong> Core <strong>1\.19\.871<\/strong> is published; this project still runs 1\.19\.800\./);
|
|
278
|
+
assert.match(html, /class="btn btn--primary" id="dp-preview-1">Deploy core 1\.19\.871 now<\/button>/);
|
|
279
|
+
assert.doesNotMatch(html, /See what would change/, 'the old two-word step label is gone');
|
|
280
|
+
assert.equal((html.match(/btn--primary/g) || []).length, 1, 'exactly one primary action');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the deploy-now button still leaves going live to a person: it opens the confirm, it moves nothing', async () => {
|
|
284
|
+
const b = boot();
|
|
285
|
+
await b.settle();
|
|
286
|
+
b.els.get('dp-preview-1')._on.click();
|
|
287
|
+
await b.settle();
|
|
288
|
+
assert.ok(b.posts.every((p) => p.body && p.body.dry_run === true), 'only the dry-run check is asked for');
|
|
289
|
+
assert.match(b.panel(), /id="dp-confirm-1"/, 'the person confirms the move themselves');
|
|
241
290
|
});
|
|
242
291
|
|
|
243
292
|
test('a box that did not answer is never rendered as up to date', async () => {
|
|
@@ -169,6 +169,8 @@ async function call(method, p, body, source) {
|
|
|
169
169
|
let json = null; try { json = await res.json(); } catch { /* none */ }
|
|
170
170
|
return { ok: res.ok, status: res.status, data: json };
|
|
171
171
|
}
|
|
172
|
+
// What an agent opened or ran that showed a person is needed (task 1005120).
|
|
173
|
+
const CHECKED = 'Opened the Plaid dashboard OAuth page: the Chase registration form is unsubmitted and needs the owner’s login.';
|
|
172
174
|
// The helper's client: the CLI's request shape, against these routers.
|
|
173
175
|
const client = { request: (method, p, opts = {}) => call(method, p, opts.body, 'cli') };
|
|
174
176
|
|
|
@@ -209,8 +211,8 @@ test('an agent that files prose is refused, rewrites with the helper, and files
|
|
|
209
211
|
assert.match(passed.lines[0], /^✓ This ask has the shape\. As a person reads it:$/);
|
|
210
212
|
assert.equal(passed.lines.slice(2).join('\n'), asks.cardText(OVERRIDE_CARD));
|
|
211
213
|
|
|
212
|
-
// 5. and files, stored against the blocker
|
|
213
|
-
const ok = await call('POST', '/blockers', rewrite, 'cli');
|
|
214
|
+
// 5. and files, stored against the blocker (with what the session checked: task 1005120)
|
|
215
|
+
const ok = await call('POST', '/blockers', { ...rewrite, checked: CHECKED }, 'cli');
|
|
214
216
|
assert.equal(ok.status, 201, JSON.stringify(ok.data));
|
|
215
217
|
assert.equal(store.get(`blockers:${ok.data.blocker.id}`).question, OVERRIDE_CARD.question);
|
|
216
218
|
assert.equal(store.get(`blockers:${ok.data.blocker.id}`).detail, OVERRIDE.detail.trim(), 'the prose rides along');
|
|
@@ -231,6 +233,94 @@ test('every source an agent files with is held to it; the hall and the templated
|
|
|
231
233
|
}
|
|
232
234
|
});
|
|
233
235
|
|
|
236
|
+
// ── A HIGHER BAR FOR FILING, AND A RE-CHECK BEFORE ASKING AGAIN (task 1005120) ──
|
|
237
|
+
//
|
|
238
|
+
// A project's blocker asked its owner to "apply to Plaid for OAuth" for days after the
|
|
239
|
+
// project's own shipped work had found there was nothing to apply for. It was filed on an
|
|
240
|
+
// inference (one bank worked, one did not) when the page that settled it was one click
|
|
241
|
+
// away. So an agent filing by hand now says what it CHECKED that showed a person is
|
|
242
|
+
// needed, and that sentence rides in the blocker's body where every later reader sees it.
|
|
243
|
+
|
|
244
|
+
test('an agent files a blocker only with what it checked; the check rides in the body', async () => {
|
|
245
|
+
const before = filed.length;
|
|
246
|
+
const card = { title: OVERRIDE.title, source: 'task', source_ref: '1005120-a', ask: OVERRIDE_CARD };
|
|
247
|
+
|
|
248
|
+
// no check at all: refused, nothing filed, and told what to send
|
|
249
|
+
const bare = await call('POST', '/blockers', card, 'cli');
|
|
250
|
+
assert.equal(bare.status, 400);
|
|
251
|
+
assert.equal(bare.data.error.code, 'checked_required');
|
|
252
|
+
assert.match(bare.data.error.message, /what you opened or ran/);
|
|
253
|
+
assert.equal(filed.length, before, 'nothing was filed');
|
|
254
|
+
|
|
255
|
+
// a hunch is not a check: too short to name what was looked at
|
|
256
|
+
const hunch = await call('POST', '/blockers', { ...card, checked: 'probably needed' }, 'cli');
|
|
257
|
+
assert.equal(hunch.status, 400);
|
|
258
|
+
assert.equal(hunch.data.error.code, 'checked_required');
|
|
259
|
+
assert.equal(filed.length, before, 'nothing was filed');
|
|
260
|
+
|
|
261
|
+
// with the check: filed, and the check is written into the blocker for its next reader
|
|
262
|
+
const ok = await call('POST', '/blockers', { ...card, body_md: 'Long detail.', checked: CHECKED }, 'cli');
|
|
263
|
+
assert.equal(ok.status, 201, JSON.stringify(ok.data));
|
|
264
|
+
assert.match(ok.data.blocker.body_md, /^Long detail\.\n\n\*\*Checked before filing \(by @someone\):\*\* Opened the Plaid dashboard/,
|
|
265
|
+
'the check rides in the body, tracked back to the filer');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('the check floor is exactly 30 characters, trimmed', async () => {
|
|
269
|
+
const card = { title: OVERRIDE.title, source: 'task', ask: OVERRIDE_CARD };
|
|
270
|
+
const short = await call('POST', '/blockers', { ...card, source_ref: '1005120-29', checked: ` ${'x'.repeat(29)} ` }, 'cli');
|
|
271
|
+
assert.equal(short.status, 400, '29 characters (padding trimmed) is below the floor');
|
|
272
|
+
assert.equal(short.data.error.code, 'checked_required');
|
|
273
|
+
assert.equal(short.data.error.details.min_length, 30);
|
|
274
|
+
const exact = await call('POST', '/blockers', { ...card, source_ref: '1005120-30', checked: 'x'.repeat(30) }, 'cli');
|
|
275
|
+
assert.equal(exact.status, 201, '30 characters is enough');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('a body that the check line pushes past the cap is refused, not silently grown', async () => {
|
|
279
|
+
const before = filed.length;
|
|
280
|
+
const r = await call('POST', '/blockers', { title: OVERRIDE.title, source: 'task', source_ref: '1005120-long', ask: OVERRIDE_CARD,
|
|
281
|
+
body_md: 'y'.repeat(api.LIMITS.DESCRIPTION), checked: CHECKED }, 'cli');
|
|
282
|
+
assert.equal(r.status, 400);
|
|
283
|
+
assert.equal(r.data.error.code, 'body_too_long');
|
|
284
|
+
assert.equal(filed.length, before, 'nothing was filed');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('the hall and the templated scripts are not held to the check', async () => {
|
|
288
|
+
assert.equal((await call('POST', '/blockers', { title: 'A person typed this' }, 'web')).status, 201);
|
|
289
|
+
for (const source of blockersRoutes.TEMPLATED_SOURCES) {
|
|
290
|
+
const r = await call('POST', '/blockers', { title: `Checked-free ${source}`, source, source_ref: `c-${source}` }, 'cli');
|
|
291
|
+
assert.equal(r.status, 201, source);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// The re-check the daily review runs (scripts/bongos/blocker-recheck.js): the shipped work
|
|
296
|
+
// since a blocker was filed that shares its subject — the case that went unread for days.
|
|
297
|
+
const recheck = require(path.join(ROOT, 'scripts', 'bongos', 'blocker-recheck.js'));
|
|
298
|
+
const PLAID = { id: '1', title: 'Chase and Bank of America need OAuth registration on toast\'s Plaid account',
|
|
299
|
+
body_md: 'Apply to Plaid for OAuth; approval takes weeks.', created_at: '2026-11-06T10:00:00Z' };
|
|
300
|
+
|
|
301
|
+
test('the re-check finds the shipped work that answered a blocker, and only work after it', () => {
|
|
302
|
+
const tasks = [
|
|
303
|
+
{ id: 7, short_name: 'plaid oauth check', status: 'shipped', shipped_at: '2026-11-07T12:00:00Z',
|
|
304
|
+
title: 'Check Plaid OAuth access for Chase', value_summary: 'Plaid already granted OAuth; Chase works, Bank of America needs one settings field.' },
|
|
305
|
+
{ id: 5, status: 'shipped', shipped_at: '2026-11-05T12:00:00Z', title: 'Plaid OAuth groundwork for Chase', value_summary: '' },
|
|
306
|
+
{ id: 8, status: 'active', shipped_at: null, title: 'Plaid OAuth for Chase', value_summary: '' },
|
|
307
|
+
{ id: 9, status: 'shipped', shipped_at: '2026-11-08T12:00:00Z', title: 'Landing page spacing', value_summary: 'Tighter hero.' },
|
|
308
|
+
];
|
|
309
|
+
const hits = recheck.relatedShipped(PLAID, tasks);
|
|
310
|
+
assert.deepEqual(hits.map((h) => h.id), [7], 'shipped after filing, on the subject; not before, not unshipped, not unrelated');
|
|
311
|
+
assert.ok(hits[0].shared.includes('plaid') && hits[0].shared.includes('oauth'), 'it says which words matched');
|
|
312
|
+
const lines = recheck.reportLines(PLAID, hits).join('\n');
|
|
313
|
+
assert.match(lines, /task 7 - plaid oauth check/, 'named as task N - name, never a bare number');
|
|
314
|
+
assert.match(lines, /read these before asking again/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('one shared word is not enough, and no match never claims the blocker still holds', () => {
|
|
318
|
+
const hits = recheck.relatedShipped(PLAID, [{ id: 3, status: 'shipped', shipped_at: '2026-11-08T00:00:00Z', title: 'Plaid logo swap', value_summary: '' }]);
|
|
319
|
+
assert.deepEqual(hits, []);
|
|
320
|
+
assert.match(recheck.reportLines(PLAID, hits).join('\n'), /not proof it still holds/);
|
|
321
|
+
assert.deepEqual(recheck.relatedShipped({ ...PLAID, created_at: 'not a date' }, []), [], 'an unreadable filing date re-checks nothing');
|
|
322
|
+
});
|
|
323
|
+
|
|
234
324
|
test('the helper checks locally, and says so, when the server cannot answer', async () => {
|
|
235
325
|
const offline = { request: async () => { throw new Error('ECONNREFUSED'); } };
|
|
236
326
|
const r = await askCli.check(writeCard('slop2.json', { question: 'Can you look at the deploy and tell me what you think about it all?' }), { client: offline });
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// tests/home_idea_box.mjs — the ideator's Home opens on the idea box (task 1005078,
|
|
2
|
+
// goal 1000095, the owner's work-first hall).
|
|
3
|
+
//
|
|
4
|
+
// Runs modules/hall-ui/public/home-idea-box.js with the city's three files in a real
|
|
5
|
+
// page, the reads stubbed by the hall-preview fixture: the box shows only in Ideating
|
|
6
|
+
// and sits above the sky; it is ONE box and a button (owner, 2026-11-05); filing is
|
|
7
|
+
// POST /inbox with { title, body_md: '', kind: 'feature' }; an empty line files
|
|
8
|
+
// nothing; a refused filing keeps the words and says why; a filed one clears and links
|
|
9
|
+
// to Your thinking; the box shows in Ideating even when the sky could not be read; at
|
|
10
|
+
// phone width nothing scrolls sideways. The pure halves run in node. Skips the browser
|
|
11
|
+
// cases where no system Edge or Chrome is installed.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --test tests/home_idea_box.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test, after } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
24
|
+
const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
|
|
25
|
+
const Box = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'home-idea-box.js'));
|
|
26
|
+
const CSS = pub('style.css') + pub('home-city.css') + pub('home-idea-box.css');
|
|
27
|
+
const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
|
|
28
|
+
const FILED = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'inbox.post.json'), 'utf8');
|
|
29
|
+
|
|
30
|
+
let browser = null;
|
|
31
|
+
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
32
|
+
after(() => browser && browser.close());
|
|
33
|
+
const skip = !browser && 'no system Edge or Chrome';
|
|
34
|
+
|
|
35
|
+
const stubs = (craft, { city = true, refuse = false } = {}) => `
|
|
36
|
+
window.__asked = [];
|
|
37
|
+
window.BongosClient = { createClient: () => ({ request: async (m, p, o) => {
|
|
38
|
+
window.__asked.push(m + ' ' + p + (o && o.body ? ' ' + JSON.stringify(o.body) : ''));
|
|
39
|
+
if (p.startsWith('/api/bongos/home/city')) return ${city} ? { ok: true, status: 200, data: ${CITY} } : { ok: false, status: 500, data: null };
|
|
40
|
+
if (m === 'POST' && p === '/api/bongos/inbox') return ${refuse}
|
|
41
|
+
? { ok: false, status: 422, data: { error: { code: 'idea_rejected', details: { reason: 'the title is a duplicate' } } } }
|
|
42
|
+
: { ok: true, status: 201, data: ${FILED} };
|
|
43
|
+
return { ok: false, status: 404, data: null };
|
|
44
|
+
} }) };
|
|
45
|
+
window.OTBCraftMode = { iconHtml: () => '', choose: () => {}, current: () => document.documentElement.dataset.craft || null };
|
|
46
|
+
window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
|
|
47
|
+
|
|
48
|
+
async function boot({ craft = 'ideating', width = 1440, ...opts } = {}) {
|
|
49
|
+
const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
|
|
50
|
+
const page = await ctx.newPage();
|
|
51
|
+
await page.setContent(`<!doctype html><html data-theme="dark"${craft ? ` data-craft="${craft}"` : ''}><head><style>${CSS}</style></head><body><section id="welcome-scroll" class="scroll">setup guide</section><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
|
|
52
|
+
await page.addScriptTag({ content: stubs(craft, opts) });
|
|
53
|
+
for (const f of ['home-idea-box.js', 'city-draw.js', 'home-city-draw.js', 'home-city.js']) await page.addScriptTag({ content: pub(f) });
|
|
54
|
+
await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
|
|
55
|
+
await page.waitForFunction(() => window.__asked.some((a) => a.includes('/home/city')));
|
|
56
|
+
return { ctx, page };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('the pure halves: the first line is the title and the rest the details; a refusal says why', () => {
|
|
60
|
+
assert.deepEqual(Box.body(' a new idea '), { title: 'a new idea', body_md: '', kind: 'feature' });
|
|
61
|
+
assert.deepEqual(Box.body('\n the title\r\nline two\n\nline three\n'), { title: 'the title', body_md: 'line two\n\nline three', kind: 'feature' });
|
|
62
|
+
assert.deepEqual(Box.body(' \n '), { title: '', body_md: '', kind: 'feature' });
|
|
63
|
+
assert.match(Box.explain({ status: 422, data: { error: { details: { reason: 'too short' } } } }), /^Not filed: too short\. Your idea is still here\.$/);
|
|
64
|
+
assert.match(Box.explain({ status: 401, data: null }), /session may have expired/);
|
|
65
|
+
assert.match(Box.explain(null), /Nothing was saved/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('in Ideating the box is the first thing in the section, ONE box and a button, and the setup guide is hidden', { skip }, async () => {
|
|
69
|
+
const { ctx, page } = await boot();
|
|
70
|
+
await page.waitForFunction(() => document.querySelector('.hc-svg').childElementCount > 0);
|
|
71
|
+
const s = await page.evaluate(() => {
|
|
72
|
+
const host = document.getElementById('home-city'), box = host.querySelector('.hib');
|
|
73
|
+
return {
|
|
74
|
+
first: host.firstElementChild === box, shown: !box.hidden && !host.hidden,
|
|
75
|
+
fields: box.querySelectorAll('input, textarea, select').length,
|
|
76
|
+
keys: document.getElementById(box.querySelector('.hib-title').getAttribute('aria-describedby')).textContent,
|
|
77
|
+
buttons: [...box.querySelectorAll('button')].map((b) => b.textContent),
|
|
78
|
+
label: box.querySelector('label[for="hib-title"]').textContent,
|
|
79
|
+
boxAboveSky: box.getBoundingClientRect().bottom <= host.querySelector('.hc-svg').getBoundingClientRect().top,
|
|
80
|
+
guide: getComputedStyle(document.getElementById('welcome-scroll')).display,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
await ctx.close();
|
|
84
|
+
assert.equal(s.first, true);
|
|
85
|
+
assert.equal(s.shown, true);
|
|
86
|
+
assert.equal(s.fields, 1, 'one box (owner, 2026-11-05)');
|
|
87
|
+
assert.equal(s.keys, 'Enter files the idea. Shift and Enter starts a new line.');
|
|
88
|
+
assert.deepEqual(s.buttons, ['File idea']);
|
|
89
|
+
assert.equal(s.label, 'What is your idea?');
|
|
90
|
+
assert.equal(s.boxAboveSky, true);
|
|
91
|
+
assert.equal(s.guide, 'none');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('in any other craft, or none, there is no box and the setup guide shows', { skip }, async () => {
|
|
95
|
+
for (const craft of ['building', null]) {
|
|
96
|
+
const { ctx, page } = await boot({ craft });
|
|
97
|
+
const s = await page.evaluate(() => ({ hidden: document.querySelector('.hib').hidden, guide: getComputedStyle(document.getElementById('welcome-scroll')).display }));
|
|
98
|
+
await ctx.close();
|
|
99
|
+
assert.equal(s.hidden, true, String(craft));
|
|
100
|
+
assert.equal(s.guide, 'block', String(craft));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('filing posts the line as a feature idea, clears the box and links to Your thinking; an empty line files nothing', { skip }, async () => {
|
|
105
|
+
const { ctx, page } = await boot();
|
|
106
|
+
await page.click('.hib-file');
|
|
107
|
+
const empty = await page.evaluate(() => ({ said: document.querySelector('.hib-said').textContent, posts: window.__asked.filter((a) => a.startsWith('POST')).length }));
|
|
108
|
+
await page.fill('.hib-title', ' A place to type an idea ');
|
|
109
|
+
await page.press('.hib-title', 'Enter');
|
|
110
|
+
await page.waitForFunction(() => document.querySelector('.hib-said a'));
|
|
111
|
+
const s = await page.evaluate(() => ({
|
|
112
|
+
posts: window.__asked.filter((a) => a.startsWith('POST')),
|
|
113
|
+
value: document.querySelector('.hib-title').value,
|
|
114
|
+
said: document.querySelector('.hib-said').textContent,
|
|
115
|
+
href: document.querySelector('.hib-said a').getAttribute('href'),
|
|
116
|
+
err: document.querySelector('.hib-said').classList.contains('hib-said--err'),
|
|
117
|
+
}));
|
|
118
|
+
await ctx.close();
|
|
119
|
+
assert.equal(empty.posts, 0);
|
|
120
|
+
assert.equal(empty.said, 'Type your idea first.');
|
|
121
|
+
assert.deepEqual(s.posts, ['POST /api/bongos/inbox {"title":"A place to type an idea","body_md":"","kind":"feature"}']);
|
|
122
|
+
assert.equal(s.value, '');
|
|
123
|
+
assert.match(s.said, /^Filed\. It waits for the next triage\. See it in Your thinking$/);
|
|
124
|
+
assert.equal(s.href, '/thinking');
|
|
125
|
+
assert.equal(s.err, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('Shift+Enter is a new line and the box grows; Enter then files the first line as the title, the rest as details', { skip }, async () => {
|
|
129
|
+
const { ctx, page } = await boot();
|
|
130
|
+
await page.click('.hib-title');
|
|
131
|
+
const h0 = await page.evaluate(() => document.querySelector('.hib-title').getBoundingClientRect().height);
|
|
132
|
+
await page.keyboard.type('The title');
|
|
133
|
+
await page.keyboard.press('Shift+Enter');
|
|
134
|
+
await page.keyboard.type('more about it');
|
|
135
|
+
await page.keyboard.press('Shift+Enter');
|
|
136
|
+
await page.keyboard.type('and more');
|
|
137
|
+
const before = await page.evaluate(() => ({ value: document.querySelector('.hib-title').value, h: document.querySelector('.hib-title').getBoundingClientRect().height, posts: window.__asked.filter((a) => a.startsWith('POST')).length }));
|
|
138
|
+
await page.keyboard.press('Enter');
|
|
139
|
+
await page.waitForFunction(() => document.querySelector('.hib-said a'));
|
|
140
|
+
const after = await page.evaluate(() => ({ posts: window.__asked.filter((a) => a.startsWith('POST')), h: document.querySelector('.hib-title').getBoundingClientRect().height }));
|
|
141
|
+
await ctx.close();
|
|
142
|
+
assert.equal(before.value, 'The title\nmore about it\nand more');
|
|
143
|
+
assert.equal(before.posts, 0, 'Shift+Enter never files');
|
|
144
|
+
assert.ok(before.h > h0, `the box grew (${h0} -> ${before.h})`);
|
|
145
|
+
assert.deepEqual(after.posts, ['POST /api/bongos/inbox {"title":"The title","body_md":"more about it\\nand more","kind":"feature"}']);
|
|
146
|
+
assert.equal(after.h, h0, 'and shrinks back once filed');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a refused filing keeps the words and says why', { skip }, async () => {
|
|
150
|
+
const { ctx, page } = await boot({ refuse: true });
|
|
151
|
+
await page.fill('.hib-title', 'A duplicate');
|
|
152
|
+
await page.click('.hib-file');
|
|
153
|
+
await page.waitForFunction(() => document.querySelector('.hib-said').textContent);
|
|
154
|
+
const s = await page.evaluate(() => ({ value: document.querySelector('.hib-title').value, said: document.querySelector('.hib-said').textContent, err: document.querySelector('.hib-said').classList.contains('hib-said--err'), link: !!document.querySelector('.hib-said a') }));
|
|
155
|
+
await ctx.close();
|
|
156
|
+
assert.equal(s.value, 'A duplicate');
|
|
157
|
+
assert.equal(s.said, 'Not filed: the title is a duplicate. Your idea is still here.');
|
|
158
|
+
assert.equal(s.err, true);
|
|
159
|
+
assert.equal(s.link, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the box shows in Ideating even when the sky could not be read', { skip }, async () => {
|
|
163
|
+
const { ctx, page } = await boot({ city: false });
|
|
164
|
+
const s = await page.evaluate(() => ({ host: document.getElementById('home-city').hidden, box: document.querySelector('.hib').hidden }));
|
|
165
|
+
await ctx.close();
|
|
166
|
+
assert.deepEqual(s, { host: false, box: false });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('at phone width the box stacks, every control clears 24px, and nothing scrolls sideways', { skip }, async () => {
|
|
170
|
+
const { ctx, page } = await boot({ width: 320 });
|
|
171
|
+
const s = await page.evaluate(() => ({
|
|
172
|
+
over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
173
|
+
sizes: [...document.querySelectorAll('.hib-title, .hib-file')].map((e) => { const r = e.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; }),
|
|
174
|
+
}));
|
|
175
|
+
await ctx.close();
|
|
176
|
+
assert.equal(s.over, 0);
|
|
177
|
+
for (const [w, h] of s.sizes) assert.ok(w >= 24 && h >= 24, `${w}x${h}`);
|
|
178
|
+
});
|