@bongos/core 1.19.963 → 1.19.964

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.
Files changed (34) hide show
  1. package/.bongos-core.json +64 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +5 -0
  8. package/docs/adr/README.md +1 -1
  9. package/docs/api/openapi.json +47 -2
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +134 -128
  12. package/docs/copy-registry.json +212 -158
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/hall-ui/public/goal-forest-lib.js +229 -0
  15. package/modules/hall-ui/public/goal-forest-render.js +151 -0
  16. package/modules/hall-ui/public/goals-page.js +6 -0
  17. package/modules/hall-ui/public/goals-render.js +9 -1
  18. package/modules/hall-ui/public/goals.css +26 -0
  19. package/modules/hall-ui/public/goals.html +19 -0
  20. package/modules/lifecycle/goal-forest.js +135 -0
  21. package/modules/lifecycle/routes/goals.js +8 -8
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/claude-materialize.js +29 -6
  25. package/scripts/hall-preview/server.js +1 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/claude_materialize.mjs +48 -4
  28. package/tests/goal_category_ui.mjs +2 -0
  29. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  30. package/tests/goal_forest.mjs +160 -0
  31. package/tests/goal_forest_lib.mjs +153 -0
  32. package/tests/goal_forest_page.mjs +196 -0
  33. package/tests/goal_invite_consent.mjs +1 -1
  34. package/tests/helpers.mjs +7 -3
