@bongos/core 1.19.262 → 1.19.264

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.
@@ -0,0 +1,153 @@
1
+ // tests/ship_error_shape.mjs
2
+ //
3
+ // Unit tests for ship.js's API-error rendering (task 1002789).
4
+ //
5
+ // HIT FOR REAL on task 1002485: a fully diagnosable push failure reached the
6
+ // builder as literally "ci-land: server publish failed - [object Object]".
7
+ // The cause is one bug class repeated at several sites — ship.js read the
8
+ // PRE-ADR-0116 flat body `{ error: 'code', …fields }` while the live server
9
+ // emits the nested envelope `{ error: { code, message, details } }`:
10
+ //
11
+ // * interpolating `data.error` into a template string stringifies the
12
+ // envelope OBJECT — that is the literal "[object Object]";
13
+ // * `data.error === 'some_code'` never matches, so the graceful branch
14
+ // behind it (box_scope re-auth, verify-attestation remedy, the
15
+ // CLAIM_SESSION_MISMATCH explanation) is DEAD CODE against a live server;
16
+ // * `data.<field>` reads a top-level field that MOVED into `error.details`.
17
+ //
18
+ // What we prove here (pure helpers only — no git, no network):
19
+ // 1. normalizeApiError now carries `details`, from the envelope when nested
20
+ // and from the body itself on a legacy flat response.
21
+ // 2. apiErrorLine renders BOTH shapes as one readable line and NEVER emits
22
+ // "[object Object]" — the regression this task exists to kill.
23
+ // 3. It keeps the scalar detail fields that name what actually failed, and
24
+ // drops non-scalar blobs (a details payload can carry a whole bundle).
25
+ // 4. It survives missing/odd/hostile bodies without throwing.
26
+ //
27
+ // Run: node tests/ship_error_shape.mjs
28
+
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ship = require('../scripts/gds/ship.js');
33
+ const { normalizeApiError, apiErrorLine } = ship;
34
+
35
+ let passed = 0;
36
+ let failed = 0;
37
+ function test(name, fn) {
38
+ try {
39
+ fn();
40
+ passed++;
41
+ console.log(` ok ${name}`);
42
+ } catch (err) {
43
+ failed++;
44
+ console.log(` FAIL ${name}`);
45
+ console.log(` ${err.message}`);
46
+ }
47
+ }
48
+ function eq(actual, expected, msg) {
49
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
50
+ }
51
+ function has(haystack, needle) {
52
+ if (!String(haystack).includes(needle)) throw new Error(`expected to contain ${JSON.stringify(needle)}, got ${JSON.stringify(haystack)}`);
53
+ }
54
+ function lacks(haystack, needle) {
55
+ if (String(haystack).includes(needle)) throw new Error(`expected NOT to contain ${JSON.stringify(needle)}, got ${JSON.stringify(haystack)}`);
56
+ }
57
+
58
+ // ---- 1. normalizeApiError carries details from both shapes ----
59
+
60
+ test('normalizeApiError takes details from the envelope', () => {
61
+ const { code, details } = normalizeApiError({
62
+ error: { code: 'CLAIM_SESSION_MISMATCH', message: 'other session', details: { claimWorktree: 'wt-1002789' } },
63
+ });
64
+ eq(code, 'CLAIM_SESSION_MISMATCH');
65
+ eq(details.claimWorktree, 'wt-1002789');
66
+ });
67
+
68
+ test('normalizeApiError falls back to the body on the legacy flat shape', () => {
69
+ // The flat shape put the structured fields at the TOP level, so the body IS
70
+ // the details. Both server generations resolve through one read.
71
+ const { code, details } = normalizeApiError({ error: 'CLAIM_SESSION_MISMATCH', claimWorktree: 'wt-legacy' });
72
+ eq(code, 'CLAIM_SESSION_MISMATCH');
73
+ eq(details.claimWorktree, 'wt-legacy');
74
+ });
75
+
76
+ test('normalizeApiError still survives missing/odd bodies', () => {
77
+ eq(normalizeApiError(null).code, undefined);
78
+ eq(typeof normalizeApiError(null).details, 'object');
79
+ eq(normalizeApiError({}).code, undefined);
80
+ });
81
+
82
+ // ---- 2. apiErrorLine never emits "[object Object]" ----
83
+
84
+ test('apiErrorLine renders the ADR 0116 envelope, not [object Object]', () => {
85
+ const line = apiErrorLine({
86
+ error: { code: 'publish_failed', message: 'push rejected', details: { step: 'push', reason: 'non-fast-forward' } },
87
+ }, 500);
88
+ lacks(line, '[object Object]');
89
+ has(line, 'publish_failed');
90
+ has(line, 'push rejected');
91
+ has(line, 'step: push');
92
+ has(line, 'non-fast-forward');
93
+ has(line, 'HTTP 500');
94
+ });
95
+
96
+ test('apiErrorLine renders the legacy flat shape', () => {
97
+ const line = apiErrorLine({ error: 'publish_failed', step: 'push' }, 500);
98
+ lacks(line, '[object Object]');
99
+ has(line, 'publish_failed');
100
+ has(line, 'step: push');
101
+ });
102
+
103
+ test('apiErrorLine does not repeat the code when message just echoes it', () => {
104
+ // envelope() defaults message to the code, so the naive `code: message`
105
+ // join would print "publish_failed: publish_failed".
106
+ const line = apiErrorLine({ error: { code: 'publish_failed', message: 'publish_failed' } }, 500);
107
+ lacks(line, 'publish_failed: publish_failed');
108
+ has(line, 'publish_failed');
109
+ });
110
+
111
+ // ---- 3. scalars kept, blobs dropped ----
112
+
113
+ test('apiErrorLine keeps scalar details and drops blobs', () => {
114
+ const line = apiErrorLine({
115
+ error: {
116
+ code: 'publish_failed',
117
+ message: 'nope',
118
+ details: { step: 'push', attempt: 3, armed: false, bundle: { b64: 'x'.repeat(200) }, files: ['a', 'b'] },
119
+ },
120
+ }, 500);
121
+ has(line, 'step: push');
122
+ has(line, 'attempt: 3');
123
+ has(line, 'armed: false');
124
+ lacks(line, 'bundle');
125
+ lacks(line, 'files');
126
+ lacks(line, '[object Object]');
127
+ });
128
+
129
+ test('apiErrorLine flattens multi-line git stderr onto one line', () => {
130
+ const line = apiErrorLine({
131
+ error: { code: 'publish_failed', message: 'nope', details: { reason: 'error: failed to push\n hint: fetch first\n' } },
132
+ }, 500);
133
+ eq(line.includes('\n'), false, 'must be a single line');
134
+ has(line, 'fetch first');
135
+ });
136
+
137
+ // ---- 4. hostile / empty bodies ----
138
+
139
+ test('apiErrorLine handles a body with no error at all', () => {
140
+ has(apiErrorLine({}, 503), 'HTTP 503');
141
+ has(apiErrorLine(null, 503), 'HTTP 503');
142
+ eq(apiErrorLine(null), 'unknown error');
143
+ });
144
+
145
+ test('apiErrorLine handles a null error member', () => {
146
+ const line = apiErrorLine({ error: null }, 500);
147
+ lacks(line, '[object Object]');
148
+ has(line, 'HTTP 500');
149
+ });
150
+
151
+ console.log('');
152
+ console.log(`ship_error_shape: ${passed} passed, ${failed} failed`);
153
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,115 @@
1
+ // tests/task_lib.mjs
2
+ //
3
+ // task 1002463 — the shared task-domain helpers extracted out of work.js and
4
+ // goals-page.js into modules/hall-ui/public/task-lib.js.
5
+ //
6
+ // WHY THIS FILE EXISTS. The extraction was verified by an equivalence pass against
7
+ // the pristine pre-refactor sources — and that pass MISSED two real regressions,
8
+ // because every input it fed deHashRefs was a string or null. A grader caught them:
9
+ // the digit bound had been "tidied" from \d{1,5} to \d+, and the null guard from
10
+ // (text == null ? '' : text) to (text || ''). Both are behaviour changes hiding in
11
+ // a refactor. The lesson is that equivalence proofs are only as good as their input
12
+ // domain, so the boundaries now have named, checked-in cases.
13
+ //
14
+ // Run: node tests/task_lib.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+ const T = require(join(ROOT, 'modules/hall-ui/public/task-lib.js'));
24
+
25
+ let passed = 0, failed = 0;
26
+ function t(name, fn) {
27
+ try { fn(); passed++; console.log(` PASS ${name}`); }
28
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
29
+ }
30
+
31
+ // ---- deHashRefs: the two regressions, pinned ------------------------------
32
+ t('deHashRefs rewrites a 1-5 digit ref (GitHub would autolink it to an issue)', () => {
33
+ assert.equal(T.deHashRefs('see #123'), 'see task 123');
34
+ assert.equal(T.deHashRefs('#1'), 'task 1');
35
+ assert.equal(T.deHashRefs('#12345'), 'task 12345');
36
+ });
37
+
38
+ t('deHashRefs LEAVES a 6+ digit run alone — \\d{1,5} is load-bearing by omission', () => {
39
+ // \d{1,5} followed by \b cannot match inside a longer digit run: after 5 digits
40
+ // the next char is still a word char, so there is no boundary and no match at
41
+ // all. Widening to \d+ silently changed this. Pinned so it cannot drift back.
42
+ assert.equal(T.deHashRefs('#123456'), '#123456');
43
+ assert.equal(T.deHashRefs('#1002463'), '#1002463');
44
+ assert.equal(T.deHashRefs('#12345 and #123456'), 'task 12345 and #123456');
45
+ });
46
+
47
+ t('deHashRefs keeps falsy-but-real input — (text == null) not (!text)', () => {
48
+ // `text || ''` would swallow these into the empty string.
49
+ assert.equal(T.deHashRefs(0), '0');
50
+ assert.equal(T.deHashRefs(false), 'false');
51
+ assert.equal(T.deHashRefs(NaN), 'NaN');
52
+ // ...while genuinely absent input is still the empty string.
53
+ assert.equal(T.deHashRefs(null), '');
54
+ assert.equal(T.deHashRefs(undefined), '');
55
+ });
56
+
57
+ // ---- touchesConflict: the parallel-safety predicate -----------------------
58
+ t('touchesConflict: a path collides with itself, its parents and its children', () => {
59
+ assert.equal(T.touchesConflict(['src'], ['src']), true);
60
+ assert.equal(T.touchesConflict(['src'], ['src/gds/db.js']), true);
61
+ assert.equal(T.touchesConflict(['src/gds/db.js'], ['src']), true);
62
+ assert.equal(T.touchesConflict(['src'], ['srcery']), false, 'prefix must not count as a parent');
63
+ assert.equal(T.touchesConflict(['a'], ['b']), false);
64
+ });
65
+
66
+ t('touchesConflict: trailing slashes normalise, non-arrays are inert', () => {
67
+ assert.equal(T.touchesConflict(['src/'], ['src']), true);
68
+ assert.equal(T.touchesConflict(null, ['src']), false);
69
+ assert.equal(T.touchesConflict('src', ['src']), false, 'a bare string is not a list');
70
+ });
71
+
72
+ // ---- priorityRank / creditsPerMinute: sort keys ---------------------------
73
+ t('priorityRank: unparseable or absent sorts LAST, not first', () => {
74
+ assert.equal(T.priorityRank('P1'), 1);
75
+ assert.equal(T.priorityRank('P5'), 5);
76
+ assert.equal(T.priorityRank(null), 99);
77
+ assert.equal(T.priorityRank('nonsense'), 99);
78
+ });
79
+
80
+ t('creditsPerMinute: unpriced/unestimated sinks below a genuine zero ratio', () => {
81
+ assert.equal(T.creditsPerMinute({ credits_reward: 10, est_minutes: 5 }), 2);
82
+ assert.equal(T.creditsPerMinute({ credits_reward: 0, est_minutes: 5 }), -Infinity);
83
+ assert.equal(T.creditsPerMinute({ credits_reward: 10, est_minutes: 0 }), -Infinity);
84
+ assert.equal(T.creditsPerMinute({}), -Infinity);
85
+ });
86
+
87
+ // ---- friendlyBatchReason: the bug this extraction fixed -------------------
88
+ t('friendlyBatchReason explains MODULE_CONFLICT — the case goals-page.js had lost', () => {
89
+ assert.match(
90
+ T.friendlyBatchReason({ reason: 'MODULE_CONFLICT', module_key: 'ideas', conflictsWithTaskId: 7 }),
91
+ /shares module "ideas" with task #7/
92
+ );
93
+ assert.match(
94
+ T.friendlyBatchReason({ reason: 'MODULE_CONFLICT', module_key: 'ideas' }),
95
+ /shares module "ideas" with an active claim/
96
+ );
97
+ });
98
+
99
+ t('friendlyBatchReason falls through to the raw code for an unmapped reason', () => {
100
+ // The failure mode this guards: a new server-side refusal code degrades to the
101
+ // machine string in the UI rather than throwing. Loud enough to notice, safe.
102
+ assert.equal(T.friendlyBatchReason({ reason: 'SOME_NEW_CODE' }), 'SOME_NEW_CODE');
103
+ assert.equal(T.friendlyBatchReason(null), 'not claimable');
104
+ });
105
+
106
+ // ---- rankTagHtml: escaping ------------------------------------------------
107
+ t('rankTagHtml escapes its label and treats xenos as "no floor"', () => {
108
+ assert.match(T.rankTagHtml('xenos'), /Any rank/);
109
+ assert.match(T.rankTagHtml('metic'), /Metic\+/);
110
+ assert.match(T.rankTagHtml('archon'), /Archon</, 'archon takes no + suffix');
111
+ assert.doesNotMatch(T.rankTagHtml('<img src=x onerror=1>'), /<img/);
112
+ });
113
+
114
+ console.log(`\ntask-lib tests: ${passed} passed, ${failed} failed`);
115
+ if (failed) process.exit(1);