@bongos/core 1.19.374 → 1.19.376
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 +99 -54
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +158 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +373 -356
- package/docs/copy-registry.json +646 -452
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/login.js +24 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
package/src/module-api.js
CHANGED
|
@@ -1178,8 +1178,22 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1178
1178
|
// landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
|
|
1179
1179
|
// 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1180
1180
|
// landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
|
|
1181
|
+
// (next carrier) — joinabilityMode re-exported (task 1003044, goal 1000058; ADR 0194).
|
|
1182
|
+
// The project's membership door — open / apply / invite_only — is enforced by
|
|
1183
|
+
// the admission machinery: the two sign-in flows self-enrol only on 'open', and
|
|
1184
|
+
// the onboarding module's public POST /access-requests must refuse an invite-only
|
|
1185
|
+
// project with the SAME decision (SR-19: one gate, no drift), so the reader moves
|
|
1186
|
+
// onto the doorway beside branding. Additive — a MINOR by the 1.19.0 precedent,
|
|
1187
|
+
// but a hand-bumped version cannot pass the publish lane's LIVE test
|
|
1188
|
+
// (tests/bump_version.mjs: HEAD's version must be one the lane's own carrier
|
|
1189
|
+
// commits announced), so it rides the next CI patch; the contradiction with
|
|
1190
|
+
// ADR 0161 §2 is filed as an idea from task 1003044.
|
|
1191
|
+
// 1.19.375 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1192
|
+
// landed since 1.19.374 with no explicit bump. run 32909701598. (task 1002620)
|
|
1193
|
+
// 1.19.376 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1194
|
+
// landed since 1.19.375 with no explicit bump. run 32910331206. (task 1002620)
|
|
1181
1195
|
// ---------------------------------------------------------------------------
|
|
1182
|
-
const CORE_VERSION = '1.19.
|
|
1196
|
+
const CORE_VERSION = '1.19.376'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1183
1197
|
|
|
1184
1198
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1185
1199
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1357,6 +1371,13 @@ module.exports = {
|
|
|
1357
1371
|
// hardcodes, instance strings).
|
|
1358
1372
|
branding: branding.branding,
|
|
1359
1373
|
clientBranding: branding.clientBranding,
|
|
1374
|
+
// joinabilityMode() — the project's effective membership door: 'open' |
|
|
1375
|
+
// 'apply' | 'invite_only' (task 1003044; ADR 0194). Read here, never
|
|
1376
|
+
// re-derived from branding().project by a module: the legacy
|
|
1377
|
+
// open-enrolment flag and the fail-closed default live in this one
|
|
1378
|
+
// reader, and the sign-in flows and the public access-request route
|
|
1379
|
+
// must answer with the same door (SR-19).
|
|
1380
|
+
joinabilityMode: auth.joinabilityMode,
|
|
1360
1381
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
1361
1382
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
1362
1383
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// tests/access_requests_invite.mjs — the admission routes against a scripted
|
|
2
|
+
// pool (task 1003044). The Archon's invite is the security-critical write on an
|
|
3
|
+
// invite-only project — it is the only thing that admits anyone — so every
|
|
4
|
+
// branch is pinned: already a member, an invite already standing, a pending
|
|
5
|
+
// request resolved in place, a fresh row, a bad username, a DB failure. Plus
|
|
6
|
+
// the public POST's three doors against the same router: apply reaches the
|
|
7
|
+
// queries, invite_only and open refuse before any.
|
|
8
|
+
//
|
|
9
|
+
// The doorway is stubbed in require.cache BEFORE the route loads (the route
|
|
10
|
+
// destructures pool/validators at require time — the web_admission_status
|
|
11
|
+
// pattern): the session gates admit a fixed Archon, the pool replays a script
|
|
12
|
+
// of results and records every query, and joinabilityMode is a switch.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/access_requests_invite.mjs
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test, before, after } from 'node:test';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const express = require('express');
|
|
23
|
+
|
|
24
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
25
|
+
const realApi = require(apiPath);
|
|
26
|
+
|
|
27
|
+
let calls = [];
|
|
28
|
+
let script = [];
|
|
29
|
+
let mode = 'apply';
|
|
30
|
+
const pool = {
|
|
31
|
+
async query(sql, params) {
|
|
32
|
+
calls.push({ sql, params });
|
|
33
|
+
if (!script.length) throw new Error(`unscripted query: ${sql.trim().slice(0, 40)}`);
|
|
34
|
+
const next = script.shift();
|
|
35
|
+
if (next instanceof Error) throw next;
|
|
36
|
+
return next;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
require.cache[apiPath].exports = {
|
|
40
|
+
...realApi,
|
|
41
|
+
pool,
|
|
42
|
+
joinabilityMode: () => mode,
|
|
43
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'archon' }; next(); },
|
|
44
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
48
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
49
|
+
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use(attachFail);
|
|
53
|
+
app.use('/api/gds', buildAccessRequestsRouter());
|
|
54
|
+
|
|
55
|
+
let server, port;
|
|
56
|
+
before(async () => {
|
|
57
|
+
server = http.createServer(app);
|
|
58
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
59
|
+
port = server.address().port;
|
|
60
|
+
});
|
|
61
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
62
|
+
|
|
63
|
+
function post(pathname, body) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const payload = JSON.stringify(body);
|
|
66
|
+
const req = http.request({
|
|
67
|
+
host: '127.0.0.1', port, path: pathname, method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
69
|
+
}, (res) => {
|
|
70
|
+
let data = '';
|
|
71
|
+
res.on('data', (c) => { data += c; });
|
|
72
|
+
res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
|
|
73
|
+
});
|
|
74
|
+
req.on('error', reject);
|
|
75
|
+
req.write(payload);
|
|
76
|
+
req.end();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const reset = (m, s) => { mode = m; script = s; calls = []; };
|
|
80
|
+
const none = { rows: [] };
|
|
81
|
+
const one = (row = {}) => ({ rows: [row] });
|
|
82
|
+
|
|
83
|
+
// ── POST /access-requests/invite ─────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test('invite: already a member → 409, nothing written', async () => {
|
|
86
|
+
reset('invite_only', [one()]);
|
|
87
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
88
|
+
assert.equal(r.status, 409);
|
|
89
|
+
assert.equal(r.json.error.code, 'already_member');
|
|
90
|
+
assert.equal(calls.length, 1);
|
|
91
|
+
assert.match(calls[0].sql, /FROM builders/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('invite: an invite already standing → 200 already_invited, nothing written', async () => {
|
|
95
|
+
reset('invite_only', [none, one()]);
|
|
96
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
97
|
+
assert.equal(r.status, 200);
|
|
98
|
+
assert.equal(r.json.already_invited, true);
|
|
99
|
+
assert.equal(calls.length, 2);
|
|
100
|
+
assert.match(calls[1].sql, /status = 'invited'/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('invite: a pending request is resolved in place — the same row the sign-in gate reads', async () => {
|
|
104
|
+
reset('invite_only', [none, none, one({ id: 7, github_login: 'Octocat', status: 'invited' })]);
|
|
105
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: '@Octocat ' });
|
|
106
|
+
assert.equal(r.status, 200);
|
|
107
|
+
assert.equal(r.json.request.status, 'invited');
|
|
108
|
+
assert.equal(calls.length, 3, 'no insert after a successful resolve');
|
|
109
|
+
assert.match(calls[2].sql, /UPDATE access_requests/);
|
|
110
|
+
assert.match(calls[2].sql, /SET status = 'invited'/);
|
|
111
|
+
assert.match(calls[2].sql, /WHERE lower\(github_login\) = lower\(\$2\) AND status = 'pending'/);
|
|
112
|
+
assert.deepEqual(calls[2].params, [42, 'Octocat'], 'resolved by the Archon; the login is trimmed and un-@ed, case kept for lower()');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('invite: no request at all → a fresh invited row, 201', async () => {
|
|
116
|
+
reset('invite_only', [none, none, none, one({ id: 8, github_login: 'octocat', status: 'invited' })]);
|
|
117
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
118
|
+
assert.equal(r.status, 201);
|
|
119
|
+
assert.equal(r.json.request.status, 'invited');
|
|
120
|
+
assert.match(calls[3].sql, /INSERT INTO access_requests/);
|
|
121
|
+
assert.match(calls[3].sql, /'invited'/);
|
|
122
|
+
assert.deepEqual(calls[3].params, ['octocat', 42]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('invite: a bad username → 400 before any query', async () => {
|
|
126
|
+
reset('invite_only', []);
|
|
127
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'not a login!' });
|
|
128
|
+
assert.equal(r.status, 400);
|
|
129
|
+
assert.equal(r.json.error.code, 'bad_github_login');
|
|
130
|
+
assert.equal(calls.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('invite: a DB failure → 500 invite_failed, no internals in the message', async () => {
|
|
134
|
+
reset('invite_only', [new Error('connection refused')]);
|
|
135
|
+
const r = await post('/api/gds/access-requests/invite', { github_login: 'octocat' });
|
|
136
|
+
assert.equal(r.status, 500);
|
|
137
|
+
assert.equal(r.json.error.code, 'invite_failed');
|
|
138
|
+
assert.doesNotMatch(r.json.error.message, /connection refused/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── the public POST's three doors ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test('public POST on apply: the door is open — the request is filed', async () => {
|
|
144
|
+
reset('apply', [none, none, one({ id: 9, github_login: 'octocat', status: 'pending' })]);
|
|
145
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
146
|
+
assert.equal(r.status, 201);
|
|
147
|
+
assert.equal(r.json.request.status, 'pending');
|
|
148
|
+
assert.equal(calls.length, 3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('public POST on invite_only: refused before any query — nothing about the applicant is consulted', async () => {
|
|
152
|
+
reset('invite_only', []);
|
|
153
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
154
|
+
assert.equal(r.status, 403);
|
|
155
|
+
assert.equal(r.json.error.code, 'invite_only');
|
|
156
|
+
assert.equal(calls.length, 0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('public POST on open: "no request needed", before any query', async () => {
|
|
160
|
+
reset('open', []);
|
|
161
|
+
const r = await post('/api/gds/access-requests', { github_login: 'octocat' });
|
|
162
|
+
assert.equal(r.status, 409);
|
|
163
|
+
assert.equal(r.json.error.code, 'open_enrollment');
|
|
164
|
+
assert.equal(calls.length, 0);
|
|
165
|
+
});
|
|
@@ -72,6 +72,48 @@ t("'0' / '' / 'yes' do NOT enable open enrollment", () => {
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// task 1003044: the decision is the project's joinability (branding().project,
|
|
76
|
+
// 'apply' on this repo's pack). The legacy flag still opens an APPLY project —
|
|
77
|
+
// a self-hosted instance that set it keeps its open door across the upgrade.
|
|
78
|
+
// The open / invite_only packs are booted in tests/joinability_*.mjs.
|
|
79
|
+
console.log('\njoinabilityMode — the door the flag now opens (task 1003044):');
|
|
80
|
+
|
|
81
|
+
t('joinabilityMode is exported, and the default pack runs as apply', () => {
|
|
82
|
+
assert.equal(typeof auth.joinabilityMode, 'function');
|
|
83
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(), 'apply'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
t('the legacy flag turns an apply project into an open one — and nothing else does', () => {
|
|
87
|
+
withOpenEnrollmentEnv('true', () => assert.equal(auth.joinabilityMode(), 'open'));
|
|
88
|
+
withOpenEnrollmentEnv('yes', () => assert.equal(auth.joinabilityMode(), 'apply'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// the reader is injectable (the pack is memoized): the edges the boots can't reach
|
|
92
|
+
t('an absent value is apply, and the legacy flag opens exactly that', () => {
|
|
93
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(() => ({})), 'apply'));
|
|
94
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: {} })), 'open'));
|
|
95
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'apply' } })), 'open'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t('a value this core cannot read fails CLOSED — invite-only, never the wider door, flag or no flag', () => {
|
|
99
|
+
withOpenEnrollmentEnv('1', () => {
|
|
100
|
+
for (const bad of ['invite-only', 'Open', 'OPEN', 'apply ', 'gated', 42]) {
|
|
101
|
+
assert.equal(auth.joinabilityMode(() => ({ project: { joinability: bad } })), 'invite_only', JSON.stringify(bad));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
t('an unreadable pack fails CLOSED', () => {
|
|
107
|
+
withOpenEnrollmentEnv('1', () => {
|
|
108
|
+
assert.equal(auth.joinabilityMode(() => { throw new Error('pack unreadable'); }), 'invite_only');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
t('an explicit invite_only is never reopened by the legacy flag; an explicit open needs no flag', () => {
|
|
113
|
+
withOpenEnrollmentEnv('1', () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'invite_only' } })), 'invite_only'));
|
|
114
|
+
withOpenEnrollmentEnv(undefined, () => assert.equal(auth.joinabilityMode(() => ({ project: { joinability: 'open' } })), 'open'));
|
|
115
|
+
});
|
|
116
|
+
|
|
75
117
|
console.log('\nfirstTimeAdmissionBlocked — the shared decision both flows make:');
|
|
76
118
|
|
|
77
119
|
t('first-timer WITHOUT invite, open enrollment OFF → BLOCKED', () => {
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// tests/goal_sort_staleness.mjs — find your own work, and see what is rotting
|
|
2
|
+
// (task 1003278, goal 1000072, criterion legibility-sort-and-staleness).
|
|
3
|
+
//
|
|
4
|
+
// Three halves. (1) The shared vocabulary: ONE activity clock and ONE staleness
|
|
5
|
+
// predicate in task-lib.js, consumed by both tiers — pinned by executing them.
|
|
6
|
+
// (2) Attribution: tasks.created_by is stamped from the authenticated request on
|
|
7
|
+
// every human create-vector and NEVER backfilled or body-supplied. (3) THE SCALE
|
|
8
|
+
// CHECK, which the criterion makes the acceptance test ("~1,357 tasks on
|
|
9
|
+
// BONGOS-V1 alone. A view that does not survive that number is not a solution"):
|
|
10
|
+
// the board's filter+sort pipeline is EXECUTED over a synthetic corpus of that
|
|
11
|
+
// size and measured, not asserted about.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/goal_sort_staleness.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
24
|
+
const taskLib = require(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'));
|
|
25
|
+
const WORK_SRC = read('modules/hall-ui/public/work.js');
|
|
26
|
+
const RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
|
|
27
|
+
const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
|
|
28
|
+
const MIGRATION = read('migrations/core_227_task_created_by.sql');
|
|
29
|
+
const DB_TASKS = read('modules/lifecycle/db-tasks.js');
|
|
30
|
+
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
// ---- the shared vocabulary --------------------------------------------------
|
|
35
|
+
|
|
36
|
+
await test('one activity clock: updated_at ∨ shipped_at, whichever is later', () => {
|
|
37
|
+
const { taskActivityMs, msOf } = taskLib;
|
|
38
|
+
const t = { updated_at: '2026-08-01T00:00:00Z', shipped_at: '2026-08-20T00:00:00Z' };
|
|
39
|
+
assert.equal(taskActivityMs(t), msOf('2026-08-20T00:00:00Z'),
|
|
40
|
+
'shipped_at folded in — a ship path can stamp it without touching updated_at (task 1002965)');
|
|
41
|
+
assert.equal(taskActivityMs({}), 0, 'no readable timestamp reads as epoch — the stalest possible');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('isStale: past the bucket is stale, inside it is not, and undated rows are STALE', () => {
|
|
45
|
+
const { isStale } = taskLib;
|
|
46
|
+
const now = Date.parse('2026-08-25T00:00:00Z');
|
|
47
|
+
const at = (daysAgo) => now - daysAgo * DAY;
|
|
48
|
+
assert.equal(isStale(at(31), '30', now), true);
|
|
49
|
+
assert.equal(isStale(at(29), '30', now), false);
|
|
50
|
+
assert.equal(isStale(at(29), '14', now), true);
|
|
51
|
+
assert.equal(isStale(at(5), '', now), true, "'' = no filter — everything passes");
|
|
52
|
+
assert.equal(isStale(0, '60', now), true,
|
|
53
|
+
'a row nobody can date is exactly what a rot-hunt must not hide');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('goals-lib DELEGATES to task-lib — one definition, not a synced pair', () => {
|
|
57
|
+
assert.match(GOALS_LIB_SRC, /const \{ msOf, taskActivityMs \} = window\.OTBTaskLib;/,
|
|
58
|
+
'the private copies were removed; a drift here would mean two meanings of "touched"');
|
|
59
|
+
assert.equal(/Date\.parse/.test(GOALS_LIB_SRC), false, 'no residual private clock');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('both tiers offer the SAME staleness buckets', () => {
|
|
63
|
+
assert.match(WORK_SRC, /\{ id: 'f-stale', label: 'Age', options: STALE_CHOICES \}/);
|
|
64
|
+
assert.match(read('modules/hall-ui/public/goals-page.js'), /\{ id: 'f-goal-stale', label: 'Age', options: window\.OTBTaskLib\.STALE_CHOICES \}/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await test("the goals page's stale FILTER and its 'recently active' SORT read one clock", () => {
|
|
68
|
+
const body = RENDER_SRC.slice(RENDER_SRC.indexOf('function filteredGoals'));
|
|
69
|
+
assert.match(body.slice(0, 2400), /isStale\(goalActivityMs\(g\), staleDays\)/,
|
|
70
|
+
'the filter reads goalActivityMs — the same function the active/stale sorts compare on');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- attribution ------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
await test('created_by comes from the AUTHENTICATED request, never the body', () => {
|
|
76
|
+
// task-write-routes stamps req.builder.id; the body validator must NOT accept a
|
|
77
|
+
// created_by key — who created a task is a fact about the caller, not an input.
|
|
78
|
+
const routes = read('modules/lifecycle/routes/task-write-routes.js');
|
|
79
|
+
assert.match(routes, /createdBy: req\.builder\.id/);
|
|
80
|
+
assert.equal(/created_by: \{/.test(routes), false, 'not a validatable body field');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('the goal-authored and idea-route vectors attribute too', () => {
|
|
84
|
+
assert.match(read('modules/lifecycle/routes/goals.js'), /createdBy: req\.builder\.id/);
|
|
85
|
+
assert.match(read('modules/ideas/inbox.js'), /createdBy: capturedBy \?\? null/,
|
|
86
|
+
'routing an idea attributes the task to the person whose idea it was');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('history is never backfilled — a guessed attribution is a confident lie', () => {
|
|
90
|
+
assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders\(id\)/);
|
|
91
|
+
assert.equal(/UPDATE tasks\s+SET created_by/i.test(MIGRATION), false,
|
|
92
|
+
'NULL = created before attribution existed; the goals.created_by convention');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the t.* views are rebuilt WITH the column — the core_190 lesson', () => {
|
|
96
|
+
// A Postgres view pins `SELECT t.*` at CREATE time; without the rebuild the
|
|
97
|
+
// Creator filter would read undefined off the claim feed for every row — the
|
|
98
|
+
// exact silent bug core_190 existed to repair.
|
|
99
|
+
assert.match(MIGRATION, /DROP VIEW IF EXISTS claimable_tasks;/);
|
|
100
|
+
assert.match(MIGRATION, /DROP VIEW IF EXISTS tasks_session_fit;/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('createTask appends createdBy at the end of the bind list (the goals convention)', () => {
|
|
104
|
+
assert.match(DB_TASKS, /goal_id, created_by, promoted_at\)/,
|
|
105
|
+
'new columns go on the end so pinned bind orders survive');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---- the mixed option form is supported, not a latent render bug ------------
|
|
109
|
+
|
|
110
|
+
await test('a filter control may mix string and {value,label} options — the kit normalises', () => {
|
|
111
|
+
// Raised in review as "will likely fail to render". It does not: hall-kit.js
|
|
112
|
+
// declares `options?: [{value,label}|string]` and normalises through optVal /
|
|
113
|
+
// optLabel, and the pre-existing f-discipline and f-goal-status controls have
|
|
114
|
+
// always used the mixed form. Pinned here so the question is settled in code.
|
|
115
|
+
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
116
|
+
assert.match(kit, /function optVal\(o\) \{ return typeof o === 'string' \? o : o\.value; \}/);
|
|
117
|
+
assert.match(kit, /function optLabel\(o\) \{ return typeof o === 'string' \? o : o\.label; \}/);
|
|
118
|
+
assert.match(WORK_SRC, /id: 'f-discipline',[\s\S]{0,200}'engineer', 'artist', 'ideator', 'ui', 'unclassified'/,
|
|
119
|
+
'the mixed form predates this task on the very same board');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---- the payload, which is where the scale actually bit ---------------------
|
|
123
|
+
|
|
124
|
+
await test('the Goals page no longer downloads the version to count it', () => {
|
|
125
|
+
// MEASURED on the real corpus, 2026-08-25: GET /tasks?version=BONGOS-V1&include=deps
|
|
126
|
+
// returned 5,229,455 bytes for ~1,400 tasks to produce ~40 counter objects.
|
|
127
|
+
// GET /goals/rollups returns one small row per goal instead.
|
|
128
|
+
const page = read('modules/hall-ui/public/goals-page.js');
|
|
129
|
+
const body = page.slice(page.indexOf('async function loadRollups'), page.indexOf('/* ---------------- list view'));
|
|
130
|
+
assert.match(body, /getJSON\(`\$\{API\}\/goals\/rollups\?version=/);
|
|
131
|
+
assert.equal(/tasks\?version=/.test(body), false,
|
|
132
|
+
'not even as a fallback — a fallback that quietly re-downloads 5 MB is the bug wearing a safety belt');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('the Backlog read is BOUNDED — it was SELECT * FROM tasks WHERE status=backlog', () => {
|
|
136
|
+
assert.match(WORK_SRC, /const BACKLOG_LIMIT = \d+;/);
|
|
137
|
+
assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('the server offers the axes, so a board can ask a narrow question', () => {
|
|
141
|
+
const routes = read('modules/lifecycle/routes/tasks.js');
|
|
142
|
+
for (const p of ['created_by', 'stale_days', 'limit', 'offset']) {
|
|
143
|
+
assert.ok(routes.includes(p), `GET /tasks must accept ${p}`);
|
|
144
|
+
}
|
|
145
|
+
assert.match(routes, /limit must be a positive integer up to 1000/,
|
|
146
|
+
'an uncapped limit would be the whole-table read this axis exists to stop');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await test("'none' selects the unattributed server-side, not a missing filter", () => {
|
|
150
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
151
|
+
assert.match(dbTasks, /if \(createdBy === 'none'\) \{\s*\n\s*where\.push\('created_by IS NULL'\);/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('the SQL rollup buckets MIRROR goals-lib readinessOf exactly', () => {
|
|
155
|
+
// A divergence here shows a person different numbers depending on which
|
|
156
|
+
// surface they opened, which is the opposite of what this goal is for.
|
|
157
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
158
|
+
const lib = GOALS_LIB_SRC;
|
|
159
|
+
// done + cut vocabularies
|
|
160
|
+
assert.match(rollup, /const DONE_STATUSES = \['shipped', 'confirmed', 'completed'\]/);
|
|
161
|
+
assert.match(lib, /DONE_STATUSES = new Set\(\['completed', 'confirmed', 'shipped'\]\)/);
|
|
162
|
+
assert.match(rollup, /const CUT_STATUSES = \['abandoned', 'dropped'\]/);
|
|
163
|
+
assert.match(lib, /CUT_STATUSES = new Set\(\['abandoned', 'dropped'\]\)/);
|
|
164
|
+
// blocked = backlog with >=1 unshipped dependency, both sides
|
|
165
|
+
assert.match(rollup, /WHEN s\.status = 'backlog' AND s\.has_unshipped_dep THEN 'blocked'/);
|
|
166
|
+
assert.match(lib, /s === 'backlog' && \(t\.blocked_by \|\| \[\]\)\.some\(\(d\) => !d\.satisfied\)/);
|
|
167
|
+
// cut is excluded from the total on both sides (migration 017's rule)
|
|
168
|
+
assert.match(rollup, /FILTER \(WHERE b\.bucket <> 'cut'\)::int\s+AS total/);
|
|
169
|
+
assert.match(read('modules/hall-ui/public/goals-page.js'), /total: row\.total \|\| 0/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('the rollup activity clock is the SAME max(updated_at, shipped_at)', () => {
|
|
173
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
174
|
+
assert.match(rollup, /GREATEST\(COALESCE\(t\.updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\(t\.shipped_at, 'epoch'::timestamptz\)\)/);
|
|
175
|
+
assert.match(read('modules/lifecycle/db-tasks.js'), /GREATEST\(COALESCE\(updated_at, 'epoch'::timestamptz\), COALESCE\(shipped_at, 'epoch'::timestamptz\)\)/,
|
|
176
|
+
"the server's stale_days filter reads the same clock as the rollup and the clients");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---- the board asks the SERVER the narrow question --------------------------
|
|
180
|
+
|
|
181
|
+
await test('Creator and Age are sent to the server, on BOTH board feeds', () => {
|
|
182
|
+
assert.match(WORK_SRC, /function serverFilterQuery\(\)/);
|
|
183
|
+
assert.match(WORK_SRC, /tasks\/claimable\$\{serverFilterQuery\(\)\}/,
|
|
184
|
+
'the claimable feed narrows at the source, not in the browser');
|
|
185
|
+
assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}\$\{q\}/,
|
|
186
|
+
'and so does the backlog read');
|
|
187
|
+
const routes = read('modules/lifecycle/routes/tasks.js');
|
|
188
|
+
const claimable = routes.slice(routes.indexOf("router.get('/tasks/claimable'"));
|
|
189
|
+
assert.match(claimable.slice(0, 2500), /createdBy: claimCreatedBy \?\? null/);
|
|
190
|
+
assert.match(claimable.slice(0, 2500), /staleDays: claimStaleDays \?\? null/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('a server-side filter change RE-FETCHES; every other filter stays instant', () => {
|
|
194
|
+
// makeFilterBar hands onChange every value, not the changed id (hall-kit.js),
|
|
195
|
+
// so the decision is made by comparing the server-facing query — which is the
|
|
196
|
+
// direct question anyway, and means a control absent from that query can never
|
|
197
|
+
// cost a round-trip.
|
|
198
|
+
assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
|
|
199
|
+
assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{[\s\S]{0,200}refreshLive\(\);\s*\n\s*loadBacklog\(\);/);
|
|
200
|
+
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
201
|
+
assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
|
|
202
|
+
'the contract this decision depends on — if it ever hands the changed id instead, revisit');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('one staleness rule, four expressions, none of them drifting', () => {
|
|
206
|
+
const clock = /GREATEST\(COALESCE\((?:v\.|t\.)?updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\((?:v\.|t\.)?shipped_at, 'epoch'::timestamptz\)\)/;
|
|
207
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
208
|
+
assert.match(dbTasks, clock, 'listTasks');
|
|
209
|
+
assert.ok((dbTasks.match(new RegExp(clock.source, 'g')) || []).length >= 2, 'and listClaimableTasks');
|
|
210
|
+
assert.match(read('modules/lifecycle/goal-rollup.js'), clock, 'the goal rollup');
|
|
211
|
+
assert.equal(taskLib.taskActivityMs({ updated_at: '2026-01-01T00:00:00Z', shipped_at: '2026-02-01T00:00:00Z' }),
|
|
212
|
+
Date.parse('2026-02-01T00:00:00Z'), 'and the browser');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---- privacy -----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
await test('a rollup never hands a private goal\'s task title to a non-member', () => {
|
|
218
|
+
// Counts on a private goal are not the secret — its prose is (ADR 0112's leak
|
|
219
|
+
// rule). next_ready_title is a real task title, so it is gated on membership
|
|
220
|
+
// or Archon; the counters still come back, so the board renders the ring and
|
|
221
|
+
// the readiness line either way.
|
|
222
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
223
|
+
assert.match(rollup, /CASE WHEN bool_or\(g\.visibility IS DISTINCT FROM 'private'\)/);
|
|
224
|
+
assert.match(rollup, /bool_or\(gm\.builder_id IS NOT NULL\)/, 'membership is what unlocks it');
|
|
225
|
+
assert.match(rollup, /THEN MAX\(nr\.title\) END\s+AS next_ready_title/);
|
|
226
|
+
const routes = read('modules/lifecycle/routes/goals.js');
|
|
227
|
+
assert.match(routes, /viewerId: req\.builder\.id/, 'identity from the request, never the query');
|
|
228
|
+
assert.match(routes, /viewerIsArchon: req\.builder\.rank === 'archon'/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('limit and offset are BOUND, not interpolated', () => {
|
|
232
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
233
|
+
assert.match(dbTasks, /tail \+= ` LIMIT \$\$\{params\.length\}`/);
|
|
234
|
+
assert.equal(/LIMIT \$\{Number\(limit\)\}/.test(dbTasks), false,
|
|
235
|
+
'a query builder that is only safe because of who calls it is one careless caller from unsafe');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---- the scale check: EXECUTED, not asserted --------------------------------
|
|
239
|
+
|
|
240
|
+
await test('the board pipeline survives the real corpus size (~1,400 rows), measured', () => {
|
|
241
|
+
// Reproduce the board's per-render work — filter (creator + staleness + the
|
|
242
|
+
// existing axes) then sort — over a corpus the size of BONGOS-V1's, and hold it
|
|
243
|
+
// to a budget. The budget is generous (well under one frame's headroom on this
|
|
244
|
+
// CI box) because the point is the ORDER of the cost, not a microbenchmark: a
|
|
245
|
+
// rewrite that makes this quadratic or fetch-per-row breaks the assert.
|
|
246
|
+
const { taskActivityMs, isStale } = taskLib;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const corpus = Array.from({ length: 1400 }, (_, i) => ({
|
|
249
|
+
id: i + 1,
|
|
250
|
+
title: `Task number ${i} of the corpus`,
|
|
251
|
+
version_id: 'BONGOS-V1',
|
|
252
|
+
kind: ['feature', 'bug', 'infra'][i % 3],
|
|
253
|
+
discipline: ['engineer', 'artist', 'ideator'][i % 3],
|
|
254
|
+
created_by: i % 5 === 0 ? null : String(1000000 + (i % 7)),
|
|
255
|
+
goal_id: String(1000000 + (i % 40)),
|
|
256
|
+
updated_at: new Date(now - (i % 90) * DAY).toISOString(),
|
|
257
|
+
shipped_at: null,
|
|
258
|
+
credits_reward: i % 50,
|
|
259
|
+
est_minutes: 30 + (i % 120),
|
|
260
|
+
priority: (i % 5) + 1,
|
|
261
|
+
newcomer_friendly: i % 11 === 0,
|
|
262
|
+
}));
|
|
263
|
+
const t0 = process.hrtime.bigint();
|
|
264
|
+
let visible;
|
|
265
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
266
|
+
visible = corpus
|
|
267
|
+
.filter((t) => String(t.created_by) === '1000003' || t.created_by == null)
|
|
268
|
+
.filter((t) => isStale(taskActivityMs(t), '30', now))
|
|
269
|
+
.sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
|
|
270
|
+
}
|
|
271
|
+
const perPassMs = Number(process.hrtime.bigint() - t0) / 1e6 / 10;
|
|
272
|
+
assert.ok(visible.length > 0, 'sanity: the synthetic filters match something');
|
|
273
|
+
assert.ok(perPassMs < 50, `one filter+sort pass over 1,400 rows took ${perPassMs.toFixed(2)}ms — the view no longer survives the corpus`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('the Tasks board still loads the corpus in a bounded number of requests', () => {
|
|
277
|
+
// The board's data story: claimable + backlog + one /goals read. The new axes
|
|
278
|
+
// must not have added a per-row or per-goal fetch. Count the getJSON call
|
|
279
|
+
// sites that run at boot — the creator labels ride ONE directory read.
|
|
280
|
+
const boots = WORK_SRC.match(/getJSON\(`\$\{API\}\/(tasks\/claimable|tasks\?status=backlog|goals|builders\/directory)`\)/g) || [];
|
|
281
|
+
assert.ok(boots.length <= 5, `bounded boot reads — got ${boots.length}`);
|
|
282
|
+
assert.equal(/getJSON\(`\$\{API\}\/tasks\/\$\{t\.id\}/.test(WORK_SRC.slice(WORK_SRC.indexOf('populateCreatorFilter'))), false,
|
|
283
|
+
'no per-task fetch crept into the new filter');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
summary();
|
package/tests/goal_subtitle.mjs
CHANGED
|
@@ -154,8 +154,13 @@ test("Claude's prose is a CLOSED disclosure, not visible body text", () => {
|
|
|
154
154
|
const el = () => ({ textContent: null, hidden: null, open: null });
|
|
155
155
|
function applyProse(goal) {
|
|
156
156
|
const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
|
|
157
|
+
const taskLib = read('modules', 'hall-ui', 'public', 'task-lib.js');
|
|
157
158
|
const els = { sub: el(), desc: el(), claude: el() };
|
|
158
|
-
|
|
159
|
+
// task 1003278: goals-lib takes its activity clock from window.OTBTaskLib (one
|
|
160
|
+
// meaning of "touched" across both tiers), so the sibling loads first — the
|
|
161
|
+
// order goals.html uses. Loading the REAL one, not a stub: a stub that always
|
|
162
|
+
// agrees is exactly what a shared definition must not be tested against.
|
|
163
|
+
const fn = new Function('window', `${taskLib}; ${lib}; return window.OTBGoalsLib.applyGoalProse;`)({ OTB: { escapeHtml: (x) => x } });
|
|
159
164
|
fn(els, goal);
|
|
160
165
|
return els;
|
|
161
166
|
}
|
package/tests/goals_lib.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// tests/goals_lib.mjs — the Goals page's extracted pure helpers (goals-lib.js,
|
|
2
|
-
// task 1003135). Pins (a) that the lib loads
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// task 1003135). Pins (a) that the lib loads with no dependency on the page's
|
|
3
|
+
// IIFE state — the whole reason it could move out of the oversized
|
|
4
|
+
// goals-page.js — and (b) the behaviour of the helpers the page destructures.
|
|
5
|
+
//
|
|
6
|
+
// task 1003278: "no page state" is NOT "no siblings". The lib already read
|
|
7
|
+
// window.OTB.escapeHtml (subtitleAttr), and its activity clock now comes from
|
|
8
|
+
// window.OTBTaskLib, because the criterion legibility-sort-and-staleness needs
|
|
9
|
+
// ONE meaning of "touched" across both tiers — a synced pair of definitions is
|
|
10
|
+
// the thing it is trying to prevent. So the harness loads the sibling libs the
|
|
11
|
+
// way goals.html does (task-lib before goals-lib) and the property under test
|
|
12
|
+
// stays what it always was: no reach into page state.
|
|
6
13
|
//
|
|
7
14
|
// Run: node tests/goals_lib.mjs
|
|
8
15
|
|
|
@@ -15,17 +22,22 @@ import { makeRunner } from './helpers.mjs';
|
|
|
15
22
|
|
|
16
23
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
24
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
|
|
25
|
+
const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
|
|
18
26
|
const { test, summary } = makeRunner();
|
|
19
27
|
|
|
20
28
|
const windowObj = {};
|
|
21
29
|
const sandbox = { window: windowObj, Set, Map, Math, Number, String, Array, Object, Date, JSON };
|
|
22
30
|
sandbox.globalThis = sandbox;
|
|
23
31
|
vm.createContext(sandbox);
|
|
32
|
+
// The REAL sibling, in load order — stubbing it would let the shared activity
|
|
33
|
+
// clock rot behind a fake that always agrees.
|
|
34
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
24
35
|
vm.runInContext(SRC, sandbox, { filename: 'goals-lib.js' });
|
|
25
36
|
const lib = windowObj.OTBGoalsLib;
|
|
26
37
|
|
|
27
38
|
await test('loads standalone in a bare window (no page-state dependency) and exports the full surface', () => {
|
|
28
39
|
assert.ok(lib, 'window.OTBGoalsLib must be set');
|
|
40
|
+
assert.equal('state' in sandbox, false, 'the lib must not reach for page state');
|
|
29
41
|
for (const k of ['DONE_STATUSES', 'CUT_STATUSES', 'isTodoStatus', 'ring', 'memberName',
|
|
30
42
|
'readinessOf', 'EMPTY_ROLLUP', 'goalRollLine', 'matchesTodoSearch', 'msOf', 'taskActivityMs']) {
|
|
31
43
|
assert.ok(k in lib, `missing export: ${k}`);
|