@@ -0,0 +1,196 @@
1
+ // tests/goal_forest_page.mjs — the per-goal dependency forest's WIRING on the
2
+ // Goals page, EXECUTED (task 1003480, goal 1000089, ADR 0227 §6 item 4).
3
+ //
4
+ // goal_forest_lib.mjs proves the layout; goal_forest.mjs proves the server
5
+ // route. What neither can see is whether goals-render.js actually calls the
6
+ // real endpoint, actually draws with the real lib, and actually renders the
7
+ // honest states ADR 0227 requires (no edges → said plainly; a failed read →
8
+ // said plainly; never a stack, never a blank canvas). This runs the REAL
9
+ // goal-forest-lib.js + the REAL createGoalsRender(ctx) factory from
10
+ // goals-render.js in a vm — calling `loadDependencyForest`/
11
+ // `renderDependencyForest` directly (they are exported for exactly this)
12
+ // rather than booting the whole page, which needs a much larger fixture for
13
+ // members/completion/invites this task never touches.
14
+ //
15
+ // Run: node tests/goal_forest_page.mjs
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import vm from 'node:vm';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { makeRunner, makeEl, walk } from './helpers.mjs';
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
25
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
26
+ const LIB_JS = read('goal-forest-lib.js');
27
+ const FOREST_RENDER_JS = read('goal-forest-render.js');
28
+ const RENDER_JS = read('goals-render.js');
29
+
30
+ const { test, summary } = makeRunner();
31
+ const tick = () => new Promise((r) => setTimeout(r, 0));
32
+ const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
33
+
34
+ const t = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
35
+ const e = (from, to) => ({ from: String(from), to: String(to) });
36
+
37
+ async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] } } = {}) {
38
+ const root = makeEl('div');
39
+ const reg = (id) => { const el = makeEl('div', { id }); root.appendChild(el); return el; };
40
+ ['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body'].forEach(reg);
41
+
42
+ const documentObj = {
43
+ getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
44
+ createElementNS(_ns, tag) {
45
+ const el = makeEl(tag);
46
+ const orig = el.setAttribute.bind(el);
47
+ el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
48
+ return el;
49
+ },
50
+ };
51
+ const windowObj = {
52
+ OTB: {
53
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
54
+ $: (sel) => documentObj.getElementById(String(sel).replace(/^#/, '')),
55
+ fmtNum: (n) => String(n), fmtDate: (iso) => String(iso ?? ''), toast() {}, rankLabel: (r) => String(r ?? ''), categoryChip: () => '',
56
+ },
57
+ OTBTaskLib: {},
58
+ OTBKit: {
59
+ makePager: () => ({ page: 1, perPage: 25 }), paginate: () => [], wirePager() {}, pagerBarHtml: () => '',
60
+ makeTabs: () => ({}), makeFilterBar: () => ({}), emptyStateHtml: (m) => m, skeletonRows: () => '',
61
+ },
62
+ OTBGoalsLib: {},
63
+ };
64
+ const requests = [];
65
+ const sandbox = {
66
+ window: windowObj, self: windowObj, document: documentObj,
67
+ console: { error() {}, warn() {}, log() {} },
68
+ setTimeout, clearTimeout,
69
+ fetch: async () => { throw new Error('goals-render.js must use ctx.getJSON, not fetch directly'); },
70
+ };
71
+ sandbox.globalThis = sandbox;
72
+ vm.createContext(sandbox);
73
+ vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
74
+ vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
75
+ vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
76
+
77
+ const state = { current: 7, forest: null, forestError: false };
78
+ const ctx = {
79
+ API: '/api/bongos',
80
+ state,
81
+ sameId: (a, b) => String(a) === String(b),
82
+ getJSON: async (p) => {
83
+ requests.push(p);
84
+ if (fail) throw new Error('offline');
85
+ if (status === 401) throw Object.assign(new Error('redirecting to auth'), {});
86
+ return payload;
87
+ },
88
+ };
89
+ const renderers = windowObj.OTBGoalsRender(ctx);
90
+ return { $: documentObj.getElementById, requests, state, renderers };
91
+ }
92
+
93
+ // ---- the honest states (ADR 0227) ---------------------------------------------
94
+
95
+ await test('loadDependencyForest calls the real per-goal endpoint — never a global one', async () => {
96
+ const h = await boot({ payload: { nodes: [], edges: [] } });
97
+ await h.renderers.loadDependencyForest(7);
98
+ assert.deepEqual(h.requests, ['/api/bongos/goals/7/dependency-forest']);
99
+ });
100
+
101
+ await test('no dependency at all says so plainly — no canvas, no stack, no leftover count', async () => {
102
+ const h = await boot({ payload: { nodes: [], edges: [] } });
103
+ await h.renderers.loadDependencyForest(7);
104
+ assert.match(h.$('goal-deps-lede').textContent, /No task in this goal depends on another task in this goal/);
105
+ assert.equal(h.$('goal-deps-body').innerHTML, '');
106
+ assert.equal(h.$('goal-deps-count').hidden, true);
107
+ });
108
+
109
+ await test('a failed read says so plainly, distinct from "still loading" and from "nothing to show"', async () => {
110
+ const h = await boot({ fail: true });
111
+ await h.renderers.loadDependencyForest(7);
112
+ assert.match(h.$('goal-deps-lede').textContent, /Couldn.t load this goal.s task dependencies/);
113
+ assert.equal(h.$('goal-deps-count').hidden, true, 'a failed read pills the same as an empty one — never a literal 0');
114
+ });
115
+
116
+ await test('while loading, the lede says so and the body stays empty (no stale content from a prior goal)', async () => {
117
+ const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(1, 2)] } });
118
+ const p = h.renderers.loadDependencyForest(7); // deliberately not awaited yet
119
+ assert.equal(h.$('goal-deps-lede').textContent, 'Reading…');
120
+ assert.equal(h.$('goal-deps-body').innerHTML, '');
121
+ await p;
122
+ });
123
+
124
+ await test('a stale response for a goal the reader navigated away from is dropped', async () => {
125
+ const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(1, 2)] } });
126
+ const p = h.renderers.loadDependencyForest(7);
127
+ h.state.current = 999; // navigated to a different goal mid-request
128
+ await p;
129
+ assert.equal(h.$('goal-deps-lede').textContent, 'Reading…', 'the stale answer never overwrote the new goal\'s loading state');
130
+ });
131
+
132
+ // ---- a populated forest ------------------------------------------------------
133
+
134
+ await test('a populated forest draws a role=img figure with no focus stops, and every fact is also a table row', async () => {
135
+ const h = await boot({ payload: { nodes: [t(1, { title: 'Base' }), t(2, { title: 'Top' })], edges: [e(2, 1)] } });
136
+ await h.renderers.loadDependencyForest(7);
137
+ const body = h.$('goal-deps-body');
138
+ const svg = body.querySelector('svg');
139
+ assert.ok(svg, 'the forest is drawn');
140
+ assert.equal(svg.getAttribute('role'), 'img');
141
+ assert.equal(svg.querySelectorAll('a').length, 0);
142
+ assert.equal(svg.querySelectorAll('[tabindex]').length, 0);
143
+ const table = body.querySelector('table');
144
+ assert.ok(table, 'the table is on the page by default (ADR 0227 §5)');
145
+ const rows = table.querySelector('tbody').querySelectorAll('tr');
146
+ assert.equal(rows.length, 2, 'one row per task');
147
+ assert.ok(table.textContent.includes('Base') && table.textContent.includes('Top'));
148
+ assert.equal(h.$('goal-deps-count').hidden, false);
149
+ assert.equal(h.$('goal-deps-count').textContent, '2');
150
+ });
151
+
152
+ await test('the picture scrolls inside its own region and names both overflow axes', async () => {
153
+ const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
154
+ await h.renderers.loadDependencyForest(7);
155
+ const region = h.$('goal-deps-body').querySelector('.gf-scroll');
156
+ assert.ok(region);
157
+ assert.equal(region.getAttribute('tabindex'), '0');
158
+ const css = fs.readFileSync(path.join(HALL, 'goals.css'), 'utf8');
159
+ const rule = css.match(/\.gf-scroll\s*\{[^}]*\}/)[0];
160
+ assert.match(rule, /overflow-x:\s*auto/);
161
+ assert.match(rule, /overflow-y:\s*hidden/, 'one axis named alone makes the other auto (task 1004130)');
162
+ });
163
+
164
+ await test('a task title is builder-authored text and never becomes markup', async () => {
165
+ const evil = '<img src=x onerror=alert(1)>';
166
+ const h = await boot({ payload: { nodes: [t(1, { title: evil }), t(2)], edges: [e(2, 1)] } });
167
+ await h.renderers.loadDependencyForest(7);
168
+ const body = h.$('goal-deps-body');
169
+ assert.equal(body.querySelector('img'), null);
170
+ assert.ok(body.textContent.includes(evil), 'renders as the literal characters, not as HTML');
171
+ });
172
+
173
+ await test('a redirecting-to-auth failure is swallowed silently, the loadGoalTasks precedent — the page is about to navigate away', async () => {
174
+ const h = await boot({ status: 401 });
175
+ await h.renderers.loadDependencyForest(7);
176
+ assert.equal(h.$('goal-deps-lede').textContent, 'Reading…', 'no error flash on a redirect the browser is already leaving for');
177
+ assert.equal(h.state.forestError, false);
178
+ });
179
+
180
+ // ---- wiring into the detail render -------------------------------------------
181
+
182
+ await test('renderDetail calls loadDependencyForest, beside loadGoalTasks, for the OPEN goal', () => {
183
+ assert.match(RENDER_JS, /loadGoalTasks\(g\.id\);\s*\n\s*loadDependencyForest\(g\.id\);/);
184
+ });
185
+
186
+ await test('the section is its OWN full-width block, not squeezed into the three-column band', () => {
187
+ const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
188
+ const bandAt = html.indexOf('class="scroll goal-band"');
189
+ const depsAt = html.indexOf('id="goal-deps-scroll"');
190
+ const tasksAt = html.indexOf('<h2 class="scroll__h">Tasks</h2>');
191
+ assert.ok(bandAt > -1 && depsAt > -1 && tasksAt > -1);
192
+ assert.ok(bandAt < depsAt && depsAt < tasksAt, 'band, then dependencies, then tasks');
193
+ assert.equal(/id="goal-deps-scroll"[\s\S]{0,400}goal-band__col/.test(html), false, 'not inside a band column');
194
+ });
195
+
196
+ summary();
@@ -169,7 +169,7 @@ function harness({ pendingRequests = [] } = {}) {
169
169
  };
