@bongos/core 1.24.11 → 1.24.13
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 +160 -85
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +175 -3
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +92 -20
- package/docs/copy-registry.json +777 -33
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +68 -4
- package/docs/page-readings.json +856 -802
- package/migrations/core_288_credit_log_idea_unique.sql +51 -0
- package/modules/economy/credits.js +6 -5
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +2 -2
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +83 -0
- package/modules/hall-ui/public/market-watch-item.js +150 -0
- package/modules/hall-ui/public/market-watch-item.states.json +143 -0
- package/modules/hall-ui/public/market-watch-lib.js +243 -0
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +169 -0
- package/modules/hall-ui/public/market-watch.html +111 -0
- package/modules/hall-ui/public/market-watch.js +129 -0
- package/modules/hall-ui/public/market-watch.states.json +14 -0
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +2 -2
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -2
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/market-watch.md +40 -0
- package/modules/lifecycle/autopickup.js +46 -24
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +24 -16
- package/modules/market-watch/db.js +15 -10
- package/modules/market-watch/module.json +2 -1
- package/modules/market-watch/pages.js +323 -0
- package/modules/market-watch/proposals.js +66 -4
- package/modules/market-watch/research.js +27 -0
- package/modules/market-watch/routes/pages.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/project-settings.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +122 -60
- package/tests/credit_log_idea_unique_db.mjs +98 -0
- package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_data_db.mjs +1 -0
- package/tests/market_watch_module.mjs +2 -1
- package/tests/market_watch_pages.mjs +356 -0
- package/tests/market_watch_rules.mjs +63 -3
- package/tests/market_watch_run.mjs +44 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// tests/credit_log_idea_unique_db.mjs — task 1004674 (goal 1000092). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL migrations/core_288_credit_log_idea_unique.sql to a throwaway credit_log and
|
|
4
|
+
// proves: a second idea.credit.* row for the same (builder, reason) is refused by the DATABASE,
|
|
5
|
+
// the writer's INSERT ... ON CONFLICT DO NOTHING turns that into a no-op (not an error), other
|
|
6
|
+
// reasons still repeat, and a ledger that already holds a duplicate gets a warning, not a
|
|
7
|
+
// failed migration. Dropping the index shows the same duplicate then lands, so a test that kept
|
|
8
|
+
// passing without the index would fail that block.
|
|
9
|
+
//
|
|
10
|
+
// Self-skips without a reachable Postgres.
|
|
11
|
+
// Run: DATABASE_URL=... node tests/credit_log_idea_unique_db.mjs
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { Pool } = require('pg');
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const MIGRATION = readFileSync(join(here, '..', 'migrations', 'core_288_credit_log_idea_unique.sql'), 'utf8');
|
|
21
|
+
|
|
22
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
23
|
+
let client;
|
|
24
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
25
|
+
console.log(`credit_log_idea_unique_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
26
|
+
await pgPool.end().catch(() => {});
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
const SCHEMA = `t1004674_${process.pid}`;
|
|
30
|
+
let pass = 0;
|
|
31
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
32
|
+
// The writer's statement, read out of bookIdeaSplit so the test cannot keep passing against
|
|
33
|
+
// an old copy of it.
|
|
34
|
+
const CREDITS_SRC = readFileSync(join(here, '..', 'modules', 'economy', 'credits.js'), 'utf8');
|
|
35
|
+
const WRITER_MATCH = CREDITS_SRC.slice(CREDITS_SRC.indexOf('async function bookIdeaSplit'))
|
|
36
|
+
.match(/`(INSERT INTO credit_log[\s\S]*?)`/);
|
|
37
|
+
const WRITER = WRITER_MATCH && WRITER_MATCH[1];
|
|
38
|
+
const RAW = 'INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES ($1, NULL, $2, $3)';
|
|
39
|
+
const count = async (b, r) => Number((await client.query('SELECT count(*) FROM credit_log WHERE builder_id = $1 AND reason = $2', [b, r])).rows[0].count);
|
|
40
|
+
const rejects = async (sql, params) => {
|
|
41
|
+
await client.query('SAVEPOINT s');
|
|
42
|
+
let err = null;
|
|
43
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
44
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
45
|
+
return err;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
50
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
51
|
+
await client.query(`CREATE TABLE credit_log (id bigserial PRIMARY KEY, builder_id bigint NOT NULL,
|
|
52
|
+
task_id bigint, delta integer NOT NULL, reason text NOT NULL, description text)`);
|
|
53
|
+
ok(WRITER && /ON CONFLICT DO NOTHING/.test(WRITER), 'bookIdeaSplit ends ON CONFLICT DO NOTHING');
|
|
54
|
+
await client.query(MIGRATION);
|
|
55
|
+
// The refusal cases use SAVEPOINTs, which need an open transaction.
|
|
56
|
+
await client.query('BEGIN');
|
|
57
|
+
const idx =await client.query("SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = 'uq_credit_log_idea_credit'", [SCHEMA]);
|
|
58
|
+
ok(idx.rows.length === 1, 'the unique index exists after a clean migration');
|
|
59
|
+
|
|
60
|
+
await client.query(WRITER, [1, null, 5, 'idea.credit.full_pass:9', 'first']);
|
|
61
|
+
await client.query(WRITER, [1, null, 5, 'idea.credit.full_pass:9', 'second']);
|
|
62
|
+
ok(await count(1, 'idea.credit.full_pass:9') === 1, 'the writer pays an idea once');
|
|
63
|
+
|
|
64
|
+
const err = await rejects(RAW, [1, 5, 'idea.credit.full_pass:9']);
|
|
65
|
+
ok(err && err.code === '23505', 'a raw second row is refused by the database (23505) — the race the NOT EXISTS alone could not close');
|
|
66
|
+
|
|
67
|
+
// The race itself: both transactions pass the NOT EXISTS check before either commits.
|
|
68
|
+
// Modelled by inserting the loser row directly, which is exactly what its INSERT does.
|
|
69
|
+
await client.query(RAW, [2, 5, 'idea.credit.task_ship:3:4']);
|
|
70
|
+
const racer = await rejects(RAW, [2, 5, 'idea.credit.task_ship:3:4']);
|
|
71
|
+
ok(racer && racer.code === '23505', 'a stream-1 duplicate is refused the same way');
|
|
72
|
+
|
|
73
|
+
await client.query(WRITER, [3, null, 5, 'idea.credit.full_pass:9', 'other payee']);
|
|
74
|
+
ok(await count(3, 'idea.credit.full_pass:9') === 1, 'a different builder is paid under the same reason (the 70/30 split)');
|
|
75
|
+
|
|
76
|
+
await client.query(RAW, [1, 40, 'task.shipped']);
|
|
77
|
+
await client.query(RAW, [1, 40, 'task.shipped']);
|
|
78
|
+
ok(await count(1, 'task.shipped') === 2, 'other reasons still repeat for a builder');
|
|
79
|
+
|
|
80
|
+
// A ledger that already holds a duplicate must not stop the instance booting.
|
|
81
|
+
await client.query('DROP INDEX uq_credit_log_idea_credit');
|
|
82
|
+
await client.query(RAW, [1, 5, 'idea.credit.full_pass:9']);
|
|
83
|
+
ok(await count(1, 'idea.credit.full_pass:9') === 2, 'probe: without the index the same duplicate lands');
|
|
84
|
+
await client.query(MIGRATION);
|
|
85
|
+
const none = await client.query("SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = 'uq_credit_log_idea_credit'", [SCHEMA]);
|
|
86
|
+
ok(none.rows.length === 0, 'with an existing duplicate the migration completes and builds no index');
|
|
87
|
+
ok(await count(1, 'idea.credit.full_pass:9') === 2, 'and deletes nothing');
|
|
88
|
+
await client.query(WRITER, [1, null, 5, 'idea.credit.full_pass:9', 'again']);
|
|
89
|
+
ok(await count(1, 'idea.credit.full_pass:9') === 2, 'the writer still refuses to add a third (the NOT EXISTS half)');
|
|
90
|
+
|
|
91
|
+
console.log(`credit_log_idea_unique_db: ${pass} passed`);
|
|
92
|
+
} finally {
|
|
93
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
94
|
+
await client.query('SET search_path TO public').catch(() => {});
|
|
95
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
96
|
+
client.release();
|
|
97
|
+
await pgPool.end();
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// tests/fixtures/market-watch-seed-rows.cjs — the committed 2026-10-28 research as the
|
|
4
|
+
// rows seed.js writes to the hub (task 1004939): one seed run, one item per researched
|
|
5
|
+
// company or partner, their score snapshots and their case-file entries, with ids handed
|
|
6
|
+
// out in seed order as a fresh database would. tests/market_watch_pages.mjs runs the
|
|
7
|
+
// Market Watch page shapes on these, and the hall-preview fixtures market-watch__*.json
|
|
8
|
+
// were generated from them, so the pages are checked against the real seed without a
|
|
9
|
+
// database.
|
|
10
|
+
|
|
11
|
+
const RESEARCH_DATE = '2026-10-28';
|
|
12
|
+
const RESEARCH_AT = `${RESEARCH_DATE}T12:00:00.000Z`;
|
|
13
|
+
|
|
14
|
+
function seedRows(seed) {
|
|
15
|
+
const built = seed.buildSeed({ market: seed.readSeed('market-data.json'), partners: seed.readSeed('partners-data.json') });
|
|
16
|
+
const runs = [{ id: 1, status: 'done', reason: null, trigger: 'seed', created_at: RESEARCH_AT }];
|
|
17
|
+
const items = [];
|
|
18
|
+
const scores = [];
|
|
19
|
+
const entries = [];
|
|
20
|
+
let entryId = 0;
|
|
21
|
+
built.items.forEach((it, i) => {
|
|
22
|
+
const id = i + 1;
|
|
23
|
+
items.push({
|
|
24
|
+
id, kind: it.kind, name: it.name, url: it.url, category: it.category, description: it.description,
|
|
25
|
+
why_tracked: it.whyTracked, facts: it.facts, status: 'active', added_via: 'seed', created_at: RESEARCH_AT,
|
|
26
|
+
});
|
|
27
|
+
for (const [dimension, value] of Object.entries(it.scores)) scores.push({ item_id: id, run_id: 1, dimension, value });
|
|
28
|
+
for (const e of it.entries) {
|
|
29
|
+
entries.push({
|
|
30
|
+
id: ++entryId, item_id: id, run_id: 1, type: e.type, found_on: RESEARCH_DATE, takeaway: e.takeaway,
|
|
31
|
+
dimension: e.dimension, source_url: e.sourceUrl, source_label: e.sourceLabel, confidence: e.confidence,
|
|
32
|
+
pinned: false, created_at: RESEARCH_AT,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return { rules: { version: 1, status: 'active', rubric: built.rules.rubric }, runs, items, scores, entries };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What db.listItems returns for these rows: latest overall, level and streak.
|
|
40
|
+
function listItemsFrom(r, numbered, db) {
|
|
41
|
+
return r.items.map((it) => {
|
|
42
|
+
const overall = r.scores.find((s) => s.item_id === it.id && s.dimension === 'overall');
|
|
43
|
+
const foundIn = [...new Set(r.entries.filter((e) => e.item_id === it.id).map((e) => e.run_id))];
|
|
44
|
+
return { ...it, overall: overall ? Number(overall.value) : null, ...db.progress(numbered, foundIn) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function latestScoresFrom(r) {
|
|
49
|
+
return r.scores.map((s) => ({ item_id: s.item_id, dimension: s.dimension, value: s.value }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// What db.caseFile returns for one item: pinned first, newest first, with its run number.
|
|
53
|
+
function caseFileFrom(r, numbered, itemId) {
|
|
54
|
+
const runN = new Map(numbered.map((x) => [String(x.id), x.n]));
|
|
55
|
+
return r.entries.filter((e) => e.item_id === itemId)
|
|
56
|
+
.sort((a, b) => (Number(b.pinned) - Number(a.pinned)) || (b.found_on > a.found_on ? 1 : b.found_on < a.found_on ? -1 : 0) || (b.id - a.id))
|
|
57
|
+
.map((e) => ({ ...e, run: runN.get(String(e.run_id)) ?? null }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { seedRows, listItemsFrom, latestScoresFrom, caseFileFrom, RESEARCH_DATE };
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -282,7 +282,10 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
282
282
|
// it is the goals seen as one picture.
|
|
283
283
|
// 2026-10-08: 'city' — the governor city — joins after the Board Room, on the
|
|
284
284
|
// same atom and for the same reason (owner call; it leads the Governing mode).
|
|
285
|
-
|
|
285
|
+
// task 1004939 (goal 1000137): 'market-watch' joins last — the project's scouts on its
|
|
286
|
+
// market, so its subject is the project. Module-gated: it is dropped wherever the
|
|
287
|
+
// default-off market-watch module is not enabled (this boot reports no module state).
|
|
288
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'city', 'thinking', 'studio', 'market-watch'],
|
|
286
289
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
287
290
|
// task 1004685 (the living-city prototype, round-3 board 6): 'my-agents' joins You —
|
|
288
291
|
// how your own Claude works for you, the personal half of "the city steers
|
|
@@ -60,6 +60,10 @@ const ORACLE = {
|
|
|
60
60
|
// owner-only (atom + owner seat) (tests/market_watch_settings.mjs proves the 403s), and the
|
|
61
61
|
// shell carries no data, so sign-in is the page tier and the routes are the wall.
|
|
62
62
|
'/builders/market-watch-settings': 'requireBuilderPage',
|
|
63
|
+
// task 1004939: the Market Watch home and tracked-item pages. Every member of the
|
|
64
|
+
// project reads them; both reads are requireBuilder, so sign-in is the whole gate.
|
|
65
|
+
'/builders/market-watch': 'requireBuilderPage',
|
|
66
|
+
'/builders/market-watch-item': 'requireBuilderPage',
|
|
63
67
|
'/builders/goals': 'requireBuilderPage',
|
|
64
68
|
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
65
69
|
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
@@ -168,6 +168,7 @@ try {
|
|
|
168
168
|
const left = await db.remainingItems(pool, { project: P, runId: run2 });
|
|
169
169
|
assert.equal(left.length, 58, '59 active minus the one done');
|
|
170
170
|
assert.ok(!left.some((i) => i.id === Number(copilot.id)));
|
|
171
|
+
assert.ok(left.every((i) => Array.isArray(i.watch_sources)), 'each item carries its added sources, [] when none (task 1005017)');
|
|
171
172
|
assert.deepEqual(await db.remainingItems(pool, { project: OTHER, runId: run2 }), [], 'another project cannot read the run');
|
|
172
173
|
});
|
|
173
174
|
|
|
@@ -34,12 +34,13 @@ test('the manifest validates, ships disabled, declares a payer, and contributes
|
|
|
34
34
|
assert.equal(manifest.spend && manifest.spend.requiresPayer, true,
|
|
35
35
|
'every run spends the project AI budget, so the payer guard is declared before the first route');
|
|
36
36
|
// Task 1004936 added its tables and the one-shot hub seed; task 1004937 the weekly-run
|
|
37
|
-
// poller; task 1004940 the weekly ask (its poller, its routes and its hall slice). Each
|
|
37
|
+
// poller; task 1004939 the home and item pages' route file; task 1004940 the weekly ask (its poller, its routes and its hall slice). Each
|
|
38
38
|
// screen task adds its own entries, so this pins what must be there, not the whole list.
|
|
39
39
|
const c = manifest.contributes;
|
|
40
40
|
assert.equal(c.migrations, true);
|
|
41
41
|
for (const p of ['hub-seed', 'weekly-run', 'weekly-ask']) assert.ok(c.pollers.includes(p), `poller ${p}`);
|
|
42
42
|
assert.ok(c.routes.includes('ask'), 'the weekly ask routes');
|
|
43
|
+
assert.ok(c.routes.includes('pages'), 'the home and item pages\' routes');
|
|
43
44
|
assert.ok(c.routes.includes('settings'), 'the owner settings routes (task 1004938, tests/market_watch_settings.mjs)');
|
|
44
45
|
assert.ok(c.uiSections.includes('weekly-ask'), 'the weekly ask hall slice (hallWidgetScripts needs a uiSection)');
|
|
45
46
|
assert.ok(c.routes.includes('rules'), 'the rules page routes (task 1004941)');
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// tests/market_watch_pages.mjs — the Market Watch home and tracked-item pages (task
|
|
2
|
+
// 1004939, goal 1000137), without a database: the page shapes run on the committed seed
|
|
3
|
+
// (36 competitors, 24 partners), the proposal rules, the three routes (a visitor with no
|
|
4
|
+
// session gets a 401 and no data; a member reads and proposes), the quiet off state, and
|
|
5
|
+
// the escaping of member and research text where it becomes markup.
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import vm from 'node:vm';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const seed = require('../modules/market-watch/seed.js');
|
|
15
|
+
const db = require('../modules/market-watch/db.js');
|
|
16
|
+
const pages = require('../modules/market-watch/pages.js');
|
|
17
|
+
const rows = require('./fixtures/market-watch-seed-rows.cjs');
|
|
18
|
+
// The page's escaper is the hall's one (dom-utils.js), loaded for real, not a stand-in.
|
|
19
|
+
{
|
|
20
|
+
const win = { location: { search: '', hash: '', pathname: '/' }, addEventListener() {}, localStorage: null };
|
|
21
|
+
const doc = { addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], documentElement: { dataset: {} } };
|
|
22
|
+
new Function('window', 'document', readFileSync(new URL('../modules/hall-ui/public/dom-utils.js', import.meta.url), 'utf8'))(win, doc);
|
|
23
|
+
globalThis.OTB = win.OTB;
|
|
24
|
+
// The sparkline is the kit's (hall-kit.js), loaded for real too.
|
|
25
|
+
new Function('window', 'document', readFileSync(new URL('../modules/hall-ui/public/hall-kit.js', import.meta.url), 'utf8'))(win, doc);
|
|
26
|
+
globalThis.OTBKit = win.OTBKit;
|
|
27
|
+
}
|
|
28
|
+
const MW = require('../modules/hall-ui/public/market-watch-lib.js');
|
|
29
|
+
|
|
30
|
+
const R = rows.seedRows(seed);
|
|
31
|
+
const RUNS = db.numberRuns(R.runs);
|
|
32
|
+
const ITEMS = rows.listItemsFrom(R, RUNS, db);
|
|
33
|
+
const NOW = new Date('2026-10-29T09:00:00Z');
|
|
34
|
+
const SETTINGS = { settings: { enabled: true, monthly_cap_usd: 20, cadence: 'weekly', configured: true }, spentThisMonthUsd: 3.4, recentRuns: [], nextRunDue: null };
|
|
35
|
+
const RULES = { active: R.rules };
|
|
36
|
+
const byName = (name, kind) => ITEMS.find((i) => i.kind === kind && i.name.startsWith(name));
|
|
37
|
+
|
|
38
|
+
function home(over = {}) {
|
|
39
|
+
return pages.shapeHome({ items: ITEMS, runs: RUNS, settings: SETTINGS, scores: rows.latestScoresFrom(R), entries: R.entries, rules: RULES, now: NOW, ...over });
|
|
40
|
+
}
|
|
41
|
+
function itemView(it) {
|
|
42
|
+
return pages.shapeItem({ item: it, entries: rows.caseFileFrom(R, RUNS, it.id), history: db.sparklines(R.scores.filter((s) => s.item_id === it.id), RUNS), rules: RULES });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- the home on the seed ---------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test('the home desk on the seed: three scouts, 36 competitors on the landscape, eight reports', () => {
|
|
48
|
+
const h = home();
|
|
49
|
+
assert.deepEqual(h.scouts.map((s) => [s.kind, s.watched]), [['competitor', 36], ['partner', 24], ['topic', 0]]);
|
|
50
|
+
assert.equal(h.scouts[0].level, 1, 'one numbered run (the seed), so every item is level 1');
|
|
51
|
+
assert.equal(h.weeks_watched, 1);
|
|
52
|
+
assert.deepEqual(h.streak, [{ n: 1, ran: true, date: '2026-10-28', reason: null }]);
|
|
53
|
+
assert.equal(h.dots.length, 36, 'every competitor is a dot');
|
|
54
|
+
for (const d of h.dots) assert.equal(Object.keys(d.scores).length, 6, `${d.name}: five dimensions plus overall`);
|
|
55
|
+
// The axes are the rules' own words, with overall last.
|
|
56
|
+
assert.deepEqual(h.axes.map((a) => a.key), [...seed.RUBRIC.competitor.map((d) => d.key), 'overall']);
|
|
57
|
+
assert.equal(h.axes[0].label, 'AI agents write the code');
|
|
58
|
+
// Eight reports, at most one per item, competitors and partners taken in turn
|
|
59
|
+
// (they are scored on different scales, so they are never ranked against each other).
|
|
60
|
+
assert.equal(h.reports.length, 8);
|
|
61
|
+
assert.equal(new Set(h.reports.map((r) => r.item_id)).size, 8);
|
|
62
|
+
assert.deepEqual(h.reports.map((r) => r.kind), ['competitor', 'partner', 'competitor', 'partner', 'competitor', 'partner', 'competitor', 'partner']);
|
|
63
|
+
assert.equal(h.reports[1].item_name, byName('NLnet Foundation', 'partner').name, 'the best-fit partner leads the partners');
|
|
64
|
+
assert.deepEqual(h.hot, h.reports.map((r) => r.item_id));
|
|
65
|
+
assert.deepEqual(h.budget, { cap_usd: 20, spent_usd: 3.4, left_usd: 16.6 });
|
|
66
|
+
assert.deepEqual(h.next_sweep, { due: null, enabled: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a report is the surest entry on its item: pinned, then confidence, then insight', () => {
|
|
70
|
+
const it = byName('GitHub Copilot', 'competitor');
|
|
71
|
+
const byId = new Map([[String(it.id), it]]);
|
|
72
|
+
const e = (id, over) => ({ id, item_id: it.id, run_id: 1, type: 'datapoint', found_on: '2026-10-28', takeaway: `t${id}`, confidence: 2, source_url: 'https://x.test', pinned: false, ...over });
|
|
73
|
+
assert.equal(pages.pickReports([e(1), e(2, { confidence: 3 }), e(3)], byId)[0].id, 2);
|
|
74
|
+
assert.equal(pages.pickReports([e(1, { type: 'insight' }), e(2)], byId)[0].id, 1);
|
|
75
|
+
assert.equal(pages.pickReports([e(1, { confidence: 3 }), e(2, { pinned: true, confidence: 1 })], byId)[0].id, 2);
|
|
76
|
+
assert.equal(pages.pickReports([e(1)], new Map()).length, 0, 'an entry on an item that is not listed is no report');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a removed item leaves the desk; a paused one stays on the landscape but is not counted as watched', () => {
|
|
80
|
+
const removed = byName('Cognition', 'competitor');
|
|
81
|
+
const paused = byName('Cursor', 'competitor');
|
|
82
|
+
const items = ITEMS.map((i) => (i.id === removed.id ? { ...i, status: 'removed' } : i.id === paused.id ? { ...i, status: 'paused' } : i));
|
|
83
|
+
const h = home({ items });
|
|
84
|
+
assert.equal(h.scouts[0].watched, 34);
|
|
85
|
+
assert.ok(!h.dots.some((d) => d.id === removed.id));
|
|
86
|
+
assert.ok(h.dots.some((d) => d.id === paused.id));
|
|
87
|
+
assert.ok(!h.reports.some((r) => r.item_id === removed.id));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the budget never reads below zero, and a stopped watch says so', () => {
|
|
91
|
+
const h = home({ settings: { ...SETTINGS, settings: { ...SETTINGS.settings, enabled: false }, spentThisMonthUsd: 25 } });
|
|
92
|
+
assert.equal(h.budget.left_usd, 0);
|
|
93
|
+
assert.deepEqual(MW.nextSweep(h), { big: 'paused', small: 'the watch is switched off' });
|
|
94
|
+
const due = home({ settings: { ...SETTINGS, nextRunDue: new Date('2026-11-02T06:00:00Z') } });
|
|
95
|
+
assert.equal(MW.nextSweep(due, NOW).big, '3d 21h');
|
|
96
|
+
assert.equal(MW.nextSweep(home(), NOW).big, 'due now');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---- the item page on the seed ----------------------------------------------------
|
|
100
|
+
|
|
101
|
+
test('the dossier on the seed: five labelled sparklines, the overall line and the case file', () => {
|
|
102
|
+
const v = itemView(byName('GitHub Copilot', 'competitor'));
|
|
103
|
+
assert.equal(v.item.kind, 'competitor');
|
|
104
|
+
assert.deepEqual(v.dimensions.map((d) => d.label), seed.RUBRIC.competitor.map((d) => d.label));
|
|
105
|
+
for (const d of v.dimensions) assert.deepEqual([d.series.length, d.first === d.latest], [1, true]);
|
|
106
|
+
assert.deepEqual(v.overall, { series: [6], first: 6, latest: 6 });
|
|
107
|
+
assert.deepEqual(v.runs, [{ n: 1, date: '2026-10-28' }]);
|
|
108
|
+
assert.equal(v.entries.length, 4);
|
|
109
|
+
for (const e of v.entries) {
|
|
110
|
+
assert.equal(e.run, 1);
|
|
111
|
+
assert.match(e.source_url, /^https:\/\//);
|
|
112
|
+
assert.ok([1, 2, 3].includes(e.confidence));
|
|
113
|
+
}
|
|
114
|
+
const p = itemView(byName('NLnet Foundation', 'partner'));
|
|
115
|
+
assert.deepEqual(p.dimensions.map((d) => d.key), seed.RUBRIC.partner.map((d) => d.key), 'a partner is scored on the partner rubric');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a sparkline is the kit sparkline, and skips a run that did not score the dimension instead of drawing a zero', () => {
|
|
119
|
+
const svg = MW.spark([4, null, 6], 60, 20, 'x');
|
|
120
|
+
assert.match(svg, /class="kit-spark"/, 'drawn by OTBKit.sparklineHtml');
|
|
121
|
+
assert.equal(svg, MW.spark([4, 6], 60, 20, 'x'), 'the null run is dropped, not drawn as 0');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- proposals ----------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const COP = { id: 4, kind: 'competitor' };
|
|
127
|
+
const KEYS = new Set(seed.RUBRIC.competitor.map((d) => d.key));
|
|
128
|
+
const norm = (b, item = COP) => pages.normalizeProposal(b, { item, rubricKeys: KEYS });
|
|
129
|
+
|
|
130
|
+
test('each of the four changes, with a one-line reason, becomes a proposal the server shapes', () => {
|
|
131
|
+
assert.deepEqual(norm({ kind: 'rescore', dimension: 'contributor_economy', value: '4', reason: 'pays maintainers now' }).value,
|
|
132
|
+
{ kind: 'rescore', item_id: 4, log_entry_id: null, reason: 'pays maintainers now', payload: { dimension: 'contributor_economy', value: 4 } });
|
|
133
|
+
assert.deepEqual(norm({ kind: 'move_kind', to: 'competitor', reason: 'r' }).value.payload, { from: 'competitor', to: 'partner' },
|
|
134
|
+
'the direction comes from the item, never from the client');
|
|
135
|
+
assert.deepEqual(norm({ kind: 'move_kind', reason: 'r' }, { id: 9, kind: 'partner' }).value.payload, { from: 'partner', to: 'competitor' });
|
|
136
|
+
assert.deepEqual(norm({ kind: 'add_source', url: 'https://example.org/changelog', reason: 'r' }).value.payload, { source_url: 'https://example.org/changelog' });
|
|
137
|
+
assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: '15', reason: 'r' }).value, { kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r', payload: {} });
|
|
138
|
+
assert.deepEqual(norm({ kind: 'remove_item', reason: 'shut down' }).value.payload, {}, 'stop tracking');
|
|
139
|
+
assert.deepEqual(pages.normalizeProposal({ kind: 'add_item', scout: 'topic', name: ' agent pay models ', reason: 'r' }).value.payload,
|
|
140
|
+
{ kind: 'topic', name: 'agent pay models', url: null });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a proposal is refused without a reason, with a bad value, or about something it cannot name', () => {
|
|
144
|
+
const err = (b, item) => norm(b, item).error;
|
|
145
|
+
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: 4 }), 'reason_required');
|
|
146
|
+
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: 4, reason: ' \n ' }), 'reason_required');
|
|
147
|
+
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: 4, reason: 'x'.repeat(281) }), 'reason_too_long');
|
|
148
|
+
assert.equal(norm({ kind: 'rescore', dimension: 'human_gates', value: 4, reason: 'two\nlines' }).value.reason, 'two lines', 'a reason is one line');
|
|
149
|
+
assert.equal(err({ kind: 'rescore', dimension: 'worker_ownership', value: 4, reason: 'r' }), 'invalid_dimension', 'a partner dimension on a competitor');
|
|
150
|
+
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: 11, reason: 'r' }), 'invalid_value');
|
|
151
|
+
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: '', reason: 'r' }), 'invalid_value');
|
|
152
|
+
assert.equal(err({ kind: 'move_kind', reason: 'r' }, { id: 3, kind: 'topic' }), 'cannot_move_topic');
|
|
153
|
+
assert.equal(err({ kind: 'add_source', url: 'javascript:alert(1)', reason: 'r' }), 'invalid_url');
|
|
154
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '0', reason: 'r' }), 'invalid_entry');
|
|
155
|
+
assert.equal(err({ kind: 'rule_change', reason: 'r' }), 'invalid_kind', 'a rules change is the rules page\'s, not this one\'s');
|
|
156
|
+
assert.equal(pages.normalizeProposal({ kind: 'rescore', reason: 'r' }).error, 'item_not_found');
|
|
157
|
+
assert.equal(pages.normalizeProposal({ kind: 'add_item', scout: 'friend', name: 'x', reason: 'r' }).error, 'invalid_scout');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('the propose dialog offers exactly the four changes, and posts what the server reads', () => {
|
|
161
|
+
const html = MW.dialogHtml('change', { item: { id: 4, kind: 'competitor', name: 'Copilot' }, dimensions: [{ key: 'human_gates', label: 'Humans hold the gates', latest: 7 }], entries: [] });
|
|
162
|
+
assert.deepEqual([...html.matchAll(/<option value="(rescore|move_kind|add_source|correct_entry)">/g)].map((m) => m[1]), ['rescore', 'move_kind', 'add_source', 'correct_entry']);
|
|
163
|
+
assert.match(html, /Move to partner/);
|
|
164
|
+
assert.doesNotMatch(MW.dialogHtml('change', { item: { kind: 'topic' } }), /move_kind/, 'a topic cannot move');
|
|
165
|
+
const form = (vals) => ({ elements: Object.fromEntries(Object.entries(vals).map(([k, v]) => [k, { value: v }])) });
|
|
166
|
+
assert.deepEqual(MW.bodyFor('change', form({ kind: 'rescore', dimension: 'human_gates', value: '8', reason: ' r ' }), { id: 4 }),
|
|
167
|
+
{ kind: 'rescore', item_id: 4, reason: 'r', dimension: 'human_gates', value: '8' });
|
|
168
|
+
assert.deepEqual(MW.bodyFor('stop', form({ reason: 'gone' }), { id: 4 }), { kind: 'remove_item', item_id: 4, reason: 'gone' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---- untrusted text becomes markup only through esc --------------------------------
|
|
172
|
+
|
|
173
|
+
test('an item name or takeaway with markup in it is escaped on the landscape and in the dialog', () => {
|
|
174
|
+
const evil = '<img src=x onerror=alert(1)>"\'';
|
|
175
|
+
const h = home();
|
|
176
|
+
const svg = MW.scopeSvg({ ...h, dots: [{ id: 1, name: evil, overall: 5, level: 1, scores: { open_source_ethos: 5, contributor_economy: 5 } }], hot: [1] }, 'open_source_ethos', 'contributor_economy');
|
|
177
|
+
assert.ok(!svg.includes('<img'), 'no tag survives');
|
|
178
|
+
assert.match(svg, /<img src=x onerror=alert\(1\)>"'/);
|
|
179
|
+
const dlg = MW.dialogHtml('change', { item: { id: 1, kind: 'competitor', name: evil }, dimensions: [], entries: [{ id: 1, found_on: '2026-10-28', takeaway: evil }] });
|
|
180
|
+
assert.ok(!dlg.includes('<img'));
|
|
181
|
+
assert.equal(MW.safeHref('javascript:alert(1)'), '#', 'only an http(s) source becomes a link');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---- the routes --------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const api = require('../src/module-api.js');
|
|
187
|
+
let viewer = { id: 77, rank: 'xenos' };
|
|
188
|
+
api.requireBuilder = (req, res, next) => {
|
|
189
|
+
if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } });
|
|
190
|
+
req.builder = viewer;
|
|
191
|
+
next();
|
|
192
|
+
};
|
|
193
|
+
const reads = [];
|
|
194
|
+
let runReads = 0;
|
|
195
|
+
const handed = [];
|
|
196
|
+
db.listItems = async (pool, a) => { reads.push(['listItems', a.project]); handed.push(a.knownRuns === RUNS); return ITEMS; };
|
|
197
|
+
db.numberedRuns = async () => { runReads += 1; return RUNS; };
|
|
198
|
+
db.settingsView = async () => SETTINGS;
|
|
199
|
+
db.rules = async () => RULES;
|
|
200
|
+
db.getItem = async (pool, { project, id, knownRuns }) => (handed.push(knownRuns === RUNS), 0) || (project === 'https://hub.test' ? ITEMS.find((i) => i.id === id) || null : null);
|
|
201
|
+
db.caseFile = async (pool, { itemId, knownRuns }) => (handed.push(knownRuns === RUNS), 0) || rows.caseFileFrom(R, RUNS, itemId);
|
|
202
|
+
db.scoreHistory = async (pool, { itemId, knownRuns }) => (handed.push(knownRuns === RUNS), 0) || db.sparklines(R.scores.filter((s) => s.item_id === itemId), RUNS);
|
|
203
|
+
db.proposals = async () => [];
|
|
204
|
+
const inserts = [];
|
|
205
|
+
const fakePool = {
|
|
206
|
+
async query(sql, params) {
|
|
207
|
+
if (/LATEST|DISTINCT ON/.test(sql)) return { rows: rows.latestScoresFrom(R) };
|
|
208
|
+
if (/found_on >= \$2/.test(sql)) return { rows: R.entries };
|
|
209
|
+
if (/INSERT INTO market_watch_proposals/.test(sql)) {
|
|
210
|
+
inserts.push({ sql, params });
|
|
211
|
+
// The item-scoped INSERT selects from the item WHERE it is this project's.
|
|
212
|
+
if (/FROM market_watch_items i/.test(sql) && params[2] === 999) return { rows: [] };
|
|
213
|
+
return { rows: [{ id: 501, kind: params[1] === undefined ? 'add_item' : (/VALUES/.test(sql) ? 'add_item' : params[1]), status: 'open', created_at: 'now' }] };
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`unexpected query: ${sql.slice(0, 60)}`);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const express = require('express');
|
|
220
|
+
const app = express();
|
|
221
|
+
app.use((req, res, next) => {
|
|
222
|
+
res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } });
|
|
223
|
+
next();
|
|
224
|
+
});
|
|
225
|
+
app.use(express.json());
|
|
226
|
+
app.use('/api/bongos', require('../modules/market-watch/routes/pages.js')({ pool: fakePool, project: 'https://hub.test' }));
|
|
227
|
+
const server = app.listen(0);
|
|
228
|
+
await new Promise((r) => server.once('listening', r));
|
|
229
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
230
|
+
test.after(() => server.close());
|
|
231
|
+
|
|
232
|
+
function call(method, path, body) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
235
|
+
const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
|
|
236
|
+
let raw = '';
|
|
237
|
+
res.on('data', (c) => { raw += c; });
|
|
238
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(raw); } catch { /* none */ } resolve({ status: res.statusCode, body: j, raw }); });
|
|
239
|
+
});
|
|
240
|
+
req.on('error', reject);
|
|
241
|
+
if (data) req.write(data);
|
|
242
|
+
req.end();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
test('A NON-MEMBER GETS NOTHING: no session is a 401 on every route, before any read or write', async () => {
|
|
247
|
+
viewer = null;
|
|
248
|
+
reads.length = 0;
|
|
249
|
+
inserts.length = 0;
|
|
250
|
+
try {
|
|
251
|
+
for (const [m, p, b] of [['GET', '/market-watch/home'], ['GET', '/market-watch/items/4'], ['POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 4, reason: 'r' }]]) {
|
|
252
|
+
const r = await call(m, p, b);
|
|
253
|
+
assert.equal(r.status, 401, `${m} ${p}`);
|
|
254
|
+
assert.doesNotMatch(r.raw, /Copilot|competitor|takeaway/, `${m} ${p} leaks nothing`);
|
|
255
|
+
}
|
|
256
|
+
assert.equal(reads.length, 0, 'nothing was read');
|
|
257
|
+
assert.equal(inserts.length, 0, 'nothing was written');
|
|
258
|
+
} finally {
|
|
259
|
+
viewer = { id: 77, rank: 'xenos' };
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('an offboarded builder is no member: 403 on every route, nothing read or written', async () => {
|
|
264
|
+
viewer = { id: 78, rank: 'metic', status: 'inactive' };
|
|
265
|
+
reads.length = 0;
|
|
266
|
+
inserts.length = 0;
|
|
267
|
+
try {
|
|
268
|
+
for (const [m, p, b] of [['GET', '/market-watch/home'], ['GET', '/market-watch/items/4'], ['POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 4, reason: 'r' }]]) {
|
|
269
|
+
assert.equal((await call(m, p, b)).status, 403, `${m} ${p}`);
|
|
270
|
+
}
|
|
271
|
+
assert.deepEqual([reads.length, inserts.length], [0, 0]);
|
|
272
|
+
} finally {
|
|
273
|
+
viewer = { id: 77, rank: 'xenos' };
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('every member reads both pages, a newcomer included, scoped to this project', async () => {
|
|
278
|
+
runReads = 0;
|
|
279
|
+
handed.length = 0;
|
|
280
|
+
const h = await call('GET', '/market-watch/home');
|
|
281
|
+
assert.equal(h.status, 200);
|
|
282
|
+
assert.equal(h.body.dots.length, 36);
|
|
283
|
+
assert.deepEqual(reads.at(-1), ['listItems', 'https://hub.test']);
|
|
284
|
+
const it = await call('GET', '/market-watch/items/4');
|
|
285
|
+
assert.equal(it.status, 200);
|
|
286
|
+
assert.equal(it.body.item.name, 'GitHub Copilot coding agent');
|
|
287
|
+
assert.equal(runReads, 2, 'one read of the runs per page load, not one per db.js read');
|
|
288
|
+
assert.deepEqual(handed, [true, true, true, true], 'listItems, getItem, caseFile and scoreHistory reuse it');
|
|
289
|
+
assert.equal((await call('GET', '/market-watch/items/9999')).status, 404);
|
|
290
|
+
assert.equal((await call('GET', '/market-watch/items/abc')).status, 400);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a member files a proposal: one row, origin member, their id, this project; nothing else changes', async () => {
|
|
294
|
+
inserts.length = 0;
|
|
295
|
+
const r = await call('POST', '/market-watch/proposals', { kind: 'rescore', item_id: 4, dimension: 'human_gates', value: 8, reason: 'agents now need two reviews' });
|
|
296
|
+
assert.equal(r.status, 201);
|
|
297
|
+
assert.equal(inserts.length, 1);
|
|
298
|
+
const [{ sql, params }] = inserts;
|
|
299
|
+
assert.match(sql, /'member'/);
|
|
300
|
+
assert.match(sql, /i\.project = \$1/, 'the item must be this project\'s, in the SQL');
|
|
301
|
+
assert.deepEqual(params, ['https://hub.test', 'rescore', 4, null, JSON.stringify({ dimension: 'human_gates', value: 8 }), 'agents now need two reviews', 77]);
|
|
302
|
+
const s = await call('POST', '/market-watch/proposals', { kind: 'add_item', scout: 'competitor', name: 'Devin 3', reason: 'new' });
|
|
303
|
+
assert.equal(s.status, 201);
|
|
304
|
+
assert.equal(inserts[1].params.at(-1), 77);
|
|
305
|
+
assert.equal((await call('POST', '/market-watch/proposals', { kind: 'rescore', item_id: 4, dimension: 'human_gates', value: 8 })).status, 400);
|
|
306
|
+
assert.equal((await call('POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 9999, reason: 'r' })).status, 404, 'an item that is not this project\'s');
|
|
307
|
+
assert.equal((await call('POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 4, reason: 'r', status: 'approved' })).status, 400,
|
|
308
|
+
'an unknown field (a member trying to approve their own proposal) is refused');
|
|
309
|
+
assert.equal(inserts.length, 2, 'refused proposals write nothing');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ---- the quiet off state -----------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
function bootPage(file, { modules, fetchStatus = 200, search = '' } = {}) {
|
|
315
|
+
const els = {};
|
|
316
|
+
const el = (id) => (els[id] = els[id] || { id, hidden: true, innerHTML: '', textContent: '', style: {}, value: '', dataset: {}, addEventListener() {}, querySelectorAll: () => [], setAttribute() {} });
|
|
317
|
+
const listeners = {};
|
|
318
|
+
const fetches = [];
|
|
319
|
+
const window = {
|
|
320
|
+
__MODULES__: modules,
|
|
321
|
+
OTBMarketWatch: MW,
|
|
322
|
+
location: { pathname: '/market-watch', search, assign() {} },
|
|
323
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
324
|
+
};
|
|
325
|
+
const document = {
|
|
326
|
+
title: 'Market Watch — Hub',
|
|
327
|
+
getElementById: el,
|
|
328
|
+
querySelectorAll: () => [],
|
|
329
|
+
addEventListener: (ev, fn) => { listeners[ev] = fn; },
|
|
330
|
+
};
|
|
331
|
+
const ctx = vm.createContext({
|
|
332
|
+
window, document, URLSearchParams, console,
|
|
333
|
+
fetch: async (url) => { fetches.push(url); return { status: fetchStatus, ok: fetchStatus === 200, json: async () => ({ error: { code: 'not_found' } }) }; },
|
|
334
|
+
});
|
|
335
|
+
vm.runInContext(readFileSync(new URL(`../modules/hall-ui/public/${file}`, import.meta.url), 'utf8'), ctx);
|
|
336
|
+
return { els, fetches, ready: () => listeners.DOMContentLoaded() };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
test('MARKET WATCH OFF: both pages show the quiet off line, not an error, and ask the server nothing', async () => {
|
|
340
|
+
for (const [file, body] of [['market-watch.js', 'mw-desk'], ['market-watch-item.js', 'mw-wrap']]) {
|
|
341
|
+
const p = bootPage(file, { modules: { enabled: { 'market-watch': false } }, search: '?id=4' });
|
|
342
|
+
await p.ready();
|
|
343
|
+
assert.equal(p.els['mw-off'].hidden, false, `${file}: the off state shows`);
|
|
344
|
+
assert.equal(p.els['mw-fail'].hidden, true, `${file}: no error`);
|
|
345
|
+
assert.equal(p.els[body].hidden, true);
|
|
346
|
+
assert.equal(p.fetches.length, 0, `${file}: an off module is not asked`);
|
|
347
|
+
}
|
|
348
|
+
// A project whose shell did not report the module, where its routes are not mounted: the
|
|
349
|
+
// read 404s with no item code, and that is the same quiet off state.
|
|
350
|
+
for (const file of ['market-watch.js', 'market-watch-item.js']) {
|
|
351
|
+
const p = bootPage(file, { modules: undefined, fetchStatus: 404, search: '?id=4' });
|
|
352
|
+
await p.ready();
|
|
353
|
+
assert.equal(p.els['mw-off'].hidden, false, `${file}: unmounted routes read as off`);
|
|
354
|
+
assert.equal(p.els['mw-fail'].hidden, true);
|
|
355
|
+
}
|
|
356
|
+
});
|