@bongos/core 1.24.11 → 1.24.12
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 +138 -73
- 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/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 +2 -0
- package/docs/page-inventory.json +68 -4
- package/docs/page-readings.json +856 -802
- 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/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/market-watch/db.js +10 -8
- 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/routes/pages.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- 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_module.mjs +2 -1
- package/tests/market_watch_pages.mjs +356 -0
- package/tests/market_watch_rules.mjs +63 -3
- package/tests/nav_permission_atoms.mjs +1 -1
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
|
|
@@ -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
|
+
});
|
|
@@ -45,7 +45,7 @@ const V1 = {
|
|
|
45
45
|
|
|
46
46
|
// ── the in-memory database ─────────────────────────────────────────────────────
|
|
47
47
|
function makeDb() {
|
|
48
|
-
const t = { versions: [], proposals: [], items: [], seq: 100 };
|
|
48
|
+
const t = { versions: [], proposals: [], items: [], scores: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
|
|
49
49
|
const nextId = () => ++t.seq;
|
|
50
50
|
t.versions.push({ id: nextId(), project: P, ...JSON.parse(JSON.stringify(V1)), approved_at: new Date('2026-10-28'), created_at: new Date('2026-10-28') });
|
|
51
51
|
t.items.push({ id: nextId(), project: P, kind: 'competitor', name: 'OpenHands', url: 'https://openhands.dev', status: 'active', why_tracked: '' });
|
|
@@ -103,6 +103,28 @@ function makeDb() {
|
|
|
103
103
|
if (it) it.status = 'removed';
|
|
104
104
|
return one(it ? { id: it.id } : null);
|
|
105
105
|
}
|
|
106
|
+
case 'mw:item-rescore': {
|
|
107
|
+
const it = t.items.find((x) => String(x.id) === String(a) && x.project === b && x.status !== 'removed');
|
|
108
|
+
const run = [...t.runs].reverse().find((x) => x.project === b && ['done', 'partial'].includes(x.status));
|
|
109
|
+
if (!it || !run) return one(null);
|
|
110
|
+
const old = t.scores.find((x) => x.item_id === it.id && x.run_id === run.id && x.dimension === c);
|
|
111
|
+
if (old) old.value = d; else t.scores.push({ item_id: it.id, run_id: run.id, dimension: c, value: d });
|
|
112
|
+
return one({ item_id: it.id });
|
|
113
|
+
}
|
|
114
|
+
case 'mw:item-move': {
|
|
115
|
+
const it = t.items.find((x) => String(x.id) === String(a) && x.project === b && x.kind === d && x.status !== 'removed');
|
|
116
|
+
if (!it || t.items.some((o) => o.project === b && o.kind === c && o.url === it.url && o.name === it.name)) return one(null);
|
|
117
|
+
it.kind = c;
|
|
118
|
+
return one({ id: it.id });
|
|
119
|
+
}
|
|
120
|
+
case 'mw:item-add-source': {
|
|
121
|
+
const it = t.items.find((x) => String(x.id) === String(a) && x.project === b && x.status !== 'removed');
|
|
122
|
+
if (!it) return one(null);
|
|
123
|
+
it.facts = it.facts || {};
|
|
124
|
+
const list = it.facts.watch_sources || [];
|
|
125
|
+
if (!list.includes(c)) it.facts.watch_sources = [...list, c];
|
|
126
|
+
return one({ id: it.id });
|
|
127
|
+
}
|
|
106
128
|
case 'mw:proposal-decide': {
|
|
107
129
|
const p = prop(a, b);
|
|
108
130
|
if (!p || p.status !== 'open') return one(null);
|
|
@@ -260,13 +282,13 @@ test('an approved add-item proposal creates the tracked item; the run\'s and the
|
|
|
260
282
|
assert.equal(proposals.itemFromPayload({}), null);
|
|
261
283
|
});
|
|
262
284
|
|
|
263
|
-
test('remove_item takes the item off the watch;
|
|
285
|
+
test('remove_item takes the item off the watch; a correct_entry is recorded as approved, not applied', async () => {
|
|
264
286
|
const { t, pool, withTx } = makeDb();
|
|
265
287
|
const itemId = t.items[0].id;
|
|
266
288
|
const filed = await proposals.createProposal(pool, { project: P, step: 'names', change: { op: 'remove', item_id: itemId }, reason: 'not a rival', proposedBy: ADA });
|
|
267
289
|
await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
|
|
268
290
|
assert.equal(t.items[0].status, 'removed');
|
|
269
|
-
t.proposals.push({ id: 900, project: P, kind: '
|
|
291
|
+
t.proposals.push({ id: 900, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 7, payload: {}, status: 'open', proposed_by: ADA });
|
|
270
292
|
const out = await proposals.decideProposal({ project: P, id: 900, decision: 'approve', decidedBy: OWNER, withTx });
|
|
271
293
|
assert.deepEqual([out.status, out.applied], ['approved', false]);
|
|
272
294
|
});
|
|
@@ -494,3 +516,41 @@ test('the weekly run fences a tracked item\'s name, link and why-tracked text as
|
|
|
494
516
|
assert.notEqual(other, markers[0].match(/([0-9a-f]{16})/)[1], 'a fresh marker per request');
|
|
495
517
|
assert.doesNotMatch(req.system[0].text, /Ignore all previous/, 'the cached rules prefix carries no item text');
|
|
496
518
|
});
|
|
519
|
+
|
|
520
|
+
// task 1004939: the item page's proposals, applied on approval with the payloads pages.js writes.
|
|
521
|
+
test('approving the item page\'s re-score, move and add-source applies each one', async () => {
|
|
522
|
+
const { t, withTx } = makeDb();
|
|
523
|
+
const itemId = t.items[0].id;
|
|
524
|
+
const file = (id, kind, payload) => t.proposals.push({ id, project: P, kind, origin: 'member', item_id: itemId, payload, status: 'open', proposed_by: ADA });
|
|
525
|
+
file(901, 'rescore', { dimension: 'human_gates', value: 8 });
|
|
526
|
+
file(902, 'add_source', { source_url: 'https://openhands.dev/blog' });
|
|
527
|
+
file(903, 'move_kind', { from: 'competitor', to: 'partner' });
|
|
528
|
+
for (const id of [901, 902, 903]) {
|
|
529
|
+
const out = await proposals.decideProposal({ project: P, id, decision: 'approve', decidedBy: OWNER, withTx });
|
|
530
|
+
assert.deepEqual([out.ok, out.status, out.applied], [true, 'applied', true], `proposal ${id}`);
|
|
531
|
+
}
|
|
532
|
+
assert.deepEqual(t.scores, [{ item_id: itemId, run_id: 50, dimension: 'human_gates', value: 8 }]);
|
|
533
|
+
assert.deepEqual(t.items[0].facts.watch_sources, ['https://openhands.dev/blog']);
|
|
534
|
+
assert.equal(t.items[0].kind, 'partner');
|
|
535
|
+
// A move that no longer applies (it is a partner now) changes nothing and stays open.
|
|
536
|
+
file(904, 'move_kind', { from: 'competitor', to: 'partner' });
|
|
537
|
+
assert.equal((await proposals.decideProposal({ project: P, id: 904, decision: 'approve', decidedBy: OWNER, withTx })).reason, 'no_longer_applies');
|
|
538
|
+
assert.equal(t.proposals.find((p) => p.id === 904).status, 'open');
|
|
539
|
+
// A payload that is not what pages.js writes is refused, never guessed at.
|
|
540
|
+
file(905, 'add_source', { source_url: 'javascript:alert(1)' });
|
|
541
|
+
assert.equal((await proposals.decideProposal({ project: P, id: 905, decision: 'approve', decidedBy: OWNER, withTx })).reason, 'bad_payload');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the payloads the item page files are the ones an approval applies', () => {
|
|
545
|
+
const pages = require(path.join(MW, 'pages.js'));
|
|
546
|
+
const item = { id: 4, kind: 'competitor' };
|
|
547
|
+
for (const body of [
|
|
548
|
+
{ kind: 'rescore', dimension: 'human_gates', value: '6.5', reason: 'r' },
|
|
549
|
+
{ kind: 'move_kind', reason: 'r' },
|
|
550
|
+
{ kind: 'add_source', url: 'https://example.org/changelog', reason: 'r' },
|
|
551
|
+
]) {
|
|
552
|
+
const n = pages.normalizeProposal(body, { item });
|
|
553
|
+
assert.ok(n.ok, body.kind);
|
|
554
|
+
assert.ok(proposals.itemChange(n.value.kind, n.value.payload), `${body.kind}: pages.js writes a payload proposals.js applies`);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
@@ -94,7 +94,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
94
94
|
// "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
|
|
95
95
|
// task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
|
|
96
96
|
// Market Watch settings nav item and its fuel glyph.
|
|
97
|
-
const SHELL_SHA = '
|
|
97
|
+
const SHELL_SHA = 'b89fd877947ae9f8';
|
|
98
98
|
|
|
99
99
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
100
100
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|