@bongos/core 1.19.870 → 1.19.872
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 +164 -94
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- 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 +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
- package/docs/api/openapi.json +61 -3
- package/docs/api-reference.md +7 -1
- package/docs/architecture.md +7 -7
- package/docs/branding-contract.md +1 -1
- package/docs/copy-inventory.md +190 -189
- package/docs/copy-registry.json +264 -255
- package/docs/design/hall-direction-v2.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/README.md +4 -4
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/onboarding/primer.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/bongos-cli-release.md +4 -4
- package/docs/recipes/core-release-pipeline.md +3 -3
- package/docs/recipes/instance-deploy.md +2 -2
- package/docs/recipes/ops-gotchas.md +2 -2
- package/docs/recipes/search-index-ingestion.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +218 -0
- package/modules/dev-box/app/README.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/blockers.js +138 -34
- package/modules/hall-ui/public/builders.js +2 -1
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/collab.js +6 -3
- package/modules/hall-ui/public/dom-utils.js +46 -5
- package/modules/hall-ui/public/goals-page.js +7 -3
- package/modules/hall-ui/public/goals-render.js +4 -3
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/people.js +3 -3
- package/modules/hall-ui/public/profile-charts.js +5 -2
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/profile.js +6 -6
- package/modules/hall-ui/public/settings-specialities.js +2 -2
- package/modules/hall-ui/public/settings.html +4 -4
- package/modules/hall-ui/public/settings.js +2 -2
- package/modules/hall-ui/public/shell.js +70 -0
- package/modules/hall-ui/public/style.css +31 -0
- package/modules/hall-ui/public/work.js +12 -14
- package/modules/provisioning/module.json +3 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-loop.js +170 -0
- package/scripts/gds/autobongos-run.js +317 -0
- package/scripts/gds/autobongos-verify.js +377 -0
- package/scripts/gds/autonomy-gauge-statusline.js +124 -0
- package/scripts/gds/autonomy-gauge.js +84 -0
- package/scripts/gds/run-unit-tests.js +120 -2
- package/scripts/gds/sequence.js +3 -0
- package/scripts/gds/start.js +16 -10
- package/src/bongos/core-update.js +147 -0
- package/src/bongos/routes/core-update.js +49 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +3 -1
- package/tests/autobongos_loop.mjs +446 -0
- package/tests/autobongos_verify.mjs +325 -0
- package/tests/autonomy_gauge.mjs +344 -0
- package/tests/blockers_hall.mjs +20 -6
- package/tests/blockers_kit_pager.mjs +260 -0
- package/tests/blockers_page_ui.mjs +5 -4
- package/tests/box_ship_permissions.mjs +30 -0
- package/tests/core_update_banner.mjs +308 -0
- package/tests/eviction_policy.mjs +22 -1
- package/tests/git_guard.mjs +68 -0
- package/tests/goal_criterion_confirm_ui.mjs +7 -0
- package/tests/goal_invite_consent.mjs +7 -0
- package/tests/goal_sort_staleness.mjs +6 -2
- package/tests/goal_subtitle.mjs +48 -4
- package/tests/hall_board_world.mjs +4 -1
- package/tests/hall_builders_page_pass.mjs +7 -0
- package/tests/hall_landing_boot.mjs +7 -0
- package/tests/hall_profile_world.mjs +20 -2
- package/tests/hall_record_world.mjs +7 -0
- package/tests/hall_roster_world.mjs +2 -1
- package/tests/hall_studio_world.mjs +7 -0
- package/tests/hook_shell_tools.mjs +23 -1
- package/tests/idea_detail_page.mjs +7 -0
- package/tests/idea_spark_hall.mjs +7 -0
- package/tests/integration_parked_ratchet.mjs +79 -0
- package/tests/module_contributions.mjs +134 -8
- package/tests/people_directory_clip.mjs +7 -0
- package/tests/profile_activity.mjs +7 -0
- package/tests/profile_ui_cross_project.mjs +9 -1
- package/tests/provision_disconnect.mjs +7 -0
- package/tests/provision_restart.mjs +7 -0
- package/tests/provision_settings_apply.mjs +7 -0
- package/tests/sessions_kit_pager.mjs +7 -0
- package/tests/watch_applications_queue.mjs +7 -0
- package/tests/watch_roster_self_row.mjs +7 -0
- package/tests/watch_sealed_floor.mjs +7 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// tests/core_update_banner.mjs — the core-update banner, both halves (task 1004045).
|
|
2
|
+
//
|
|
3
|
+
// The 1003159 grade's lesson is the design constraint here: that task shipped two
|
|
4
|
+
// guards that could never refuse, past a green suite, because every assertion read
|
|
5
|
+
// SOURCE TEXT. So these EXECUTE — the verdict function against real inputs, the
|
|
6
|
+
// registry read against a fake transport, and the banner against the real shell.js
|
|
7
|
+
// in a DOM stub — and the one unavoidable source assertion is labelled as such.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import vm from 'node:vm';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const require_ = createRequire(import.meta.url);
|
|
18
|
+
const CU = require_(path.join(ROOT, 'src/bongos/core-update.js'));
|
|
19
|
+
|
|
20
|
+
// The module exports exactly one function, so these helpers drive the real thing
|
|
21
|
+
// through it rather than reaching past it into internals that no route uses.
|
|
22
|
+
// `store` is per-call so the unit assertions never share the module's own cache.
|
|
23
|
+
const freshStore = () => ({ at: 0, versions: [], ok: false, inflight: null });
|
|
24
|
+
const verdictFor = (running, available, channel) =>
|
|
25
|
+
CU.readCoreUpdate({ running, channel, store: freshStore(), fetch: async () => ({ ok: true, versions: available }) });
|
|
26
|
+
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
27
|
+
|
|
28
|
+
// ── the verdict ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
test('behind: names the newest version the channel permits', async () => {
|
|
31
|
+
const v = await verdictFor('1.19.861', ['1.19.860', '1.19.861', '1.19.871']);
|
|
32
|
+
assert.equal(v.behind, true);
|
|
33
|
+
assert.equal(v.latest, '1.19.871');
|
|
34
|
+
assert.equal(v.reading, 'ok');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('current: not behind, and no version offered', async () => {
|
|
38
|
+
const v = await verdictFor('1.19.871', ['1.19.861', '1.19.871']);
|
|
39
|
+
assert.equal(v.behind, false);
|
|
40
|
+
assert.equal(v.latest, null);
|
|
41
|
+
assert.equal(v.reading, 'ok');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a MAJOR ahead is not an upgrade this banner offers', async () => {
|
|
45
|
+
// The runner refuses a major unattended, so offering one would be a button
|
|
46
|
+
// whose press is then refused — previewFromSnapshot avoids the same trap.
|
|
47
|
+
const v = await verdictFor('1.19.861', ['2.0.0']);
|
|
48
|
+
assert.equal(v.behind, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a prerelease ahead is not offered', async () => {
|
|
52
|
+
const v = await verdictFor('1.19.861', ['1.19.872-rc.1']);
|
|
53
|
+
assert.equal(v.behind, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('NO ANSWER is not "up to date" — the distinction the banner turns on', async () => {
|
|
57
|
+
// A registry that cannot be reached, and a box that cannot name its own core:
|
|
58
|
+
// two different roads to the same must-say-nothing state.
|
|
59
|
+
const unreachable = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), fetch: async () => ({ ok: false, versions: [] }) });
|
|
60
|
+
assert.equal(unreachable.reading, 'no_answer');
|
|
61
|
+
assert.equal(unreachable.behind, false);
|
|
62
|
+
const noVersion = await verdictFor(null, ['1.19.871']);
|
|
63
|
+
assert.equal(noVersion.reading, 'no_answer');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── the registry read: fail-soft, never a throw ───────────────────────────────
|
|
67
|
+
|
|
68
|
+
function fakeGet(handler) {
|
|
69
|
+
return (_url, _opts, cb) => {
|
|
70
|
+
const listeners = {};
|
|
71
|
+
const res = {
|
|
72
|
+
statusCode: 200, resume() {}, setEncoding() {},
|
|
73
|
+
on(ev, fn) { (listeners[ev] ||= []).push(fn); return res; },
|
|
74
|
+
};
|
|
75
|
+
handler(res);
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
cb(res);
|
|
78
|
+
for (const fn of listeners.data || []) fn(res._body || '');
|
|
79
|
+
for (const fn of listeners.end || []) fn();
|
|
80
|
+
});
|
|
81
|
+
return { on() {}, destroy() {} };
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a non-200 from the registry yields no answer and does not throw', async () => {
|
|
86
|
+
const v = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), get: fakeGet((res) => { res.statusCode = 503; }) });
|
|
87
|
+
assert.equal(v.reading, 'no_answer');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an unparseable packument yields no answer and does not throw', async () => {
|
|
91
|
+
const v = await CU.readCoreUpdate({ running: '1.19.861', store: freshStore(), get: fakeGet((res) => { res._body = 'not json'; }) });
|
|
92
|
+
assert.equal(v.reading, 'no_answer');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a real packument is parsed, and its newest version reaches the verdict', async () => {
|
|
96
|
+
// Drives the ACTUAL https parsing path, only the socket is fake.
|
|
97
|
+
const v = await CU.readCoreUpdate({
|
|
98
|
+
running: '1.19.861', store: freshStore(),
|
|
99
|
+
get: fakeGet((res) => { res._body = JSON.stringify({ versions: { '1.19.861': {}, '1.19.871': {} } }); }),
|
|
100
|
+
});
|
|
101
|
+
assert.equal(v.reading, 'ok');
|
|
102
|
+
assert.equal(v.latest, '1.19.871');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the cache spares the registry a second read inside the TTL', async () => {
|
|
106
|
+
const store = freshStore();
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const fetch = async () => { calls++; return { ok: true, versions: ['1.19.871'] }; };
|
|
109
|
+
let t = 1_000_000;
|
|
110
|
+
const now = () => t;
|
|
111
|
+
const args = { running: '1.19.861', store, fetch, now, ttlMs: 1000 };
|
|
112
|
+
await CU.readCoreUpdate(args);
|
|
113
|
+
await CU.readCoreUpdate(args);
|
|
114
|
+
assert.equal(calls, 1, 'second call served from cache');
|
|
115
|
+
t += 2000;
|
|
116
|
+
await CU.readCoreUpdate(args);
|
|
117
|
+
assert.equal(calls, 2, 'a call past the TTL re-reads');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('concurrent cold-cache reads make ONE outbound call, not one each', async () => {
|
|
121
|
+
const store = freshStore();
|
|
122
|
+
let calls = 0;
|
|
123
|
+
const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'] }), 5)); };
|
|
124
|
+
const args = { running: '1.19.861', store, fetch, now: () => 1 };
|
|
125
|
+
const all = await Promise.all([CU.readCoreUpdate(args), CU.readCoreUpdate(args), CU.readCoreUpdate(args)]);
|
|
126
|
+
assert.equal(calls, 1, 'three simultaneous cold reads share one registry call');
|
|
127
|
+
for (const v of all) assert.equal(v.latest, '1.19.871');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a failed read is NOT cached as an answer', async () => {
|
|
131
|
+
const store = freshStore();
|
|
132
|
+
let calls = 0;
|
|
133
|
+
const fetch = async () => { calls++; return { ok: false, versions: [] }; };
|
|
134
|
+
await CU.readCoreUpdate({ running: '1.19.861', store, fetch, now: () => 5 });
|
|
135
|
+
await CU.readCoreUpdate({ running: '1.19.861', store, fetch, now: () => 6 });
|
|
136
|
+
assert.equal(calls, 2, 'a failure must be retried, not remembered');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── the route's gate ──────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
test('the route is gated by TWO middlewares before its handler', () => {
|
|
142
|
+
const router = require_(path.join(ROOT, 'src/bongos/routes/core-update.js'))();
|
|
143
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/core-update');
|
|
144
|
+
assert.ok(layer, 'GET /core-update is registered');
|
|
145
|
+
assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('[source] the gate names core.pin.move, the deploy door’s own atom', () => {
|
|
149
|
+
// The one source-text assertion, and labelled: the middleware above is an opaque
|
|
150
|
+
// closure, so the ATOM it was built with cannot be read back off the stack. It
|
|
151
|
+
// guards against the gate being silently widened, not against it being absent —
|
|
152
|
+
// the layer count above is what proves a gate is there at all.
|
|
153
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
|
|
154
|
+
assert.match(src, /requirePermission\('core\.pin\.move'\)/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── the route, driven as a REAL request ───────────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// The grader's finding on the first round of this task, and it was right: every
|
|
160
|
+
// route assertion above inspects the stack or the source, and NEITHER can see a
|
|
161
|
+
// mis-shaped `asyncHandler` call — which is precisely the bug that shipped
|
|
162
|
+
// (handler passed where the tag goes, so every request 500s). This drives an
|
|
163
|
+
// actual HTTP request through the actual assembled router, with only the two auth
|
|
164
|
+
// gates replaced, so the handler wiring is exercised end to end.
|
|
165
|
+
|
|
166
|
+
test('a real request through the assembled router returns the verdict, not a 500', async () => {
|
|
167
|
+
// The route captures `auth` at module load, so the stubs must be installed in
|
|
168
|
+
// the require cache BEFORE it is first required — hence the fresh child require
|
|
169
|
+
// and the explicit cache delete.
|
|
170
|
+
const r2 = createRequire(import.meta.url);
|
|
171
|
+
const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
|
|
172
|
+
const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
|
|
173
|
+
const realAuth = r2(authPath);
|
|
174
|
+
const savedBuilder = realAuth.requireBuilder;
|
|
175
|
+
const savedPerm = realAuth.requirePermission;
|
|
176
|
+
realAuth.requireBuilder = (_req, _res, next) => next();
|
|
177
|
+
realAuth.requirePermission = () => (_req, _res, next) => next();
|
|
178
|
+
delete r2.cache[routePath];
|
|
179
|
+
|
|
180
|
+
// Warm the module's OWN cache through the public door, so the route's own
|
|
181
|
+
// dependency-free call is served from it and the test never touches the network.
|
|
182
|
+
await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'] }) });
|
|
183
|
+
const express = r2('express');
|
|
184
|
+
const app = express();
|
|
185
|
+
app.use(r2(routePath)());
|
|
186
|
+
const server = app.listen(0);
|
|
187
|
+
await new Promise((res) => server.once('listening', res));
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/core-update`);
|
|
190
|
+
assert.equal(res.status, 200, 'the handler runs — a 500 here is the asyncHandler arity bug');
|
|
191
|
+
const body = await res.json();
|
|
192
|
+
assert.equal(body.ok, true);
|
|
193
|
+
assert.equal(body.update.reading, 'ok');
|
|
194
|
+
assert.ok('behind' in body.update, 'the verdict shape reaches the client');
|
|
195
|
+
} finally {
|
|
196
|
+
server.close();
|
|
197
|
+
realAuth.requireBuilder = savedBuilder;
|
|
198
|
+
realAuth.requirePermission = savedPerm;
|
|
199
|
+
delete r2.cache[routePath];
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── the banner ────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function boot({ permissions, update, status = 200, dismissed = null, provisioning = true } = {}) {
|
|
206
|
+
const els = new Map();
|
|
207
|
+
const inserted = [];
|
|
208
|
+
const stored = {};
|
|
209
|
+
const requested = [];
|
|
210
|
+
const makeEl = () => ({
|
|
211
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _children: [],
|
|
212
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
213
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
214
|
+
addEventListener(ev, fn) { (this._on ||= {})[ev] = fn; },
|
|
215
|
+
querySelector() { return { addEventListener: (e, f) => { this._close = f; } }; },
|
|
216
|
+
querySelectorAll() { return []; },
|
|
217
|
+
appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() { this._removed = true; },
|
|
218
|
+
insertAdjacentElement(_pos, el) { inserted.push(el); },
|
|
219
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
220
|
+
});
|
|
221
|
+
const documentObj = {
|
|
222
|
+
body: { dataset: { page: 'settings' } },
|
|
223
|
+
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
224
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
225
|
+
querySelectorAll() { return []; },
|
|
226
|
+
createElement() { return makeEl(); },
|
|
227
|
+
addEventListener() {}, dispatchEvent() {},
|
|
228
|
+
get cookie() { return ''; }, set cookie(_v) {},
|
|
229
|
+
};
|
|
230
|
+
const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
|
|
231
|
+
const windowObj = {
|
|
232
|
+
__BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
|
|
233
|
+
BongosClient: {
|
|
234
|
+
createClient: () => ({
|
|
235
|
+
request: (method, p) => {
|
|
236
|
+
requested.push(method + ' ' + p);
|
|
237
|
+
if (p === '/api/bongos/core-update') return Promise.resolve({ status, data: update ? { ok: true, update } : null });
|
|
238
|
+
return new Promise(() => {});
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
243
|
+
localStorage: {
|
|
244
|
+
getItem: (k) => (k === 'bongos.coreUpdate.dismissed' ? dismissed : null),
|
|
245
|
+
setItem: (k, v) => { stored[k] = v; },
|
|
246
|
+
},
|
|
247
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
248
|
+
};
|
|
249
|
+
const sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
|
|
250
|
+
CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
|
|
251
|
+
sandbox.globalThis = sandbox;
|
|
252
|
+
vm.createContext(sandbox);
|
|
253
|
+
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
254
|
+
windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions });
|
|
255
|
+
return { inserted, stored, requested, banner: () => inserted.find((e) => String(e._html).includes('is available')) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const BEHIND = { running: '1.19.861', latest: '1.19.871', behind: true, reading: 'ok' };
|
|
259
|
+
|
|
260
|
+
test('an owner who is behind sees the banner, naming both versions', async () => {
|
|
261
|
+
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
262
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
263
|
+
const el = b.banner();
|
|
264
|
+
assert.ok(el, 'banner rendered');
|
|
265
|
+
assert.match(el.innerHTML, /1\.19\.871/);
|
|
266
|
+
assert.match(el.innerHTML, /1\.19\.861/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('a builder without the atom is never even asked about it', async () => {
|
|
270
|
+
const b = boot({ permissions: { 'task.create': true }, update: BEHIND });
|
|
271
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
272
|
+
assert.equal(b.banner(), undefined);
|
|
273
|
+
assert.ok(!b.requested.includes('GET /api/bongos/core-update'), 'no request is made');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('no answer renders no banner — not a false "up to date"', async () => {
|
|
277
|
+
const b = boot({ permissions: { 'core.pin.move': true }, update: { running: '1.19.861', latest: null, behind: false, reading: 'no_answer' } });
|
|
278
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
279
|
+
assert.equal(b.banner(), undefined);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('a dismissal silences THAT version only', async () => {
|
|
283
|
+
const same = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.871' });
|
|
284
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
285
|
+
assert.equal(same.banner(), undefined, 'the dismissed version stays dismissed');
|
|
286
|
+
|
|
287
|
+
const newer = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.800' });
|
|
288
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
289
|
+
assert.ok(newer.banner(), 'a newer release re-announces itself');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('the Upgrade action appears only where the deploy door exists', async () => {
|
|
293
|
+
const on = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: true });
|
|
294
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
295
|
+
assert.match(on.banner().innerHTML, /href="\/deploy"/);
|
|
296
|
+
|
|
297
|
+
const off = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: false });
|
|
298
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
299
|
+
assert.ok(off.banner(), 'a tenant still hears the news');
|
|
300
|
+
assert.doesNotMatch(off.banner().innerHTML, /href="\/deploy"/, 'but is not offered a 404');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
304
|
+
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
305
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
306
|
+
const first = b.requested.filter((r) => r.endsWith('/core-update')).length;
|
|
307
|
+
assert.equal(first, 1);
|
|
308
|
+
});
|
|
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { requireHostModule } from './helpers.mjs';
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
|
-
const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens } = requireHostModule(require, '../.claude/hooks/eviction-policy.js');
|
|
9
|
+
const { classifyEviction, estTokens, blockText, stubFor, normalize, readRange, isVolatileBash, salientTokens, DEFAULTS } = requireHostModule(require, '../.claude/hooks/eviction-policy.js');
|
|
10
10
|
|
|
11
11
|
let passed = 0, failed = 0;
|
|
12
12
|
function test(name, fn) {
|
|
@@ -276,5 +276,26 @@ test('1364 unit: readRange + salientTokens edges', () => {
|
|
|
276
276
|
assert.ok(!toks.has('plain'), 'a common short word is not distinctive');
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
// The claim lifecycle card is a neverEvictPattern, and its header is built from
|
|
280
|
+
// branding (modules/lifecycle/ship-card.js renders productName + " · task #" + id).
|
|
281
|
+
// The guard used to hardcode one instance name, so it silently stopped protecting
|
|
282
|
+
// the card the moment the brand changed (task 1001784). Pin the BRAND-AGNOSTIC
|
|
283
|
+
// shape instead: any product name, and a DIGIT after the hash -- a pattern that
|
|
284
|
+
// loses the \d escape matches the literal letter d and so matches nothing real.
|
|
285
|
+
// The three positive cases are documentary: the guard reads only the " · task #<digit>"
|
|
286
|
+
// suffix, never the name, so they differ in SHAPE (one word, two words, a lowercase
|
|
287
|
+
// interior word) to show a reader that the name is not part of the match.
|
|
288
|
+
test('1001784: the lifecycle-card guard matches any brand, not one hardcoded name', () => {
|
|
289
|
+
const guarded = (text) => DEFAULTS.neverEvictPatterns.some((re) => re.test(text));
|
|
290
|
+
assert.ok(guarded(' Cloud Bongos · task #1001784 Claimed'), 'this instance');
|
|
291
|
+
assert.ok(guarded(' Acme Corp · task #42 Shipped'), 'any other brand');
|
|
292
|
+
assert.ok(guarded(' Port of Call · task #7 Claimed'), 'a multi-word brand with a lowercase interior word');
|
|
293
|
+
// The negative case must carry the same " · task #" prefix as a real card,
|
|
294
|
+
// or it fails for the wrong reason and passes under BOTH the buggy and the fixed
|
|
295
|
+
// regex. This string matches / · task #d/ and must NOT match / · task #\d/,
|
|
296
|
+
// which is exactly the escape this test exists to pin.
|
|
297
|
+
assert.ok(!guarded(' Cloud Bongos · task #d Claimed'), 'a literal d is not a task number');
|
|
298
|
+
});
|
|
299
|
+
|
|
279
300
|
console.log(`\neviction_policy: ${passed} passed, ${failed} failed`);
|
|
280
301
|
process.exit(failed ? 1 : 0);
|
package/tests/git_guard.mjs
CHANGED
|
@@ -118,6 +118,74 @@ test('the PowerShell tool keeps the same false-positive floor as Bash', () => {
|
|
|
118
118
|
}
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// The four spellings PowerShell resolves to the same executable as a plain `git`
|
|
122
|
+
// (task 1004028). It looks a command up case-insensitively, appends the extensions
|
|
123
|
+
// in $env:PATHEXT, and eats a backtick as its escape character — so each of these
|
|
124
|
+
// RUNS on a Windows laptop, and each one used to return block:false because the
|
|
125
|
+
// guard matched a literal lower-case `git`.
|
|
126
|
+
test('PowerShell spellings of the executable are blocked, not just the lower-case one', () => {
|
|
127
|
+
for (const cmd of [
|
|
128
|
+
'Git stash',
|
|
129
|
+
'GIT branch -D task-1',
|
|
130
|
+
'git.exe worktree remove ../wt',
|
|
131
|
+
'GIT.EXE push origin main',
|
|
132
|
+
'g`it push origin main',
|
|
133
|
+
'Git.exe -C ../wt stash',
|
|
134
|
+
]) {
|
|
135
|
+
const r = pwsh(cmd);
|
|
136
|
+
assert.equal(r.status, 2, `PowerShell spelling went unguarded: ${cmd}`);
|
|
137
|
+
assert.match(r.stderr, /git-guard: blocked/);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Git Bash is the `Bash` tool name on a case-insensitive Windows filesystem, where
|
|
142
|
+
// `Git stash` and `git.exe stash` both resolve and run. The executable widening is
|
|
143
|
+
// therefore not PowerShell-only. The backtick is: it is PowerShell's escape
|
|
144
|
+
// character, and in Bash it opens a command substitution instead, so stripping it
|
|
145
|
+
// there would invent a git invocation the shell never runs.
|
|
146
|
+
test('the executable widening covers Bash too, but the backtick rule does not', () => {
|
|
147
|
+
for (const cmd of ['Git stash', 'git.exe push origin main', 'GIT worktree remove ../wt']) {
|
|
148
|
+
assert.equal(bash(cmd).status, 2, `Git-Bash spelling went unguarded: ${cmd}`);
|
|
149
|
+
}
|
|
150
|
+
assert.equal(bash('g`it push origin main').status, 0, 'a backtick is not an escape in Bash');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Only the executable token folds. `git Stash` is an error git itself refuses, and
|
|
154
|
+
// `-d` vs `-D` is the entire point of the fourth rule — folding the flags would
|
|
155
|
+
// block the safe delete and make the guard useless.
|
|
156
|
+
test('subcommands and flags stay case-sensitive', () => {
|
|
157
|
+
for (const cmd of [
|
|
158
|
+
'Git Stash',
|
|
159
|
+
'GIT STASH',
|
|
160
|
+
'Git branch -d merged-branch',
|
|
161
|
+
'git.exe PUSH origin main',
|
|
162
|
+
'Git WORKTREE remove ../wt',
|
|
163
|
+
]) {
|
|
164
|
+
for (const run of [bash, pwsh]) {
|
|
165
|
+
assert.equal(run(cmd).status, 0, `folded a subcommand or flag it must not fold: ${cmd}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The read-only and ordinary shapes keep passing in every spelling: widening the
|
|
171
|
+
// executable must not cost the guard its false-positive floor.
|
|
172
|
+
test('the widened executable keeps the false-positive floor in every spelling', () => {
|
|
173
|
+
for (const cmd of [
|
|
174
|
+
'Git stash list',
|
|
175
|
+
'git.exe status',
|
|
176
|
+
'GIT push origin task-1004028',
|
|
177
|
+
'Git branch -d merged',
|
|
178
|
+
'git.exe worktree list --porcelain',
|
|
179
|
+
'Github Actions rerun',
|
|
180
|
+
'gitk --all',
|
|
181
|
+
'node scripts/gds/worktree.js remove wt-1',
|
|
182
|
+
]) {
|
|
183
|
+
for (const run of [bash, pwsh]) {
|
|
184
|
+
assert.equal(run(cmd).status, 0, `false positive on: ${cmd}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
121
189
|
test('a non-shell tool and unknown payload shapes still fail open', () => {
|
|
122
190
|
assert.equal(
|
|
123
191
|
runHook(JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'git stash' } })).status,
|
|
@@ -105,6 +105,13 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
105
105
|
const pager = (prefix) => ({ prefix, page: 1, perPage: 25 });
|
|
106
106
|
const windowObj = {
|
|
107
107
|
OTB: {
|
|
108
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
109
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
110
|
+
roleAxisLabel: () => 'Role',
|
|
111
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
112
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
113
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
114
|
+
ROLE_UNSET: 'unclassified',
|
|
108
115
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])),
|
|
109
116
|
$, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
|
|
110
117
|
toast: (m) => calls.toasts.push(m),
|
|
@@ -142,6 +142,13 @@ function harness({ pendingRequests = [] } = {}) {
|
|
|
142
142
|
scrollTo() {}, addEventListener() {},
|
|
143
143
|
matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
|
|
144
144
|
OTB: {
|
|
145
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
146
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
147
|
+
roleAxisLabel: () => 'Role',
|
|
148
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
149
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
150
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
151
|
+
ROLE_UNSET: 'unclassified',
|
|
145
152
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
146
153
|
$: (sel) => documentObj.querySelector(sel),
|
|
147
154
|
fmtNum: (n) => String(n ?? ''),
|
|
@@ -115,8 +115,12 @@ await test('a filter control may mix string and {value,label} options — the ki
|
|
|
115
115
|
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
116
116
|
assert.match(kit, /function optVal\(o\) \{ return typeof o === 'string' \? o : o\.value; \}/);
|
|
117
117
|
assert.match(kit, /function optLabel\(o\) \{ return typeof o === 'string' \? o : o\.label; \}/);
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// task 1003936 moved f-discipline off the literal list and onto OTB.roleOptions(),
|
|
119
|
+
// so the live example of the MIXED form is now f-goal-status. The invariant under
|
|
120
|
+
// test is the kit's normalisation, not which control happens to exercise it.
|
|
121
|
+
const GOALS_SRC = read('modules/hall-ui/public/goals-page.js');
|
|
122
|
+
assert.match(GOALS_SRC, /id: 'f-goal-status',[\s\S]{0,120}'open', 'achieved', 'archived'/,
|
|
123
|
+
'the mixed form is still live on a real control');
|
|
120
124
|
});
|
|
121
125
|
|
|
122
126
|
// ---- the payload, which is where the scale actually bit ---------------------
|
package/tests/goal_subtitle.mjs
CHANGED
|
@@ -184,6 +184,27 @@ test('an unset subtitle is hidden; a set one is shown trimmed', () => {
|
|
|
184
184
|
assert.equal(els.sub.textContent, 'the grep path');
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
// goalTitleRegions — where the row's title ends and the card's begins, as an
|
|
188
|
+
// ANCHOR rather than a byte count (task 1004036).
|
|
189
|
+
//
|
|
190
|
+
// The assertion below and the vacuity guard further down BOTH derive the row's
|
|
191
|
+
// region from here, deliberately. The previous shape asserted subtitleAttr(g)
|
|
192
|
+
// absent from a 160-char window while the row's real region measured 766 — 21%
|
|
193
|
+
// of the span it claimed to check — and a negative assertion passes when it
|
|
194
|
+
// finds nothing, which is also what it does when it is looking in the wrong
|
|
195
|
+
// place. Injecting the tooltip into the row left it passing.
|
|
196
|
+
//
|
|
197
|
+
// One derivation, not two: a guard that recomputed the region itself would stay
|
|
198
|
+
// green while the assertion it claims to protect was narrowed back. Narrow this
|
|
199
|
+
// function and the guard fails, which is the only arrangement that makes the
|
|
200
|
+
// guard worth having.
|
|
201
|
+
function goalTitleRegions(js) {
|
|
202
|
+
const row = js.indexOf('class="gr-title"');
|
|
203
|
+
const card = js.indexOf('class="goal-card__title gr-title"');
|
|
204
|
+
assert.ok(card > row, 'card title boundary not found — re-anchor this slice to whatever now follows the row title');
|
|
205
|
+
return { row, card, rowRegion: js.slice(row, card) };
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
test('on a LIST the subtitle is a visible line; on the CARD it stays a tooltip', () => {
|
|
188
209
|
// task 1003206: the row/card painters moved to goals-render.js — the second half
|
|
189
210
|
// of the carve goals-lib.js began — so read the family, not one filename.
|
|
@@ -197,15 +218,38 @@ test('on a LIST the subtitle is a visible line; on the CARD it stays a tooltip',
|
|
|
197
218
|
// task 1003307 gave the ROW its line back on the owner's call: 35 of 72 goals
|
|
198
219
|
// carry a subtitle, and a tooltip does not exist on a touch screen. The CARD
|
|
199
220
|
// has no room for a line, so it keeps the tooltip (task 1003224's shape).
|
|
200
|
-
const row = js
|
|
221
|
+
const { row, card, rowRegion } = goalTitleRegions(js);
|
|
201
222
|
assert.ok(row > 0, 'the row title is still rendered');
|
|
202
|
-
assert.match(js.slice(row, row + 200), /subtitleLine\(g\)/, 'the row renders the visible line');
|
|
203
|
-
assert.doesNotMatch(js.slice(row, row + 160), /subtitleAttr\(g\)/, 'the row does not also carry the tooltip');
|
|
204
|
-
const card = js.indexOf('class="goal-card__title gr-title"');
|
|
205
223
|
assert.ok(card > 0, 'the card title is still rendered');
|
|
224
|
+
assert.match(rowRegion, /subtitleLine\(g\)/, 'the row renders the visible line');
|
|
225
|
+
assert.doesNotMatch(rowRegion, /subtitleAttr\(g\)/, 'the row does not also carry the tooltip');
|
|
206
226
|
assert.match(js.slice(card, card + 160), /subtitleAttr\(g\)/, 'the card carries the tooltip');
|
|
207
227
|
});
|
|
208
228
|
|
|
229
|
+
// ---- the negative assertion above is not vacuous (task 1004036) -------------
|
|
230
|
+
// A negative assertion passes when it finds nothing, which is also what it does
|
|
231
|
+
// when it is looking in the wrong place — so one that has never been watched
|
|
232
|
+
// FAIL is not evidence of anything. The previous shape checked subtitleAttr(g)
|
|
233
|
+
// was absent from a 160-char window while the row's real region measured 766,
|
|
234
|
+
// so it verified 21% of the span it claimed to; injecting the tooltip into the
|
|
235
|
+
// row left it passing. This case does that injection and requires the anchored
|
|
236
|
+
// region to catch it. It guards the ASSERTION, not the page.
|
|
237
|
+
test('the row/tooltip check CATCHES a row that carries the tooltip', () => {
|
|
238
|
+
const js = goalsPageSource();
|
|
239
|
+
const { row, card } = goalTitleRegions(js);
|
|
240
|
+
|
|
241
|
+
// Inject the forbidden attribute INSIDE the row's region but past the old
|
|
242
|
+
// 160-char window — the exact regression the assertion exists to catch.
|
|
243
|
+
const at = row + Math.floor((card - row) / 2);
|
|
244
|
+
const broken = js.slice(0, at) + ' title="${subtitleAttr(g)}" ' + js.slice(at);
|
|
245
|
+
|
|
246
|
+
// Through the SAME derivation the assertion uses, so narrowing it reds here.
|
|
247
|
+
assert.throws(
|
|
248
|
+
() => assert.doesNotMatch(goalTitleRegions(broken).rowRegion, /subtitleAttr\(g\)/),
|
|
249
|
+
'the row region does not notice a tooltip added to the row — the check is vacuous again',
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
209
253
|
test('every CSS token the new styles use actually exists', () => {
|
|
210
254
|
// copy-desk.css shipped with --line and --panel, which style.css never
|
|
211
255
|
// defines, so its fallbacks rendered as nothing (idea 1000821).
|
|
@@ -534,7 +534,10 @@ test('a backlog row names its craft and its goal', () => {
|
|
|
534
534
|
const backlog = workJs.slice(workJs.indexOf('function renderBacklog'));
|
|
535
535
|
// The same vocabulary the Ready card has carried since 1755 — a second spelling
|
|
536
536
|
// would let the two tabs describe one task differently.
|
|
537
|
-
|
|
537
|
+
// task 1003936: the backlog row reads the ONE role vocabulary instead of the
|
|
538
|
+
// stored default. Same invariant (the row carries a role tag), new spelling.
|
|
539
|
+
assert.match(backlog, /window\.OTB\.roleIsUnset\(t\.discipline\)/);
|
|
540
|
+
assert.match(backlog, /window\.OTB\.roleLabel\(t\.discipline\)/);
|
|
538
541
|
assert.match(backlog, /disciplineTag/, 'the row renders the discipline tag');
|
|
539
542
|
assert.match(backlog, /taskState\.goalTitles \|\| \{\}/, 'the goal is named by TITLE, not by bare id');
|
|
540
543
|
assert.match(backlog, /'No goal'/, 'and a goal-less row says so rather than rendering a gap');
|
|
@@ -65,6 +65,13 @@ function hallRender(joinability) {
|
|
|
65
65
|
const sandbox = {
|
|
66
66
|
window: {
|
|
67
67
|
OTB: {
|
|
68
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
69
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
70
|
+
roleAxisLabel: () => 'Role',
|
|
71
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
72
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
73
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
74
|
+
ROLE_UNSET: 'unclassified',
|
|
68
75
|
escapeHtml,
|
|
69
76
|
$: (sel) => nodes[sel] || null,
|
|
70
77
|
fmtNum: (n) => String(n),
|
|
@@ -68,6 +68,13 @@ async function boot({ meStatus }) {
|
|
|
68
68
|
__MODULES__: { enabled: {} },
|
|
69
69
|
BongosClient: { createClient: () => api },
|
|
70
70
|
OTB: {
|
|
71
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
72
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
73
|
+
roleAxisLabel: () => 'Role',
|
|
74
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
75
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
76
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
77
|
+
ROLE_UNSET: 'unclassified',
|
|
71
78
|
moduleOn: () => true,
|
|
72
79
|
escapeHtml: (s) => String(s == null ? '' : s),
|
|
73
80
|
$: () => makeEl(),
|
|
@@ -154,6 +154,13 @@ function bootCharts() {
|
|
|
154
154
|
window: {
|
|
155
155
|
__BRANDING__: { currency: { label: 'drachmae' } },
|
|
156
156
|
OTB: {
|
|
157
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
158
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
159
|
+
roleAxisLabel: () => 'Role',
|
|
160
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
161
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
162
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
163
|
+
ROLE_UNSET: 'unclassified',
|
|
157
164
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
158
165
|
$: () => null,
|
|
159
166
|
fmtNum: (n) => String(n ?? 0),
|
|
@@ -220,10 +227,14 @@ test('craftMix sorts by count and folds the tail past seven into "other" — nev
|
|
|
220
227
|
const rows = ['a', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', null].map((d) => ({ discipline: d }));
|
|
221
228
|
const mix = plain(C.craftMix(rows));
|
|
222
229
|
assert.equal(mix.length, 7);
|
|
223
|
-
|
|
230
|
+
// task 1003936: the bucket label is now the DISPLAY name (OTB.roleLabel), so a
|
|
231
|
+
// synthetic 'a' reads 'A'. The invariant under test is the sort and the fold.
|
|
232
|
+
assert.deepEqual(mix[0], { label: 'A', n: 2 });
|
|
224
233
|
assert.equal(mix[6].label, 'other');
|
|
225
234
|
assert.equal(mix.reduce((s, k) => s + k.n, 0), rows.length, 'nothing is lost in the fold');
|
|
226
|
-
|
|
235
|
+
// task 1003936: a missing role is still its own bucket, but the LABEL is a
|
|
236
|
+
// phrase a person would say, not the column default (docs/voice.md V2).
|
|
237
|
+
assert.ok(C.craftMix([{ discipline: null }]).some((k) => k.label === 'No role yet'), 'a missing role reads as "No role yet"');
|
|
227
238
|
assert.equal(C.craftMix(rows.slice(0, 3)).length, 2, 'under the cap there is no fold');
|
|
228
239
|
});
|
|
229
240
|
|
|
@@ -288,6 +299,13 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
|
|
|
288
299
|
__BRANDING__: { currency: { label: 'drachmae' } },
|
|
289
300
|
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
290
301
|
OTB: {
|
|
302
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
303
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
304
|
+
roleAxisLabel: () => 'Role',
|
|
305
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
306
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
307
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
308
|
+
ROLE_UNSET: 'unclassified',
|
|
291
309
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
292
310
|
$: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
|
|
293
311
|
fmtNum: (n) => String(n ?? 0),
|
|
@@ -347,6 +347,13 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
347
347
|
__BRANDING__: { currency: { label: 'credits' } }, __MODULES__: { enabled: {} },
|
|
348
348
|
BongosClient: { createClient: () => client },
|
|
349
349
|
OTB: {
|
|
350
|
+
// task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
|
|
351
|
+
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
352
|
+
roleAxisLabel: () => 'Role',
|
|
353
|
+
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
354
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
|
|
355
|
+
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
356
|
+
ROLE_UNSET: 'unclassified',
|
|
350
357
|
escapeHtml,
|
|
351
358
|
$: (sel) => documentObj.querySelector(sel),
|
|
352
359
|
fmtDate: (iso) => String(iso).slice(0, 10),
|