@bongos/core 1.24.74 → 1.24.76
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 +77 -42
- package/.claude/skills/blocker-review/SKILL.md +10 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +6 -2
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +55 -47
- package/docs/copy-registry.json +143 -71
- package/docs/module-api-changelog.md +2 -0
- package/docs/packs/engineer.md +2 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +10 -10
- package/modules/government/board.js +10 -4
- package/modules/government/db.js +7 -5
- package/modules/government/genesis-close.js +45 -0
- package/modules/government/migrations/government_038_vote_cast_via.sql +23 -0
- package/modules/government/routes/government.js +4 -5
- package/modules/hall-ui/public/board-room.js +1 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +56 -5
- package/modules/hall-ui/public/genesis-home.js +7 -12
- 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/modules/onboarding/genesis-home.js +65 -8
- package/modules/onboarding/routes/onboarding.js +15 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/blocker-recheck.js +111 -0
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/gds/blocker-recheck.js +7 -0
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/deploy_page_projects.mjs +49 -0
- package/tests/genesis_home.mjs +86 -1
- package/tests/government_agent_asks.mjs +92 -2
- package/tests/government_board_genesis_stage.mjs +83 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/home_idea_box.mjs +178 -0
|
@@ -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 });
|
|
@@ -303,3 +303,86 @@ test('the port carries the open and the status; the status route is pinned on th
|
|
|
303
303
|
const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
|
|
304
304
|
assert.match(pins, /'GET \/government\/board\/genesis-stages': 'perm:board\.vote\.cast'/);
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
// ── task 1005121: closing a stage is ONE call ────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const { closeGenesisStage } = require(path.join(GOV, 'genesis-close.js'));
|
|
310
|
+
|
|
311
|
+
test('task 1005121: under Monarchy / BDFL closeGenesisStage opens, confirms and CLOSES in one call, and records HOW', async () => {
|
|
312
|
+
const votes = [];
|
|
313
|
+
let karma = 0;
|
|
314
|
+
const passed = await capturing('board.genesis_stage.passed', () => withDb({
|
|
315
|
+
getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
|
|
316
|
+
openBoardItem: async (args) => ({ ...stageItem(), constitution: MONARCHY, opened_by: args.openedBy }),
|
|
317
|
+
getBoardItemForVote: async () => stageItem(),
|
|
318
|
+
builderHoldsRankActive: async (id) => id === FOUNDER,
|
|
319
|
+
getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder')],
|
|
320
|
+
getBoardItemAuthor: async (item) => item.opened_by,
|
|
321
|
+
insertBoardVote: async (row) => { const v = { ...row, voter_id: row.voterId, cast_via: row.castVia }; votes.push(v); return v; },
|
|
322
|
+
insertVoteKarma: async () => { karma += 1; },
|
|
323
|
+
getVotesForItem: async () => votes,
|
|
324
|
+
closeBoardItem: async ({ outcome }) => ({ ...stageItem(), closed_at: 'T', outcome }),
|
|
325
|
+
insertAuthorRatifyKarma: async () => true,
|
|
326
|
+
}, async () => {
|
|
327
|
+
const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
|
|
328
|
+
assert.equal(out.closed, true, 'the answer says CLOSED — no second request needed');
|
|
329
|
+
assert.equal(out.stage.key, 'charter');
|
|
330
|
+
assert.equal(out.cast.cast_via, 'stage_close');
|
|
331
|
+
}));
|
|
332
|
+
assert.equal(votes.length, 1);
|
|
333
|
+
assert.equal(votes[0].castVia, 'stage_close', 'the vote row records it was a confirm given by closing the stage');
|
|
334
|
+
assert.equal(votes[0].direction, 'yes');
|
|
335
|
+
assert.equal(karma, 0, 'the confirm decided nothing the ask had not — no vote karma');
|
|
336
|
+
assert.equal(passed.length, 1, 'the genesis home hears the pass');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('task 1005121: where one yes cannot carry it, nothing is cast and the answer says what the sitting waits on', async () => {
|
|
340
|
+
let inserted = 0;
|
|
341
|
+
await withDb({
|
|
342
|
+
getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
|
|
343
|
+
openBoardItem: async () => ({ ...stageItem({ constitution: CONSENT }) }),
|
|
344
|
+
insertBoardVote: async () => { inserted += 1; return null; },
|
|
345
|
+
listLatestGenesisStageItems: async () => [stageItem({ constitution: CONSENT })],
|
|
346
|
+
getVotesForItems: async () => [],
|
|
347
|
+
getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder'), member(AVERY, 'avery')],
|
|
348
|
+
}, async () => {
|
|
349
|
+
const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
|
|
350
|
+
assert.equal(out.closed, false);
|
|
351
|
+
assert.equal(out.opened, true);
|
|
352
|
+
assert.equal(out.item.id, '90');
|
|
353
|
+
assert.equal(out.status.state, 'sitting');
|
|
354
|
+
assert.deepEqual(out.status.waiting_on.map((m) => m.builder_id), [FOUNDER, AVERY]);
|
|
355
|
+
assert.ok(out.status.needs, 'the rule, in words');
|
|
356
|
+
});
|
|
357
|
+
assert.equal(inserted, 0, 'no vote is cast on anyone\'s behalf under a rule one person cannot carry');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('task 1005121: a refusal from the gate passes straight through, nothing opened', async () => {
|
|
361
|
+
await withDb({
|
|
362
|
+
getTaskStatuses: async () => [{ id: '101', status: 'active' }],
|
|
363
|
+
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
364
|
+
}, async () => {
|
|
365
|
+
const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
|
|
366
|
+
assert.equal(out.closed, false);
|
|
367
|
+
assert.equal(out.reason, 'stage_tasks_unfinished');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('task 1005121: cast_via stage_close is internal, genesis-only and yes-only; the vote route never passes it', async () => {
|
|
372
|
+
assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'proxy' })).reason, 'bad_cast_via');
|
|
373
|
+
await withDb({
|
|
374
|
+
getBoardItemForVote: async () => ({ ...stageItem(), subject_type: 'full_idea' }),
|
|
375
|
+
insertBoardVote: async () => { throw new Error('must not be reached'); },
|
|
376
|
+
}, async () => {
|
|
377
|
+
assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'stage_close' })).reason, 'bad_cast_via');
|
|
378
|
+
});
|
|
379
|
+
const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
|
|
380
|
+
const call = routes.slice(routes.indexOf('const out = await board.castVote({'), routes.indexOf('const out = await board.castVote({') + 300);
|
|
381
|
+
assert.doesNotMatch(call, /castVia|cast_via/, 'a request can never claim its vote was a stage-close confirm');
|
|
382
|
+
assert.ok(routes.includes("closeGenesisStage: require('../genesis-close').closeGenesisStage"), 'the one-call close is on the port');
|
|
383
|
+
const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_038_vote_cast_via.sql'), 'utf8');
|
|
384
|
+
assert.match(sql, /cast_via text NOT NULL DEFAULT 'ballot'/, 'every vote before the column reads as a ballot');
|
|
385
|
+
assert.match(sql, /cast_via IN \('ballot', 'stage_close'\) AND \(cast_via = 'ballot' OR direction = 'yes'\)/);
|
|
386
|
+
const dbSrc = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
387
|
+
assert.match(dbSrc, /INSERT INTO government_board_votes \(item_id, voter_id, direction, reason_md, section_key, cast_via\)/);
|
|
388
|
+
});
|
|
@@ -54,6 +54,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
54
54
|
'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
|
|
55
55
|
'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
|
|
56
56
|
'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
|
|
57
|
+
'government_038_vote_cast_via.sql', // HOW a vote was cast: a ballot, or a stage-close confirm (task 1005121)
|
|
57
58
|
]);
|
|
58
59
|
|
|
59
60
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
|
@@ -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
|
+
});
|