@bongos/core 1.19.970 → 1.19.972
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 +64 -29
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +151 -152
- package/docs/copy-registry.json +193 -212
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/proposals.js +1 -1
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +16 -0
- package/modules/hall-ui/public/mine-lens.js +172 -0
- package/modules/hall-ui/public/work-summary.js +90 -0
- package/modules/hall-ui/public/work.css +50 -0
- package/modules/hall-ui/public/work.html +24 -0
- package/modules/hall-ui/public/work.js +38 -51
- package/modules/lifecycle/db-goals.js +20 -3
- package/modules/lifecycle/routes/goals.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +1 -1
- package/scripts/gds/fitness.js +90 -0
- package/scripts/gds/probe-cache.js +0 -0
- package/scripts/gds/scope-roster-guard.js +118 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/copy_inventory.mjs +0 -0
- package/tests/fitness_no_raw_nul.mjs +113 -0
- package/tests/fitness_scope_roster.mjs +117 -0
- package/tests/goal_mine_lens.mjs +233 -0
- package/tests/goal_tier.mjs +30 -4
- package/tests/hall_mine_lens.mjs +381 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// tests/goal_mine_lens.mjs — the goal board's half of the hall's "mine" lens:
|
|
2
|
+
// the goals you are added on, and inside one goal, the tasks you are holding.
|
|
3
|
+
//
|
|
4
|
+
// Two things are pinned here, and both are the kind that render fine while being
|
|
5
|
+
// wrong:
|
|
6
|
+
//
|
|
7
|
+
// * "My goals" NOW READS THE ROW. The lens used to answer membership out of the
|
|
8
|
+
// per-goal DETAIL prefetch — a best-effort fan-out. When that fan-out had not
|
|
9
|
+
// landed, or had failed, every goal answered "not a member" and the lens
|
|
10
|
+
// returned an empty board: an assertion about the READER, manufactured out of
|
|
11
|
+
// a failed fetch. GET /goals now carries `viewer_is_member`, so the lens has
|
|
12
|
+
// evidence that arrives with the list it is filtering.
|
|
13
|
+
// * A GOAL'S TO-DO LIST CAN BE NARROWED TO YOUR OWN CLAIMS, which is the board's
|
|
14
|
+
// "Only mine" question scoped to one goal.
|
|
15
|
+
//
|
|
16
|
+
// Executes the REAL goals-page.js + goals-render.js in a vm against the stub DOM
|
|
17
|
+
// this repo uses in place of jsdom — the tests/goal_criterion_confirm_ui.mjs
|
|
18
|
+
// precedent, and for its stated reason: it is the only way a renderer bug is
|
|
19
|
+
// caught rather than a source string.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/goal_mine_lens.mjs
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const hall = (f) => fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8');
|
|
32
|
+
const TASK_LIB_SRC = hall('task-lib.js');
|
|
33
|
+
const GOALS_LIB_SRC = hall('goals-lib.js');
|
|
34
|
+
// goal-forest-lib.js is loaded so the detail view's dependency forest does not
|
|
35
|
+
// throw on the #/goal/N tests; nothing here asserts on it. It is a UMD module
|
|
36
|
+
// that attaches to the sandbox global rather than to `window`, so it is bridged
|
|
37
|
+
// across by hand after it runs.
|
|
38
|
+
const GOAL_FOREST_LIB_SRC = hall('goal-forest-lib.js');
|
|
39
|
+
const GOAL_FOREST_RENDER_SRC = hall('goal-forest-render.js');
|
|
40
|
+
const GOALS_RENDER_SRC = hall('goals-render.js');
|
|
41
|
+
const GOALS_PAGE_SRC = hall('goals-page.js');
|
|
42
|
+
|
|
43
|
+
const ME_ID = 1;
|
|
44
|
+
|
|
45
|
+
// Boot goals-page.js.
|
|
46
|
+
// `filters` — control values seeded BEFORE boot, so the first render already
|
|
47
|
+
// runs under the lens (the stub filter bar fires no onChange).
|
|
48
|
+
// `detailsFail` — the per-goal detail prefetch rejects, which is the exact
|
|
49
|
+
// state that used to empty the "My goals" board.
|
|
50
|
+
async function boot({
|
|
51
|
+
hash = '',
|
|
52
|
+
goals = [],
|
|
53
|
+
tasks = [],
|
|
54
|
+
activeClaims = [],
|
|
55
|
+
filters = {},
|
|
56
|
+
detailsFail = false,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const requests = [];
|
|
59
|
+
const els = new Map();
|
|
60
|
+
const makeEl = (key) => ({
|
|
61
|
+
_key: key, dataset: {}, style: {}, title: '', textContent: '', hidden: false,
|
|
62
|
+
disabled: false, value: '', innerHTML: '',
|
|
63
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
64
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
65
|
+
appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; },
|
|
66
|
+
addEventListener() {},
|
|
67
|
+
});
|
|
68
|
+
const $ = (sel) => {
|
|
69
|
+
if (!sel) return null;
|
|
70
|
+
if (!els.has(sel)) els.set(sel, makeEl(sel));
|
|
71
|
+
return els.get(sel);
|
|
72
|
+
};
|
|
73
|
+
// Seed the lens controls before anything reads them.
|
|
74
|
+
for (const [id, value] of Object.entries(filters)) $(`#${id}`).value = value;
|
|
75
|
+
|
|
76
|
+
const api = {
|
|
77
|
+
request(method, p) {
|
|
78
|
+
requests.push([method, p]);
|
|
79
|
+
const reply = (data, status = 200) => Promise.resolve({ status, ok: true, data });
|
|
80
|
+
if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: ME_ID, rank: 'metic' }, active_claims: [] });
|
|
81
|
+
if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals });
|
|
82
|
+
if (method === 'GET' && /\/goals\/\d+$/.test(p)) {
|
|
83
|
+
if (detailsFail) return Promise.resolve({ status: 500, ok: false, data: null });
|
|
84
|
+
const id = Number(p.match(/\/goals\/(\d+)$/)[1]);
|
|
85
|
+
const goal = goals.find((g) => Number(g.id) === id) || goals[0];
|
|
86
|
+
return reply({ goal, members: [], criteria: [], dependencies: [] });
|
|
87
|
+
}
|
|
88
|
+
if (method === 'GET' && /\/tasks\/claimable/.test(p)) return reply({ claimable: [], active_claims: activeClaims });
|
|
89
|
+
if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks });
|
|
90
|
+
if (method === 'GET' && /\/conflicts$/.test(p)) return reply({ pairs: [] });
|
|
91
|
+
if (method === 'GET' && /\/requests$/.test(p)) return reply({ requests: [] });
|
|
92
|
+
return reply({});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const windowObj = {
|
|
97
|
+
OTB: {
|
|
98
|
+
roleAxisLabel: () => 'Role',
|
|
99
|
+
roleLabel: (d) => String(d || ''),
|
|
100
|
+
roleOptions: () => [{ value: '', label: '— any —' }],
|
|
101
|
+
roleIsUnset: () => true,
|
|
102
|
+
ROLE_UNSET: 'unclassified',
|
|
103
|
+
KIND_UNSET: 'unclassified',
|
|
104
|
+
kindIsUnset: () => true,
|
|
105
|
+
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])),
|
|
106
|
+
$, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
|
|
107
|
+
toast() {}, categoryChip: () => '', fillKindFilter() {},
|
|
108
|
+
},
|
|
109
|
+
OTBKit: {
|
|
110
|
+
makePager: (prefix) => ({ prefix, page: 1, perPage: 50 }),
|
|
111
|
+
paginate: (rows) => rows,
|
|
112
|
+
wirePager() {}, pagerBarHtml: () => '',
|
|
113
|
+
makeTabs: () => ({ setCount() {}, select() {}, activate() {} }),
|
|
114
|
+
makeFilterBar() {},
|
|
115
|
+
emptyStateHtml: (m) => `<div class="kit-empty">${m}</div>`,
|
|
116
|
+
skeletonRows: () => '',
|
|
117
|
+
ageChipHtml: () => '',
|
|
118
|
+
},
|
|
119
|
+
OTBBranding: { currencyLabel: () => 'credits', taskSettled: () => false, taskCreditValue: () => 0 },
|
|
120
|
+
OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
|
|
121
|
+
BongosClient: { createClient: () => api },
|
|
122
|
+
location: { hash, pathname: '/builders/goals', search: '', assign() {} },
|
|
123
|
+
addEventListener() {},
|
|
124
|
+
scrollTo() {},
|
|
125
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
126
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
127
|
+
};
|
|
128
|
+
const documentObj = {
|
|
129
|
+
body: { dataset: {} },
|
|
130
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
131
|
+
getElementById: (id) => $(`#${id}`),
|
|
132
|
+
querySelector: (sel) => $(sel),
|
|
133
|
+
querySelectorAll: () => [],
|
|
134
|
+
createElement: () => makeEl('created'),
|
|
135
|
+
addEventListener() {},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const sandbox = {
|
|
139
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
140
|
+
console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object,
|
|
141
|
+
JSON, Math, encodeURIComponent, Promise, Date, URLSearchParams,
|
|
142
|
+
};
|
|
143
|
+
sandbox.globalThis = sandbox;
|
|
144
|
+
vm.createContext(sandbox);
|
|
145
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
146
|
+
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
147
|
+
vm.runInContext(GOAL_FOREST_LIB_SRC, sandbox, { filename: 'goal-forest-lib.js' });
|
|
148
|
+
windowObj.OTBGoalForestLib = sandbox.OTBGoalForestLib;
|
|
149
|
+
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
150
|
+
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
151
|
+
vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
|
|
152
|
+
for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
|
|
153
|
+
|
|
154
|
+
return { requests, el: (sel) => $(sel) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const GOALS = [
|
|
158
|
+
{ id: 11, version_id: 'V1', title: 'Goal I am on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: true },
|
|
159
|
+
{ id: 12, version_id: 'V1', title: 'Goal I am not on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: false },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 1. "My goals" — the goals you are added on
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test('the goal board shows both goals with the lens off', async () => {
|
|
167
|
+
const { el } = await boot({ goals: GOALS });
|
|
168
|
+
const grid = el('#goal-grid').innerHTML;
|
|
169
|
+
assert.match(grid, /Goal I am on/);
|
|
170
|
+
assert.match(grid, /Goal I am not on/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('"My goals" keeps the goals the viewer is a member of and drops the rest', async () => {
|
|
174
|
+
const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' } });
|
|
175
|
+
const grid = el('#goal-grid').innerHTML;
|
|
176
|
+
assert.match(grid, /Goal I am on/);
|
|
177
|
+
assert.doesNotMatch(grid, /Goal I am not on/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// The regression this change exists for. Before `viewer_is_member`, membership
|
|
181
|
+
// was read from the per-goal detail — so a failed prefetch answered "you are on
|
|
182
|
+
// none of them" and the lens rendered an empty board.
|
|
183
|
+
test('"My goals" still answers when the per-goal detail prefetch FAILS', async () => {
|
|
184
|
+
const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' }, detailsFail: true });
|
|
185
|
+
const grid = el('#goal-grid').innerHTML;
|
|
186
|
+
assert.match(grid, /Goal I am on/,
|
|
187
|
+
'the row carries the membership, so a dead detail read cannot empty the lens');
|
|
188
|
+
assert.doesNotMatch(grid, /Goal I am not on/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A deploy whose server has not yet grown the flag must fall back rather than
|
|
192
|
+
// answer "no" — the boolean check is what makes the fallback reachable.
|
|
193
|
+
test('a goal row WITHOUT the flag falls back to the detail instead of reading as "not mine"', async () => {
|
|
194
|
+
const legacy = GOALS.map(({ viewer_is_member, ...g }) => g); // eslint-disable-line no-unused-vars
|
|
195
|
+
const { el } = await boot({ goals: legacy, filters: { 'f-goal-mine': 'mine' } });
|
|
196
|
+
// The stub's detail returns members: [], so nothing matches — the point is that
|
|
197
|
+
// the page consulted the detail at all rather than short-circuiting on a
|
|
198
|
+
// missing field, and that it did not crash.
|
|
199
|
+
assert.ok(el('#goal-grid').innerHTML.length > 0, 'the board still renders');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// 2. Inside one goal — the tasks you have claimed
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const GOAL_TASKS = [
|
|
207
|
+
{ id: 301, title: 'held by me', status: 'active', goal_id: 11, touches: [] },
|
|
208
|
+
{ id: 302, title: 'held by someone else', status: 'active', goal_id: 11, touches: [] },
|
|
209
|
+
{ id: 303, title: 'nobody has claimed this', status: 'ready', goal_id: 11, touches: [] },
|
|
210
|
+
];
|
|
211
|
+
const GOAL_CLAIMS = [
|
|
212
|
+
{ claim_id: 1, task_id: 301, builder_id: ME_ID, github_login: 'me', display_name: 'Me' },
|
|
213
|
+
{ claim_id: 2, task_id: 302, builder_id: 999, github_login: 'them', display_name: 'Them' },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
test("a goal's To-do list shows every open task with the lens off", async () => {
|
|
217
|
+
const { el } = await boot({ hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS });
|
|
218
|
+
const list = el('#goal-todo-list').innerHTML;
|
|
219
|
+
for (const title of ['held by me', 'held by someone else', 'nobody has claimed this']) {
|
|
220
|
+
assert.match(list, new RegExp(title), `${title} is there unfiltered`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('"claimed by me" narrows a goal\'s To-do list to the viewer\'s own claims', async () => {
|
|
225
|
+
const { el } = await boot({
|
|
226
|
+
hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS,
|
|
227
|
+
filters: { 'f-goal-claimed': 'mine' },
|
|
228
|
+
});
|
|
229
|
+
const list = el('#goal-todo-list').innerHTML;
|
|
230
|
+
assert.match(list, /held by me/);
|
|
231
|
+
assert.doesNotMatch(list, /held by someone else/, "another builder's claim is not mine");
|
|
232
|
+
assert.doesNotMatch(list, /nobody has claimed this/, 'an unclaimed task is nobody\'s');
|
|
233
|
+
});
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -116,14 +116,15 @@ await test('getGoal: unknown id returns null (not undefined)', async () => {
|
|
|
116
116
|
// =========================================================================
|
|
117
117
|
// listGoals
|
|
118
118
|
// =========================================================================
|
|
119
|
-
await test('listGoals: no args binds [null, null, null] (
|
|
119
|
+
await test('listGoals: no args binds [null, null, null, null] (every filter off, no viewer)', async () => {
|
|
120
120
|
const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [GOAL_ROW] } : null));
|
|
121
121
|
const out = await listGoals(undefined, { pool });
|
|
122
122
|
assert.deepEqual(out, [GOAL_ROW]);
|
|
123
123
|
const sel = find(pool.queries, /FROM goals/);
|
|
124
124
|
// task 1002539 added an OPTIONAL limit. Postgres treats `LIMIT NULL` as unbounded,
|
|
125
125
|
// so the goal board's read is byte-for-byte unchanged in behaviour.
|
|
126
|
-
|
|
126
|
+
// $4 is the viewer whose membership `viewer_is_member` answers; null = nobody.
|
|
127
|
+
assert.deepEqual(sel.params, [null, null, null, null]);
|
|
127
128
|
assert.match(sel.sql, /ORDER BY version_id, sort_order, created_at, id/, 'priority-first, then stable ordering');
|
|
128
129
|
assert.match(sel.sql, /LIMIT \$3::int/, 'the limit is bound, never interpolated');
|
|
129
130
|
});
|
|
@@ -131,13 +132,38 @@ await test('listGoals: no args binds [null, null, null] (all three filters off)'
|
|
|
131
132
|
await test('listGoals: versionId + status flow through to the bound params', async () => {
|
|
132
133
|
const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
|
|
133
134
|
await listGoals({ versionId: 'BONGOS-V1', status: 'open' }, { pool });
|
|
134
|
-
assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null]);
|
|
135
|
+
assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null, null]);
|
|
135
136
|
});
|
|
136
137
|
|
|
137
138
|
await test('listGoals: an explicit limit is bound so Postgres truncates, not JS', async () => {
|
|
138
139
|
const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
|
|
139
140
|
await listGoals({ versionId: 'BONGOS-V1', status: 'open', limit: 6 }, { pool });
|
|
140
|
-
assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6]);
|
|
141
|
+
assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6, null]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The "goals I am added on" half of the hall's Only-mine lens. The board asks this
|
|
145
|
+
// of a list it already fetches, so the answer has to ride the row rather than cost
|
|
146
|
+
// a membership read per goal.
|
|
147
|
+
await test('listGoals: a viewer binds as $4 and the row carries viewer_is_member', async () => {
|
|
148
|
+
const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
|
|
149
|
+
await listGoals({ viewerId: 77 }, { pool });
|
|
150
|
+
const sel = find(pool.queries, /FROM goals/);
|
|
151
|
+
assert.deepEqual(sel.params, [null, null, null, 77]);
|
|
152
|
+
assert.match(sel.sql, /AS viewer_is_member/, 'the flag is named on the wire');
|
|
153
|
+
assert.match(sel.sql, /EXISTS \(SELECT 1 FROM goal_members gm[\s\S]*?gm\.builder_id = \$4::bigint\)/,
|
|
154
|
+
'and it is an EXISTS over goal_members for THAT builder');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// A NULL viewer must yield false, never NULL: the client branches on
|
|
158
|
+
// `typeof g.viewer_is_member === 'boolean'` to decide whether the server answered
|
|
159
|
+
// at all, so a three-valued column would make "nobody is reading" indistinguishable
|
|
160
|
+
// from "this deploy does not send the flag" and silently re-arm the empty-lens bug.
|
|
161
|
+
await test('listGoals: a viewer-less read still answers the flag with a BOOLEAN false', async () => {
|
|
162
|
+
const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
|
|
163
|
+
await listGoals({}, { pool });
|
|
164
|
+
const sel = find(pool.queries, /FROM goals/);
|
|
165
|
+
assert.match(sel.sql, /\(\$4::bigint IS NOT NULL\s*\n?\s*AND EXISTS/,
|
|
166
|
+
'the NULL case is short-circuited to false before the EXISTS is consulted');
|
|
141
167
|
});
|
|
142
168
|
|
|
143
169
|
// =========================================================================
|