@bongos/core 1.19.962 → 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 +69 -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 +51 -0
  8. package/docs/adr/README.md +1 -0
  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 +4 -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 +98 -8
  25. package/scripts/hall-preview/server.js +1 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/claude_materialize.mjs +180 -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,153 @@
1
+ // tests/goal_forest_lib.mjs — the per-goal dependency forest's model and
2
+ // layout, EXECUTED (task 1003480, goal 1000089, ADR 0227 §6 item 4 + §10.5).
3
+ //
4
+ // goal-forest-lib.js is pure, so this runs the real code over synthetic
5
+ // corpora, including one at the measured worst case (§10.5: the biggest single
6
+ // goal's forest is 127 tasks). What is pinned: no task is drawn twice or on
7
+ // top of another, a task always sits right of what it depends on, two
8
+ // independent chains in one goal share the picture without overlapping, and
9
+ // every fact in the picture is also a table row.
10
+ //
11
+ // Run: node tests/goal_forest_lib.mjs
12
+ import assert from 'node:assert/strict';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { createRequire } from 'node:module';
16
+ import { makeRunner } from './helpers.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const L = require(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'));
21
+
22
+ const { test, summary } = makeRunner();
23
+
24
+ const node = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
25
+ const edge = (from, to) => ({ from: String(from), to: String(to) });
26
+
27
+ function overlaps(a, b) {
28
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
29
+ }
30
+ function assertSound(a, lay, label) {
31
+ const ids = lay.nodes.map((n) => n.id);
32
+ assert.equal(new Set(ids).size, ids.length, `${label}: no task drawn twice`);
33
+ assert.equal(ids.length, a.order.length, `${label}: every analysed node is drawn — none are edge-less by construction`);
34
+ for (let i = 0; i < lay.nodes.length; i++) {
35
+ for (let j = i + 1; j < lay.nodes.length; j++) {
36
+ assert.equal(overlaps(lay.nodes[i], lay.nodes[j]), false, `${label}: ${lay.nodes[i].id} overlaps ${lay.nodes[j].id}`);
37
+ }
38
+ }
39
+ for (const n of lay.nodes) {
40
+ assert.ok(n.x >= 0 && n.y >= 0, `${label}: ${n.id} is on the canvas`);
41
+ assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${label}: ${n.id} fits inside the svg's own size`);
42
+ }
43
+ }
44
+
45
+ // ---- structure -----------------------------------------------------------------
46
+
47
+ await test('an edge means "from waits on to", and each node counts its own degree', () => {
48
+ const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2), edge(1, 3)] });
49
+ assert.equal(a.waits['1'].length, 2);
50
+ assert.equal(a.needed['2'].length, 1);
51
+ });
52
+
53
+ await test('a self edge, a duplicate, and an edge to an unknown task are all ignored', () => {
54
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 99), edge(2, 2), edge(1, 2), edge(1, 2)] });
55
+ assert.equal(a.edges.length, 1);
56
+ });
57
+
58
+ await test('chains are found, largest first — two disjoint pairs are two chains of size 2, not one', () => {
59
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4), edge(5, 6), edge(6, 7)] });
60
+ assert.deepEqual(a.chains.map((c) => c.length), [3, 2, 2]);
61
+ });
62
+
63
+ // ---- layout --------------------------------------------------------------------
64
+
65
+ await test('a task sits to the RIGHT of every task it depends on', () => {
66
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 2)] });
67
+ const lay = L.layout(a);
68
+ const x = Object.fromEntries(lay.nodes.map((n) => [n.id, n.x]));
69
+ for (const e of a.edges) assert.ok(x[e.to] < x[e.from], `${e.from} waits on ${e.to}, must be right of it`);
70
+ });
71
+
72
+ await test('two independent chains in one goal share the picture with no overlap', () => {
73
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4)] });
74
+ const lay = L.layout(a);
75
+ assertSound(a, lay, 'two chains');
76
+ });
77
+
78
+ await test('a would-be cycle terminates and still places every task', () => {
79
+ const a = L.analyse({ nodes: [1, 2, 3].map((i) => node(i)), edges: [edge(1, 2), edge(2, 1), edge(3, 1)] });
80
+ const lay = L.layout(a);
81
+ assert.equal(lay.nodes.length, 3);
82
+ assertSound(a, lay, 'cycle');
83
+ });
84
+
85
+ await test('the same data always gives the same picture', () => {
86
+ const p = { nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 1), edge(4, 2), edge(5, 2)] };
87
+ assert.deepEqual(L.layout(L.analyse(p)), L.layout(L.analyse(p)));
88
+ });
89
+
90
+ await test('an edge path starts at the dependency\'s right edge and ends at the dependent\'s left edge', () => {
91
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(2, 1)] });
92
+ const lay = L.layout(a);
93
+ const by = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
94
+ const [e] = lay.edges;
95
+ assert.ok(e.d.startsWith(`M${by['1'].x + lay.nodeW} ${by['1'].y + lay.nodeH / 2}`));
96
+ assert.ok(e.d.endsWith(`${by['2'].x} ${by['2'].y + lay.nodeH / 2}`));
97
+ });
98
+
99
+ await test('THE MEASURED WORST CASE: a 127-task single goal forest lays out soundly and fast (ADR 0227 §10.5)', () => {
100
+ let seed = 20260921;
101
+ const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
102
+ const ids = Array.from({ length: 127 }, (_, i) => i + 1);
103
+ const nodes = ids.map((i) => node(i));
104
+ const edges = [];
105
+ for (let i = 1; i < ids.length; i++) edges.push(edge(ids[i], ids[Math.floor(rnd() * i)]));
106
+ const a = L.analyse({ nodes, edges });
107
+ const t0 = Date.now();
108
+ const lay = L.layout(a);
109
+ const ms = Date.now() - t0;
110
+ assertSound(a, lay, 'worst case');
111
+ assert.ok(ms < 500, `laid out in ${ms}ms`);
112
+ });
113
+
114
+ // ---- table + description ---------------------------------------------------------
115
+
116
+ await test('the table has one row per task and carries both directions by name', () => {
117
+ const a = L.analyse({ nodes: [node(1, { title: 'Base' }), node(2, { title: 'Top' })], edges: [edge(2, 1)] });
118
+ const rows = L.tableRows(a);
119
+ const base = rows.find((r) => r.id === '1');
120
+ const top = rows.find((r) => r.id === '2');
121
+ assert.deepEqual(top.waits, [{ id: '1', title: 'Base' }]);
122
+ assert.deepEqual(base.needed, [{ id: '2', title: 'Top' }]);
123
+ });
124
+
125
+ await test('priority renders as P1..P5, or an em dash for null', () => {
126
+ const a = L.analyse({ nodes: [node(1, { priority: 1 }), node(2, { priority: null })], edges: [edge(1, 2)] });
127
+ const rows = L.tableRows(a);
128
+ assert.equal(rows.find((r) => r.id === '1').priority, 'P1');
129
+ assert.equal(rows.find((r) => r.id === '2').priority, '—');
130
+ });
131
+
132
+ await test('a lone pair is labelled plainly, a real chain is labelled and counted', () => {
133
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(2, 3), edge(2, 4)] });
134
+ const rows = L.tableRows(a);
135
+ assert.match(rows[0].chainLabel, /^Chain 1 · 4 tasks$/);
136
+ });
137
+
138
+ await test('describe states the numbers plainly, and an empty forest says so', () => {
139
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 2)] });
140
+ assert.match(L.describe(a), /^2 tasks in this goal have a dependency.*in 1 chain\.$/);
141
+ assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
142
+ });
143
+
144
+ // ---- purity -----------------------------------------------------------------------
145
+
146
+ await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
147
+ const fs = require('fs');
148
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
149
+ assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|XMLHttpRequest/.test(src.replace(/root\.OTBGoalForestLib/, '')), false);
150
+ assert.equal(/goal_id|category/i.test(src), false, 'this file knows nothing about goals or categories — that is the map, not the forest');
151
+ });
152
+
153
+ summary();
@@ -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