170
170
  sandbox.globalThis = sandbox;
171
171
  vm.createContext(sandbox);
172
- for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goals-render.js']) {
172
+ for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
173
173
  vm.runInContext(read(P + f), sandbox, { filename: f });
174
174
  }
175
175
 
package/tests/helpers.mjs CHANGED
@@ -199,10 +199,14 @@ export function hallSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'publi
199
199
  return familySource(pubDir, (f) => f === 'builders.js' || f === 'hall-render.js');
200
200
  }
201
201
 
202
- // The GOALS page script: goals-page.js plus its two carved siblings — goals-lib.js
203
- // (pure helpers, task 1003135) and goals-render.js (view painters, task 1003206).
202
+ // The GOALS page script: goals-page.js plus its carved siblings — goals-lib.js
203
+ // (pure helpers, task 1003135), goals-render.js (view painters, task 1003206),
204
+ // and goal-forest-render.js (the dependency-forest drawing code goals-render.js
205
+ // calls into via window.OTBGoalForestRender(ctx), task 1003480 — without this
206
+ // file in the boot order, createGoalsRender throws immediately, since it calls
207
+ // that factory eagerly at the top).
204
208
  export function goalsPageSource(pubDir = path.join(ROOT, 'modules', 'hall-ui', 'public')) {
205
- return familySource(pubDir, (f) => ['goals-page.js', 'goals-lib.js', 'goals-render.js'].includes(f));
209
+ return familySource(pubDir, (f) => ['goals-page.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js'].includes(f));
206
210
  }
207
211
 
208
212
  // The KERNEL IDENTITY surface: auth.js (session resolution, the middleware and the