@bongos/core 1.19.990 → 1.19.992
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 +55 -30
- package/docs/copy-inventory.md +13 -13
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +4 -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/provisioning/error-note.js +33 -0
- package/modules/provisioning/migrations/provisioning_026_error_note_action.sql +20 -0
- package/modules/provisioning/provisioning.js +5 -13
- package/modules/provisioning/routes/provisioning.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +1 -1
- package/scripts/gds/provision-repo-private.js +2 -0
- package/scripts/gds/provision.js +7 -7
- package/src/module-api.js +1 -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/provision_repo_private.mjs +16 -0
- package/tests/provisioning_domain_attach.mjs +11 -0
- package/tests/provisioning_error_note_scope.mjs +147 -0
- package/tests/provisioning_settings_apply.mjs +14 -0
- package/tests/text_fit.mjs +91 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/provisioning_error_note_scope.mjs — the "we stopped trying" note belongs to the
|
|
2
|
+
// action that wrote it (task 1004197; the F19 latch, task 1002720).
|
|
3
|
+
//
|
|
4
|
+
// What is pinned:
|
|
5
|
+
// 1. both retire paths record the retiring intent's action beside the note, through the
|
|
6
|
+
// setInstanceStatus whitelist, and provisioning_026 adds that column idempotently;
|
|
7
|
+
// 2. clearErrorNote releases only its own action's note (or one with no recorded
|
|
8
|
+
// action), in one narrow statement; ownsErrorNote answers the same question for the
|
|
9
|
+
// owner's re-kick doors;
|
|
10
|
+
// 3. every success leg names its own action — no unscoped clear is left.
|
|
11
|
+
// The re-kick doors themselves are pinned in tests/provisioning_settings_apply.mjs and
|
|
12
|
+
// tests/provisioning_domain_attach.mjs.
|
|
13
|
+
//
|
|
14
|
+
// DB-free: CI has no Postgres, so the SQL is exercised through a recording fake.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/provisioning_error_note_scope.mjs
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { makeRunner } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const P = require('../scripts/gds/provision.js');
|
|
27
|
+
const { clearErrorNote, ownsErrorNote } = require('../modules/provisioning/error-note.js');
|
|
28
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
29
|
+
|
|
30
|
+
const { test, summary } = makeRunner();
|
|
31
|
+
|
|
32
|
+
const activeStandalone = {
|
|
33
|
+
id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
|
|
34
|
+
port: 3010, db_name: 'spike', domain: null, target_ref: 'jaxri/spike-harbor',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function recordingDb(rows = [{ id: 18 }]) {
|
|
38
|
+
const calls = [];
|
|
39
|
+
return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- 1. the author is recorded ------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function retireDeps(getInstanceById) {
|
|
45
|
+
let claims = 0;
|
|
46
|
+
const patches = [];
|
|
47
|
+
const deps = {
|
|
48
|
+
apply: true, log: () => {},
|
|
49
|
+
db: { query: async () => ({ rows: [] }) },
|
|
50
|
+
provisioning: {
|
|
51
|
+
claimNextIntent: async () => (claims++ === 0
|
|
52
|
+
? { id: 9, instance_id: 18, action: 'domain-attach', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
53
|
+
getInstanceById,
|
|
54
|
+
resolveIntent: async () => {},
|
|
55
|
+
setInstanceStatus: async (_db, _id, _s, patch) => { patches.push(patch); },
|
|
56
|
+
recordEvent: async () => {},
|
|
57
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
58
|
+
},
|
|
59
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
60
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
61
|
+
checkCaddyWiring: () => null,
|
|
62
|
+
};
|
|
63
|
+
return { deps, patches };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await test('a retire that returned ok:false records its action beside the note', async () => {
|
|
67
|
+
// Arrange: an active instance with no domain makes the attach leg fail on its last attempt.
|
|
68
|
+
const r = retireDeps(async () => ({ ...activeStandalone }));
|
|
69
|
+
// Act
|
|
70
|
+
await P.cmdRunIntents(r.deps);
|
|
71
|
+
// Assert
|
|
72
|
+
assert.equal(r.patches.length, 1);
|
|
73
|
+
assert.ok(r.patches[0].error_note, 'the reason reaches the card');
|
|
74
|
+
assert.equal(r.patches[0].error_note_action, 'domain-attach');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await test('a retire by a throw records its action beside the note', async () => {
|
|
78
|
+
const r = retireDeps(async () => { throw new Error('box unreachable'); });
|
|
79
|
+
await P.cmdRunIntents(r.deps);
|
|
80
|
+
assert.deepEqual(r.patches[0], { error_note: 'box unreachable', error_note_action: 'domain-attach' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('setInstanceStatus writes error_note_action (it is on the whitelist)', async () => {
|
|
84
|
+
const db = recordingDb();
|
|
85
|
+
await provisioning.setInstanceStatus(db, 18, 'active', { error_note: 'x', error_note_action: 'restart' });
|
|
86
|
+
assert.match(db.calls[0].sql, /error_note_action = \$\d/);
|
|
87
|
+
assert.ok(db.calls[0].params.includes('restart'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('provisioning_026 adds the column idempotently, and nothing else', () => {
|
|
91
|
+
const sql = readFileSync(path.join(ROOT, 'modules/provisioning/migrations/provisioning_026_error_note_action.sql'), 'utf8')
|
|
92
|
+
.replace(/--.*$/gm, '');
|
|
93
|
+
assert.match(sql, /ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS error_note_action text;/);
|
|
94
|
+
assert.doesNotMatch(sql, /DROP|UPDATE|DELETE/i, 'additive only (ADR 0083)');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- 2. who may release the note ------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
await test('ownsErrorNote: own action, or no recorded action, owns the note; another action does not', () => {
|
|
100
|
+
assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: 'restart' }, 'restart'), true);
|
|
101
|
+
assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: null }, 'restart'), true, 'a note from before the column is anyone\'s, as before');
|
|
102
|
+
assert.equal(ownsErrorNote({ error_note: 'x' }, 'restart'), true);
|
|
103
|
+
assert.equal(ownsErrorNote({ error_note: 'x', error_note_action: 'domain-attach' }, 'restart'), false);
|
|
104
|
+
assert.equal(ownsErrorNote({ error_note: null, error_note_action: 'restart' }, 'restart'), false, 'no note, nothing to own');
|
|
105
|
+
assert.equal(ownsErrorNote(null, 'restart'), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('clearErrorNote releases only its own action\'s note, in one narrow statement', async () => {
|
|
109
|
+
const db = recordingDb();
|
|
110
|
+
const out = await clearErrorNote(db, 18, 'repo-private');
|
|
111
|
+
assert.deepEqual(out, { id: 18 });
|
|
112
|
+
assert.equal(db.calls.length, 1, 'one statement — no read-then-write window');
|
|
113
|
+
const { sql, params } = db.calls[0];
|
|
114
|
+
assert.match(sql, /SET error_note = NULL, error_note_action = NULL/);
|
|
115
|
+
assert.match(sql, /error_note IS NOT NULL AND \(error_note_action IS NULL OR error_note_action = \$2\)/,
|
|
116
|
+
'another action\'s note does not match, so it stays');
|
|
117
|
+
assert.deepEqual(params, [18, 'repo-private']);
|
|
118
|
+
assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch never touches the lifecycle');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await test('an unscoped clear passes null, which releases only a note with no recorded action', async () => {
|
|
122
|
+
const db = recordingDb([]);
|
|
123
|
+
assert.equal(await clearErrorNote(db, 18), null);
|
|
124
|
+
assert.deepEqual(db.calls[0].params, [18, null]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test('provisioning.js re-exports both from error-note.js', () => {
|
|
128
|
+
assert.equal(provisioning.clearErrorNote, clearErrorNote);
|
|
129
|
+
assert.equal(provisioning.ownsErrorNote, ownsErrorNote);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---- 3. every success leg names its own action -------------------------------------
|
|
133
|
+
|
|
134
|
+
await test('each success leg clears with its OWN action, and no unscoped clear is left', () => {
|
|
135
|
+
const want = {
|
|
136
|
+
'scripts/gds/provision.js': ['domain-attach', 'settings-apply', 'restart'],
|
|
137
|
+
'scripts/gds/provision-core-upgrade.js': ['core-upgrade'],
|
|
138
|
+
'scripts/gds/provision-repo-private.js': ['repo-private'],
|
|
139
|
+
};
|
|
140
|
+
for (const [file, actions] of Object.entries(want)) {
|
|
141
|
+
const src = readFileSync(path.join(ROOT, file), 'utf8');
|
|
142
|
+
const calls = [...src.matchAll(/clearErrorNote\(db, inst\.id(?:, '([a-z-]+)')?\)/g)].map((m) => m[1] || '(none)');
|
|
143
|
+
assert.deepEqual(calls.sort(), [...actions].sort(), `${file}: ${calls.join(', ')}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
summary();
|
|
@@ -323,6 +323,20 @@ await ta('the owner re-kick: same values re-sent while a retired push left its e
|
|
|
323
323
|
receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
+
await ta('another action\'s note is NOT a re-kick: same values re-sent over a retired RESTART\'s note → "No change", nothing queued (task 1004197)', async () => {
|
|
327
|
+
// A settings push that works cannot release a restart's note, so treating that note as
|
|
328
|
+
// "the push is owed" would re-queue a push (and a restart) on every unchanged save.
|
|
329
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated' }, error_note: 'could not restart spike', error_note_action: 'restart' } });
|
|
330
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
331
|
+
assert.equal(r.status, 200, r.raw);
|
|
332
|
+
assert.equal(enqueueCalls.length, 0, 'no push re-queued');
|
|
333
|
+
assert.equal(updateCalls.length, 0);
|
|
334
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated' }, error_note: 'could not apply the settings on spike', error_note_action: 'settings-apply' } });
|
|
335
|
+
const own = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
336
|
+
assert.deepEqual(enqueueCalls.map((c) => c.id), [18], 'its OWN retired push still re-kicks');
|
|
337
|
+
receiptShape(own.body.push, { queued: true, restart: true, reachable: true });
|
|
338
|
+
});
|
|
339
|
+
|
|
326
340
|
console.log('\nthe state gate — store, queue nothing, say when it takes effect:');
|
|
327
341
|
|
|
328
342
|
await ta('requested / provisioning: stored, NO intent (standup writes the same values), message says so', async () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// tests/text_fit.mjs — measured-width text fitting, EXECUTED (task 1004200; moved
|
|
2
|
+
// out of goal-forest-lib.js into its own file by task 1004204 so the goal map
|
|
3
|
+
// and the goal forest share one copy).
|
|
4
|
+
//
|
|
5
|
+
// text-fit.js is pure — the caller hands it `measure` — so these run the real
|
|
6
|
+
// code against a proportional fake where no fixed character count can fit.
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/text_fit.mjs
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { makeRunner } from './helpers.mjs';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const FILE = path.join(ROOT, 'modules/hall-ui/public/text-fit.js');
|
|
19
|
+
const L = require(FILE);
|
|
20
|
+
|
|
21
|
+
const { test, summary } = makeRunner();
|
|
22
|
+
|
|
23
|
+
// ---- fitting a line to its box (task 1004200) ---------------------------------------
|
|
24
|
+
|
|
25
|
+
// A proportional fake: wide capitals cost three times a narrow letter, so any
|
|
26
|
+
// character budget that fits one title overflows the other.
|
|
27
|
+
const WIDTHS = { W: 12, M: 11, i: 3, '…': 8, '🚀': 14 };
|
|
28
|
+
const measure = (s) => Array.from(s).reduce((w, c) => w + (WIDTHS[c] ?? 6), 0);
|
|
29
|
+
|
|
30
|
+
await test('fitText leaves a title that fits untouched', () => {
|
|
31
|
+
assert.equal(L.fitText('#7 Fix', 136, measure), '#7 Fix');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await test('fitText cuts to the MEASURED width, so wide glyphs keep fewer characters than narrow ones', () => {
|
|
35
|
+
const wide = L.fitText('W'.repeat(40), 136, measure);
|
|
36
|
+
const narrow = L.fitText('i'.repeat(80), 136, measure);
|
|
37
|
+
for (const s of [wide, narrow]) {
|
|
38
|
+
assert.ok(measure(s) <= 136, `${s} measures ${measure(s)}, past the box`);
|
|
39
|
+
assert.ok(s.endsWith('…'), 'a cut title says it was cut');
|
|
40
|
+
}
|
|
41
|
+
assert.ok(Array.from(narrow).length > 3 * Array.from(wide).length, 'no fixed character count could produce both');
|
|
42
|
+
assert.equal(wide, 'W'.repeat(10) + '…', 'the LONGEST prefix that fits: 10 W + … = 128px, 11 would be 140px');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await test('fitText never ends on a dangling space and never splits an emoji', () => {
|
|
46
|
+
const spaced = L.fitText('ab cd ef gh ij kl mn op qr', 62, measure);
|
|
47
|
+
assert.doesNotMatch(spaced, /\s…$/);
|
|
48
|
+
const emoji = L.fitText('🚀'.repeat(30), 60, measure);
|
|
49
|
+
assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji), false, 'no lone high surrogate');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await test('fitText returns a bare ellipsis when not even one character fits', () => {
|
|
53
|
+
assert.equal(L.fitText('WWW', 10, measure), '…');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('fitLines keeps a short title on one line, whole', () => {
|
|
57
|
+
assert.deepEqual(L.fitLines('#7 Fix', 136, 2, measure), ['#7 Fix']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await test('fitLines wraps at a space, and a second line that fits is left whole', () => {
|
|
61
|
+
assert.deepEqual(L.fitLines('aaaa bbbb cccc dddd', 60, 2, measure), ['aaaa bbbb', 'cccc dddd']);
|
|
62
|
+
assert.deepEqual(L.fitLines('a b\n c', 60, 2, measure), ['a b c'], 'runs of whitespace read as one space');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('fitLines ellipsises the LAST line when the title outruns every line, and no line overflows', () => {
|
|
66
|
+
const lines = L.fitLines('BV1.R26 — Real governance: the constitution becomes enforceable at last', 136, 2, measure);
|
|
67
|
+
assert.equal(lines.length, 2);
|
|
68
|
+
for (const l of lines) assert.ok(measure(l) <= 136, `"${l}" measures ${measure(l)}`);
|
|
69
|
+
assert.ok(lines[1].endsWith('…'));
|
|
70
|
+
assert.ok(!lines[0].endsWith('…'), 'only the last line is cut');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('fitLines breaks a word wider than a whole line instead of overflowing, and always finishes', () => {
|
|
74
|
+
const lines = L.fitLines('W'.repeat(40), 136, 2, measure);
|
|
75
|
+
assert.deepEqual(lines, ['W'.repeat(11), 'W'.repeat(10) + '…']);
|
|
76
|
+
assert.ok(L.fitLines('WWW', 5, 2, measure).length <= 2, 'a box narrower than one glyph still terminates');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('fitLines with one line is fitText', () => {
|
|
80
|
+
const t = 'Move the remaining hall pages onto the kit';
|
|
81
|
+
assert.deepEqual(L.fitLines(t, 90, 1, measure), [L.fitText(t, 90, measure)]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---- purity -----------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
await test('the helper is pure: no DOM, no fetch — the caller measures', () => {
|
|
87
|
+
const src = fs.readFileSync(FILE, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
88
|
+
assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|getComputedTextLength/.test(src.replace(/root\.OTBTextFit/, '')), false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
summary();
|