@bongos/core 1.19.989 → 1.19.991
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 +48 -28
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +13 -13
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/core-release-pipeline.md +5 -0
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/hall-ui/public/goal-forest-lib.js +1 -47
- package/modules/hall-ui/public/goal-forest-render.js +2 -1
- package/modules/hall-ui/public/goal-map-lib.js +2 -9
- package/modules/hall-ui/public/goal-map.html +2 -0
- package/modules/hall-ui/public/goal-map.js +28 -5
- package/modules/hall-ui/public/goals.html +2 -0
- package/modules/hall-ui/public/text-fit.js +58 -0
- package/modules/lifecycle/routes/artist-gate.js +24 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/upgrade-artist-gate.js +46 -0
- package/scripts/gds/upgrade.js +13 -15
- package/src/module-api.js +1 -1
- package/tests/artist_gate_route.mjs +70 -1
- package/tests/goal_forest_lib.mjs +0 -61
- package/tests/goal_forest_page.mjs +2 -0
- package/tests/goal_map_page.mjs +31 -0
- package/tests/text_fit.mjs +91 -0
- package/tests/upgrade_artist_gate.mjs +144 -0
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
28
28
|
const kit = window.OTBKit;
|
|
29
29
|
const lib = window.OTBGoalMapLib;
|
|
30
|
+
const textFit = window.OTBTextFit;
|
|
30
31
|
const { escapeHtml } = window.OTB;
|
|
31
32
|
const $ = (id) => document.getElementById(id);
|
|
32
33
|
|
|
@@ -47,7 +48,18 @@
|
|
|
47
48
|
|
|
48
49
|
// ---- the map -----------------------------------------------------------------
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// Node text is fitted to MEASURED width, not a character count (task 1004204;
|
|
52
|
+
// the forest's fix, task 1004200). Measuring needs the svg in the document, so
|
|
53
|
+
// drawGraph collects each line into `lines` and renderGraph fits them after
|
|
54
|
+
// appending. The clipPath is the backstop for a map drawn while hidden.
|
|
55
|
+
const TEXT_X = 16;
|
|
56
|
+
function fitNodeText(lines, maxWidth) {
|
|
57
|
+
lines.forEach(({ el, full }) => {
|
|
58
|
+
el.textContent = textFit.fitText(full, maxWidth, (s) => { el.textContent = s; return el.getComputedTextLength(); });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function drawGraph(a, lay, cats, lines) {
|
|
51
63
|
const svg = svgEl('svg', {
|
|
52
64
|
class: 'gm-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
|
|
53
65
|
role: 'img', 'aria-labelledby': 'gm-cap', focusable: 'false',
|
|
@@ -55,6 +67,8 @@
|
|
|
55
67
|
const defs = svgEl('defs', {}, svg);
|
|
56
68
|
const marker = svgEl('marker', { id: 'gm-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
|
|
57
69
|
svgEl('path', { class: 'gm-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
|
|
70
|
+
const clip = svgEl('clipPath', { id: 'gm-text-clip' }, defs);
|
|
71
|
+
svgEl('rect', { width: lay.nodeW, height: lay.nodeH }, clip);
|
|
58
72
|
|
|
59
73
|
const edges = svgEl('g', { class: 'gm-edges' }, svg);
|
|
60
74
|
lay.edges.forEach((e) => {
|
|
@@ -78,9 +92,12 @@
|
|
|
78
92
|
+ ` — waits on ${lib.plural(n.depends_on || 0, 'goal')}, needed by ${lib.plural(n.depended_on_by || 0, 'goal')}`;
|
|
79
93
|
svgEl('rect', { class: 'gm-card', width: p.w, height: p.h, rx: 8 }, g);
|
|
80
94
|
svgEl('rect', { class: 'gm-stripe', width: 6, height: p.h, rx: 3 }, g);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
[['gm-name', 22, lib.displayTitle(n)], ['gm-cat', 38, cat], ['gm-tasks', 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`]]
|
|
96
|
+
.forEach(([cls, y, full]) => {
|
|
97
|
+
const el = text(g, cls, TEXT_X, y, full);
|
|
98
|
+
el.setAttribute('clip-path', 'url(#gm-text-clip)');
|
|
99
|
+
lines.push({ el, full });
|
|
100
|
+
});
|
|
84
101
|
const trackW = p.w - 32;
|
|
85
102
|
svgEl('rect', { class: 'gm-weight-track', x: 16, y: 60, width: trackW, height: 3, rx: 1.5 }, g);
|
|
86
103
|
const frac = lib.weightOf(n.task_total, maxTotal);
|
|
@@ -103,7 +120,13 @@
|
|
|
103
120
|
</figcaption>
|
|
104
121
|
<div class="gm-scroll" id="gm-scroll" tabindex="0" role="region" aria-label="Goal map — scrolls sideways inside this box"></div>
|
|
105
122
|
</figure>`;
|
|
106
|
-
|
|
123
|
+
const lay = lib.layout(a);
|
|
124
|
+
const lines = [];
|
|
125
|
+
$('gm-scroll').appendChild(drawGraph(a, lay, cats, lines));
|
|
126
|
+
const fit = () => fitNodeText(lines, lay.nodeW - 2 * TEXT_X);
|
|
127
|
+
fit();
|
|
128
|
+
// A web font that lands after the first fit changes every width.
|
|
129
|
+
if (document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(fit);
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
function renderLegend(cats) {
|
|
@@ -249,6 +249,8 @@
|
|
|
249
249
|
<!-- task 1003480: the per-goal dependency forest's pure model + layout —
|
|
250
250
|
goal-map-lib.js's sibling, loaded before the renderer that draws it. -->
|
|
251
251
|
<script src="/builders/goal-forest-lib.js"></script>
|
|
252
|
+
<!-- tasks 1004200/1004204: measured-width title fitting, shared with the goal map. -->
|
|
253
|
+
<script src="/builders/text-fit.js"></script>
|
|
252
254
|
<!-- task 1003480: the forest's own drawing module — goal-map.js's sibling,
|
|
253
255
|
carved out of goals-render.js to keep that file under the size ratchet.
|
|
254
256
|
Loaded before goals-render.js, which calls window.OTBGoalForestRender(ctx). -->
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// text-fit.js — fit a line of text to a MEASURED width (task 1004200, shared by
|
|
2
|
+
// task 1004204). Proportional glyphs vary too much for any character budget to
|
|
3
|
+
// fit a fixed box, so every SVG card title is cut by its measured width instead.
|
|
4
|
+
// `measure` is always the caller's (getComputedTextLength in the page, a fake in
|
|
5
|
+
// tests), so this file stays DOM-free. Loaded by both the goal forest and the
|
|
6
|
+
// goal map.
|
|
7
|
+
(function (root, factory) {
|
|
8
|
+
var mod = factory();
|
|
9
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = mod;
|
|
10
|
+
else root.OTBTextFit = mod;
|
|
11
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// The longest code-point prefix of `chars` that measures within maxWidth with
|
|
15
|
+
// `suffix` appended ('' if none does). Code points, so an emoji is never split.
|
|
16
|
+
function longestPrefix(chars, maxWidth, measure, suffix) {
|
|
17
|
+
var best = '';
|
|
18
|
+
var lo = 1;
|
|
19
|
+
var hi = chars.length;
|
|
20
|
+
while (lo <= hi) {
|
|
21
|
+
var mid = (lo + hi) >> 1;
|
|
22
|
+
var cand = chars.slice(0, mid).join('').replace(/\s+$/, '');
|
|
23
|
+
if (measure(cand + suffix) <= maxWidth) { best = cand; lo = mid + 1; } else { hi = mid - 1; }
|
|
24
|
+
}
|
|
25
|
+
return best;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// fitText — `text` whole if it fits, else its longest prefix that fits with an ellipsis.
|
|
29
|
+
function fitText(text, maxWidth, measure) {
|
|
30
|
+
var t = String(text == null ? '' : text);
|
|
31
|
+
if (measure(t) <= maxWidth) return t;
|
|
32
|
+
return longestPrefix(Array.from(t), maxWidth, measure, '…') + '…';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// fitLines — `text` wrapped at spaces into at most `maxLines` lines of maxWidth,
|
|
36
|
+
// the last ellipsised when the rest will not fit. A word wider than a whole
|
|
37
|
+
// line is broken where it runs out of room, the way the browser would.
|
|
38
|
+
function fitLines(text, maxWidth, maxLines, measure) {
|
|
39
|
+
var rest = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
40
|
+
var lines = [];
|
|
41
|
+
while (rest && lines.length < maxLines - 1 && measure(rest) > maxWidth) {
|
|
42
|
+
var words = rest.split(' ');
|
|
43
|
+
var line = '';
|
|
44
|
+
for (var i = 0; i < words.length; i++) {
|
|
45
|
+
var next = line ? line + ' ' + words[i] : words[i];
|
|
46
|
+
if (measure(next) > maxWidth) break;
|
|
47
|
+
line = next;
|
|
48
|
+
}
|
|
49
|
+
if (!line) line = longestPrefix(Array.from(words[0]), maxWidth, measure, '') || Array.from(words[0])[0];
|
|
50
|
+
lines.push(line);
|
|
51
|
+
rest = rest.slice(line.length).trim();
|
|
52
|
+
}
|
|
53
|
+
if (rest) lines.push(fitText(rest, maxWidth, measure));
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { fitText: fitText, fitLines: fitLines };
|
|
58
|
+
}));
|
|
@@ -50,7 +50,12 @@ module.exports = function buildArtistGateRouter() {
|
|
|
50
50
|
// second guard is that the refusal must name what holds the deploy and which
|
|
51
51
|
// control releases it, and a sentence assembled independently by each caller is
|
|
52
52
|
// a sentence that eventually says something different. One composer, one wording.
|
|
53
|
-
|
|
53
|
+
//
|
|
54
|
+
// A caller on this machine that reached the app DIRECTLY may also read it,
|
|
55
|
+
// without a session (task 1004205; owner decision 2026-09-23): that is how
|
|
56
|
+
// `bongos upgrade` on the box asks before moving the pin, and the box holds no
|
|
57
|
+
// builder session. Everything else still passes requireBuilder.
|
|
58
|
+
router.get('/artist-gate', unlessDirectLocalCall(api.requireBuilder), async (req, res) => {
|
|
54
59
|
try {
|
|
55
60
|
const gate = api.artistGate;
|
|
56
61
|
const mode = gate.artistGateMode();
|
|
@@ -99,3 +104,21 @@ module.exports = function buildArtistGateRouter() {
|
|
|
99
104
|
|
|
100
105
|
return router;
|
|
101
106
|
};
|
|
107
|
+
|
|
108
|
+
// A request from this machine with NO proxy hop. Public traffic always reaches
|
|
109
|
+
// the app through the proxy (Caddy, behind Cloudflare), which sets a forwarding
|
|
110
|
+
// header, so a loopback peer without one is a local process — the rule
|
|
111
|
+
// src/bongos/metrics.js applies to its local scraper. The payload is ids, dates
|
|
112
|
+
// and the policy level: nothing a process already on the box could not read.
|
|
113
|
+
function isDirectLocalCall(req) {
|
|
114
|
+
const h = req.headers || {};
|
|
115
|
+
if (h['x-forwarded-for'] || h['x-real-ip'] || h.forwarded || h['cf-connecting-ip']) return false;
|
|
116
|
+
const peer = (req.socket && req.socket.remoteAddress) || '';
|
|
117
|
+
return peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Runs `requireSession` for everyone except a direct local call (above), which
|
|
121
|
+
// goes straight to the handler with no builder attached.
|
|
122
|
+
function unlessDirectLocalCall(requireSession) {
|
|
123
|
+
return (req, res, next) => (isDirectLocalCall(req) ? next() : requireSession(req, res, next));
|
|
124
|
+
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.991",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.991",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.991",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/upgrade-artist-gate.js — the artist gate's deploy hold on the
|
|
4
|
+
// UPGRADE path (task 1004205; ADR 0241).
|
|
5
|
+
//
|
|
6
|
+
// WHY. On a `strict` project an unresolved artist review holds the DEPLOY, never
|
|
7
|
+
// the ship. Only go-live.js asked the gate, and cloudbongos.com moves its pin
|
|
8
|
+
// with the vendored `upgrade.js --from` instead, so on this instance an open
|
|
9
|
+
// review held nothing (2026-09-23: the gate answered holds:true and the upgrade
|
|
10
|
+
// never read it). Every UI change now files such a review (owner decision
|
|
11
|
+
// 2026-09-23, task 1004201), so this is where "the owner sees it before users
|
|
12
|
+
// do" is actually enforced.
|
|
13
|
+
//
|
|
14
|
+
// It reuses go-live.js's reader and judgement verbatim, so both deploy paths
|
|
15
|
+
// decide the same answer the same way: a hold or a broken gate halts, a gate
|
|
16
|
+
// that cannot be read at all is a note. It asks the instance being upgraded,
|
|
17
|
+
// at the origin of --health-url, because the box runs the instance it upgrades;
|
|
18
|
+
// the instance answers a direct local request without a session (the route).
|
|
19
|
+
// The FIRST upgrade carrying this asks an instance that still wants a session,
|
|
20
|
+
// so it can only print the unread note — every upgrade after that holds.
|
|
21
|
+
// Kept out of upgrade.js, which sits at the line cap.
|
|
22
|
+
|
|
23
|
+
const { readArtistGate, artistGateEntries } = require('./go-live');
|
|
24
|
+
|
|
25
|
+
const GATE_TIMEOUT_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
function unreadNote(why) {
|
|
28
|
+
return { halts: [], notes: [{ code: 'ARTIST_GATE_UNREAD', message: `could not read this instance's artist gate (${why}) — if the project runs artist_gate 'strict', an unresolved artist review may be holding this deploy and this upgrade cannot see it` }] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// checkArtistGate — { halts, notes } for this instance's gate, go-live's shape.
|
|
32
|
+
async function checkArtistGate({ healthUrl }, deps = {}) {
|
|
33
|
+
if (!healthUrl) return unreadNote('no --health-url to reach the instance by');
|
|
34
|
+
let origin;
|
|
35
|
+
try { origin = new URL(healthUrl).origin; } catch (_) { return unreadNote(`--health-url ${healthUrl} is not a URL`); }
|
|
36
|
+
const fetchImpl = deps.fetchImpl || fetch;
|
|
37
|
+
const call = async (method, urlPath) => {
|
|
38
|
+
const res = await fetchImpl(`${origin}${urlPath}`, { method, signal: AbortSignal.timeout(GATE_TIMEOUT_MS) });
|
|
39
|
+
let data = null;
|
|
40
|
+
try { data = await res.json(); } catch (_) { /* not JSON: readArtistGate reads it as "not a gate" */ }
|
|
41
|
+
return { status: res.status, ok: res.ok, data };
|
|
42
|
+
};
|
|
43
|
+
return artistGateEntries(await readArtistGate(call));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { checkArtistGate };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Turns the former stub (which only ran a module-vs-core compatibility PRE-CHECK) into the real
|
|
6
6
|
// channel a two-repo consumer uses to pull the Cloud Bongos core from version N to N+1:
|
|
7
7
|
//
|
|
8
|
-
// pre-flight clean git tree + module-compat vs the TARGET core version
|
|
8
|
+
// pre-flight clean git tree + module-compat vs the TARGET core version + the artist gate (ADR 0241)
|
|
9
9
|
// → bump pin vendor the new core .tgz, rewrite the package.json `file:` dependency
|
|
10
10
|
// → npm install node_modules/@bongos/core becomes the new version
|
|
11
11
|
// → migrate `npm run migrate` → the core's migrate.sh applies additive `core_*` migrations
|
|
@@ -13,13 +13,10 @@
|
|
|
13
13
|
// → restart restart the instance's service so the new core is live
|
|
14
14
|
// → verify installed package version == target (+ optional /healthz + ledger row)
|
|
15
15
|
//
|
|
16
|
-
// SCOPE (ADR 0100 §2, reshaped by ADR 0103 §4):
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// with `start`/`migrate` npm scripts and a materialized .claude/. Every step below maps to something
|
|
21
|
-
// that consumer actually has. A published-registry source, rollback, and multi-consumer shapes are
|
|
22
|
-
// deliberately OUT — they are follow-ups a second consumer's needs will define.
|
|
16
|
+
// SCOPE (ADR 0100 §2, reshaped by ADR 0103 §4): first built for emersonian-circles (task 1917), an
|
|
17
|
+
// npm-dependency instance pinning "@bongos/core": "file:vendor/bongos-core-<ver>.tgz" with
|
|
18
|
+
// `start`/`migrate` npm scripts and a materialized .claude/; the registry source (ADR 0134) and
|
|
19
|
+
// auto-rollback (task 2149) came later, with the consumers that needed them.
|
|
23
20
|
//
|
|
24
21
|
// Runs from the consumer instance root (default) or `--instance <dir>`. This script ships IN the core
|
|
25
22
|
// package, so once a consumer is on core vN+1 its own `bongos upgrade` is this code. The FIRST bump
|
|
@@ -33,6 +30,7 @@ const crypto = require('node:crypto');
|
|
|
33
30
|
const { spawnSync } = require('node:child_process');
|
|
34
31
|
const { arg, hasFlag } = require('./cli-lib');
|
|
35
32
|
const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
|
|
33
|
+
const { checkArtistGate } = require('./upgrade-artist-gate');
|
|
36
34
|
|
|
37
35
|
const CORE_PKG = '@bongos/core';
|
|
38
36
|
const ARTIFACT = 'bongos-core';
|
|
@@ -1146,13 +1144,13 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1146
1144
|
else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
|
|
1147
1145
|
}
|
|
1148
1146
|
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1147
|
+
// 1a. artist gate (ADR 0241, task 1004205): an open review holds the pin, --force too; a downgrade never waits.
|
|
1148
|
+
const gate = isDowngrade ? { halts: [], notes: [] } : await (deps.checkArtistGate || checkArtistGate)(opts, deps);
|
|
1149
|
+
for (const n of gate.notes) err(` ! ${n.message}`);
|
|
1150
|
+
if (gate.halts.length) return { ok: false, artistGate: true, error: gate.halts.map((h) => [h.message, h.detail].filter(Boolean).join('\n ')).join('\n') };
|
|
1151
|
+
// 1b. mandatory post-restart health check (audit H9 / task 1002222, F5): a bump that restarts the
|
|
1152
|
+
// service must prove it serves, so --health-url is required up front, before anything moves.
|
|
1153
|
+
// (No managed restart ⇒ nothing is brought live here ⇒ the operator restarts + verifies.)
|
|
1156
1154
|
const willRestart = !!(opts.service || opts.restartCmd);
|
|
1157
1155
|
if (!dryRun && opts.healthCheck !== false && willRestart && !opts.healthUrl) {
|
|
1158
1156
|
return { ok: false, error: 'post-restart health check is mandatory for a bump that restarts the service, but no --health-url was given.\n Pass --health-url <url> to confirm the new core serves after restart, or --no-health-check to explicitly skip the confirmation.' };
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.991'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -37,7 +37,12 @@ db.listOpenArtistReviews = async (opts) => { queries.push(opts); return { review
|
|
|
37
37
|
|
|
38
38
|
let mode = 'strict';
|
|
39
39
|
let since = null;
|
|
40
|
-
|
|
40
|
+
let signedIn = true;
|
|
41
|
+
api.requireBuilder = (req, res, next) => {
|
|
42
|
+
if (!signedIn) return res.status(401).json({ error: { code: 'unauthenticated' } });
|
|
43
|
+
req.builder = { id: 7, rank: 'thetes' };
|
|
44
|
+
next();
|
|
45
|
+
};
|
|
41
46
|
// The route must resolve the level through the doorway's ONE reader; stubbing it
|
|
42
47
|
// here is also what proves it does not read branding().project behind its back.
|
|
43
48
|
api.artistGate = { ...gateModule, artistGateMode: () => mode, artistGateSince: () => since };
|
|
@@ -171,4 +176,68 @@ test('a database failure is a 500 with no internals, never a quiet "nothing hold
|
|
|
171
176
|
db.listOpenArtistReviews = async (opts) => { queries.push(opts); return { reviews: rows, truncated: scanTruncated }; };
|
|
172
177
|
});
|
|
173
178
|
|
|
179
|
+
// ---- who may read it without a session (task 1004205) ---------------------------
|
|
180
|
+
|
|
181
|
+
function getWith(path, headers) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
http.get(`${base}${path}`, { headers }, (res) => {
|
|
184
|
+
let d = '';
|
|
185
|
+
res.on('data', (c) => { d += c; });
|
|
186
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j }); });
|
|
187
|
+
}).on('error', reject);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
test('the box asking its OWN instance directly reads the gate with no session — how `bongos upgrade` asks', async () => {
|
|
192
|
+
reset({ open: [review()] });
|
|
193
|
+
signedIn = false;
|
|
194
|
+
try {
|
|
195
|
+
const r = await getWith('/artist-gate', {});
|
|
196
|
+
assert.equal(r.status, 200);
|
|
197
|
+
assert.equal(r.body.holds, true);
|
|
198
|
+
assert.deepEqual(r.body.reviews, [{ id: '9001', created_at: '2026-09-12T00:00:00.000Z' }]);
|
|
199
|
+
} finally { signedIn = true; }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('anything that came through a proxy still needs a session — public traffic never rides the local exception', async () => {
|
|
203
|
+
reset({ open: [review()] });
|
|
204
|
+
signedIn = false;
|
|
205
|
+
try {
|
|
206
|
+
for (const headers of [
|
|
207
|
+
{ 'X-Forwarded-For': '203.0.113.9' },
|
|
208
|
+
{ 'X-Real-IP': '203.0.113.9' },
|
|
209
|
+
{ Forwarded: 'for=203.0.113.9' },
|
|
210
|
+
{ 'CF-Connecting-IP': '203.0.113.9' },
|
|
211
|
+
]) {
|
|
212
|
+
const r = await getWith('/artist-gate', headers);
|
|
213
|
+
assert.equal(r.status, 401, JSON.stringify(headers));
|
|
214
|
+
}
|
|
215
|
+
} finally { signedIn = true; }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a peer that is not this machine is not local, forwarding header or not — it needs a session', async () => {
|
|
219
|
+
reset({ open: [review()] });
|
|
220
|
+
signedIn = false;
|
|
221
|
+
try {
|
|
222
|
+
const out = await new Promise((resolve) => {
|
|
223
|
+
const r = { statusCode: 200 };
|
|
224
|
+
const res = {
|
|
225
|
+
status(c) { r.statusCode = c; return this; },
|
|
226
|
+
json(b) { r.body = b; resolve(r); return this; },
|
|
227
|
+
set() { return this; },
|
|
228
|
+
fail(code, o) { r.statusCode = (o && o.status) || 400; resolve(r); },
|
|
229
|
+
};
|
|
230
|
+
buildRouter().handle({ method: 'GET', url: '/artist-gate', headers: {}, socket: { remoteAddress: '203.0.113.9' } }, res, () => resolve({ statusCode: 404 }));
|
|
231
|
+
});
|
|
232
|
+
assert.equal(out.statusCode, 401);
|
|
233
|
+
} finally { signedIn = true; }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a signed-in builder behind the proxy reads it exactly as before', async () => {
|
|
237
|
+
reset({ open: [review()] });
|
|
238
|
+
const r = await getWith('/artist-gate', { 'X-Forwarded-For': '203.0.113.9' });
|
|
239
|
+
assert.equal(r.status, 200);
|
|
240
|
+
assert.equal(r.body.holds, true);
|
|
241
|
+
});
|
|
242
|
+
|
|
174
243
|
test.after(() => server.close());
|
|
@@ -141,67 +141,6 @@ await test('describe states the numbers plainly, and an empty forest says so', (
|
|
|
141
141
|
assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
// ---- fitting a title to its card (task 1004200) ------------------------------------
|
|
145
|
-
|
|
146
|
-
// A proportional fake: wide capitals cost three times a narrow letter, so any
|
|
147
|
-
// character budget that fits one title overflows the other.
|
|
148
|
-
const WIDTHS = { W: 12, M: 11, i: 3, '…': 8, '🚀': 14 };
|
|
149
|
-
const measure = (s) => Array.from(s).reduce((w, c) => w + (WIDTHS[c] ?? 6), 0);
|
|
150
|
-
|
|
151
|
-
await test('fitText leaves a title that fits untouched', () => {
|
|
152
|
-
assert.equal(L.fitText('#7 Fix', 136, measure), '#7 Fix');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
await test('fitText cuts to the MEASURED width, so wide glyphs keep fewer characters than narrow ones', () => {
|
|
156
|
-
const wide = L.fitText('W'.repeat(40), 136, measure);
|
|
157
|
-
const narrow = L.fitText('i'.repeat(80), 136, measure);
|
|
158
|
-
for (const s of [wide, narrow]) {
|
|
159
|
-
assert.ok(measure(s) <= 136, `${s} measures ${measure(s)}, past the box`);
|
|
160
|
-
assert.ok(s.endsWith('…'), 'a cut title says it was cut');
|
|
161
|
-
}
|
|
162
|
-
assert.ok(Array.from(narrow).length > 3 * Array.from(wide).length, 'no fixed character count could produce both');
|
|
163
|
-
assert.equal(wide, 'W'.repeat(10) + '…', 'the LONGEST prefix that fits: 10 W + … = 128px, 11 would be 140px');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
await test('fitText never ends on a dangling space and never splits an emoji', () => {
|
|
167
|
-
const spaced = L.fitText('ab cd ef gh ij kl mn op qr', 62, measure);
|
|
168
|
-
assert.doesNotMatch(spaced, /\s…$/);
|
|
169
|
-
const emoji = L.fitText('🚀'.repeat(30), 60, measure);
|
|
170
|
-
assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji), false, 'no lone high surrogate');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
await test('fitText returns a bare ellipsis when not even one character fits', () => {
|
|
174
|
-
assert.equal(L.fitText('WWW', 10, measure), '…');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
await test('fitLines keeps a short title on one line, whole', () => {
|
|
178
|
-
assert.deepEqual(L.fitLines('#7 Fix', 136, 2, measure), ['#7 Fix']);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
await test('fitLines wraps at a space, and a second line that fits is left whole', () => {
|
|
182
|
-
assert.deepEqual(L.fitLines('aaaa bbbb cccc dddd', 60, 2, measure), ['aaaa bbbb', 'cccc dddd']);
|
|
183
|
-
assert.deepEqual(L.fitLines('a b\n c', 60, 2, measure), ['a b c'], 'runs of whitespace read as one space');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
await test('fitLines ellipsises the LAST line when the title outruns every line, and no line overflows', () => {
|
|
187
|
-
const lines = L.fitLines('BV1.R26 — Real governance: the constitution becomes enforceable at last', 136, 2, measure);
|
|
188
|
-
assert.equal(lines.length, 2);
|
|
189
|
-
for (const l of lines) assert.ok(measure(l) <= 136, `"${l}" measures ${measure(l)}`);
|
|
190
|
-
assert.ok(lines[1].endsWith('…'));
|
|
191
|
-
assert.ok(!lines[0].endsWith('…'), 'only the last line is cut');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
await test('fitLines breaks a word wider than a whole line instead of overflowing, and always finishes', () => {
|
|
195
|
-
const lines = L.fitLines('W'.repeat(40), 136, 2, measure);
|
|
196
|
-
assert.deepEqual(lines, ['W'.repeat(11), 'W'.repeat(10) + '…']);
|
|
197
|
-
assert.ok(L.fitLines('WWW', 5, 2, measure).length <= 2, 'a box narrower than one glyph still terminates');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
await test('fitLines with one line is fitText', () => {
|
|
201
|
-
const t = 'Move the remaining hall pages onto the kit';
|
|
202
|
-
assert.deepEqual(L.fitLines(t, 90, 1, measure), [L.fitText(t, 90, measure)]);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
144
|
// ---- purity -----------------------------------------------------------------------
|
|
206
145
|
|
|
207
146
|
await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
|
|
@@ -25,6 +25,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
25
25
|
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
26
26
|
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
27
27
|
const LIB_JS = read('goal-forest-lib.js');
|
|
28
|
+
const FIT_JS = read('text-fit.js');
|
|
28
29
|
const FOREST_RENDER_JS = read('goal-forest-render.js');
|
|
29
30
|
const RENDER_JS = read('goals-render.js');
|
|
30
31
|
|
|
@@ -74,6 +75,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
74
75
|
sandbox.globalThis = sandbox;
|
|
75
76
|
vm.createContext(sandbox);
|
|
76
77
|
vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
|
|
78
|
+
vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
|
|
77
79
|
vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
|
|
78
80
|
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
79
81
|
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import vm from 'node:vm';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
23
24
|
import { makeRunner, makeEl, walk } from './helpers.mjs';
|
|
24
25
|
|
|
25
26
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -27,6 +28,7 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
|
27
28
|
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
28
29
|
const KIT_JS = read('hall-kit.js');
|
|
29
30
|
const LIB_JS = read('goal-map-lib.js');
|
|
31
|
+
const FIT_JS = read('text-fit.js');
|
|
30
32
|
const PAGE_JS = read('goal-map.js');
|
|
31
33
|
const PAGE_HTML = read('goal-map.html');
|
|
32
34
|
const PAGE_CSS = read('goal-map.css');
|
|
@@ -52,6 +54,8 @@ async function boot(payload, { status = 200, fail = false } = {}) {
|
|
|
52
54
|
const el = makeEl(tag);
|
|
53
55
|
const orig = el.setAttribute.bind(el);
|
|
54
56
|
el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
|
|
57
|
+
// A proportional fake of SVGTextElement's measurement: a W is twice a letter.
|
|
58
|
+
if (tag === 'text') el.getComputedTextLength = () => Array.from(el.textContent).reduce((w, c) => w + (c === 'W' ? 12 : 6), 0);
|
|
55
59
|
return el;
|
|
56
60
|
},
|
|
57
61
|
};
|
|
@@ -77,6 +81,7 @@ async function boot(payload, { status = 200, fail = false } = {}) {
|
|
|
77
81
|
vm.createContext(sandbox);
|
|
78
82
|
vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
|
|
79
83
|
vm.runInContext(LIB_JS, sandbox, { filename: 'goal-map-lib.js' });
|
|
84
|
+
vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
|
|
80
85
|
vm.runInContext(PAGE_JS, sandbox, { filename: 'goal-map.js' });
|
|
81
86
|
await settle();
|
|
82
87
|
const $ = (id) => documentObj.getElementById(id);
|
|
@@ -131,6 +136,31 @@ await test('CATEGORY IS A WORD on every drawn node, not only a colour — and "N
|
|
|
131
136
|
assert.ok(h.$('gm-legend').textContent.includes('Modules'), 'and the legend spells each category out');
|
|
132
137
|
});
|
|
133
138
|
|
|
139
|
+
await test('node text is fitted to its card by MEASURED width, clipped as a backstop, and kept whole in the tooltip and table (task 1004204)', async () => {
|
|
140
|
+
const wide = 'W'.repeat(60);
|
|
141
|
+
const long = 'Agents module — framework, runtime, and the whole event router rewrite';
|
|
142
|
+
const h = await boot({
|
|
143
|
+
nodes: [node(1, { title: wide, depended_on_by: 1 }), node(2, { title: 'Fix', depends_on: 1, category: { slug: 'x', label: long } })],
|
|
144
|
+
edges: [{ from: '2', to: '1', count: 1 }],
|
|
145
|
+
counts: {},
|
|
146
|
+
});
|
|
147
|
+
const lib = createRequire(import.meta.url)(path.join(HALL, 'goal-map-lib.js'));
|
|
148
|
+
const inner = lib.layout(lib.analyse({ nodes: [node(1), node(2)], edges: [] })).nodeW - 32;
|
|
149
|
+
const texts = h.svg().querySelectorAll('.gm-node').flatMap((g) => g.querySelectorAll('text'));
|
|
150
|
+
assert.equal(texts.length, 6, 'name, category and task count on each of two nodes');
|
|
151
|
+
for (const t of texts) {
|
|
152
|
+
assert.ok(t.getComputedTextLength() <= inner, `"${t.textContent}" measures ${t.getComputedTextLength()}px, past the card's ${inner}px`);
|
|
153
|
+
assert.equal(t.getAttribute('clip-path'), 'url(#gm-text-clip)');
|
|
154
|
+
}
|
|
155
|
+
assert.ok(h.svg().querySelector('#gm-text-clip'), 'the clip the lines point at exists');
|
|
156
|
+
const names = h.svg().querySelectorAll('.gm-name').map((t) => t.textContent);
|
|
157
|
+
assert.ok(names.includes('Fix'), 'a title that fits is left whole');
|
|
158
|
+
assert.ok(names.some((n) => /^W+…$/.test(n)), 'a wide title is cut by width and says so');
|
|
159
|
+
assert.ok(h.svg().querySelectorAll('.gm-cat').some((t) => t.textContent.endsWith('…')), 'the category line is fitted too');
|
|
160
|
+
assert.ok(h.svg().querySelectorAll('title').some((x) => x.textContent.includes(wide)), 'the full title is still readable on hover');
|
|
161
|
+
assert.ok(h.$('gm-table').textContent.includes(wide), 'and in the table');
|
|
162
|
+
});
|
|
163
|
+
|
|
134
164
|
// ---- the split is stated, the alone goals are a list ----------------------------------
|
|
135
165
|
|
|
136
166
|
await test('the stand-alone goals are a LIST, never dots, and the split is stated in the page head', async () => {
|
|
@@ -225,6 +255,7 @@ await test('the page has its h1 in static markup (so every state has one) and lo
|
|
|
225
255
|
assert.match(PAGE_HTML, /<h1 class="page-head__h">Goal map<\/h1>/);
|
|
226
256
|
assert.ok(PAGE_HTML.indexOf('goal-map-lib.js') > -1 && PAGE_HTML.indexOf('goal-map-lib.js') < PAGE_HTML.indexOf('goal-map.js'));
|
|
227
257
|
assert.ok(PAGE_HTML.indexOf('hall-kit.js') < PAGE_HTML.indexOf('goal-map.js'));
|
|
258
|
+
assert.ok(PAGE_HTML.indexOf('text-fit.js') > -1 && PAGE_HTML.indexOf('text-fit.js') < PAGE_HTML.indexOf('goal-map.js'));
|
|
228
259
|
});
|
|
229
260
|
|
|
230
261
|
await test('it takes NO nav slot (the sky’s rule) — the Goals page is its door', () => {
|