@bongos/core 1.19.272 → 1.19.274
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 +32 -17
- package/docs/copy-inventory.md +37 -37
- package/docs/copy-registry.json +43 -43
- package/modules/hall-ui/public/dom-utils.js +40 -0
- package/modules/hall-ui/public/task.js +9 -3
- package/modules/hall-ui/public/work.js +16 -7
- package/modules/lifecycle/est-advisory.js +162 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/est_advisory.mjs +183 -0
- package/tests/goal_advisory.mjs +6 -1
- package/tests/hall_error_envelope.mjs +264 -0
|
@@ -560,7 +560,7 @@ module.exports = function buildTasksRouter() {
|
|
|
560
560
|
description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
|
|
561
561
|
status: { type: 'string', maxLength: 32 },
|
|
562
562
|
touches: { type: 'array', maxItems: 500, itemsType: 'string' },
|
|
563
|
-
est_minutes: { type: 'integer', min: 0, max: 100000 },
|
|
563
|
+
est_minutes: { type: 'integer', min: 0, max: 100000 }, // AGENT-minutes — see est-advisory.js (task 1003148)
|
|
564
564
|
est_cost_usd: { type: 'number', min: 0 },
|
|
565
565
|
manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
|
|
566
566
|
priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
|
|
@@ -757,7 +757,7 @@ module.exports = function buildTasksRouter() {
|
|
|
757
757
|
needs_migration: needsMigration,
|
|
758
758
|
criteria,
|
|
759
759
|
suggested_criteria,
|
|
760
|
-
goal_advisory,
|
|
760
|
+
goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
|
|
761
761
|
});
|
|
762
762
|
} catch (err) {
|
|
763
763
|
console.error('[gds] POST /tasks', err);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.274",
|
|
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.274",
|
|
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.274",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -971,8 +971,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
971
971
|
// landed since 1.19.270 with no explicit bump. run 32437463473. (task 1002620)
|
|
972
972
|
// 1.19.272 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
973
973
|
// landed since 1.19.271 with no explicit bump. run 32438140524. (task 1002620)
|
|
974
|
+
// 1.19.273 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
975
|
+
// landed since 1.19.272 with no explicit bump. run 32441024959. (task 1002620)
|
|
976
|
+
// 1.19.274 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
977
|
+
// landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
|
|
974
978
|
// ---------------------------------------------------------------------------
|
|
975
|
-
const CORE_VERSION = '1.19.
|
|
979
|
+
const CORE_VERSION = '1.19.274'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
976
980
|
|
|
977
981
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
978
982
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/est_advisory.mjs — the estimate-SCALE advisory (task 1003148).
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE. Nothing tells a task author their estimate is on the wrong SCALE.
|
|
4
|
+
// Across 1,173 shipped tasks carrying both est_minutes and actual_minutes the
|
|
5
|
+
// median actual/est ratio is 0.100, uniform across every kind with a real sample
|
|
6
|
+
// (feature 0.078/n=489, bug 0.150/n=214, infra 0.097/n=144, cleanup 0.133/n=89).
|
|
7
|
+
// Authors write human-hours-shaped numbers for work an agent does in minutes.
|
|
8
|
+
//
|
|
9
|
+
// pms_calibrated_minutes (migration 015) already divides by that median, so the
|
|
10
|
+
// board looks sane while the STORED number stays wrong and every new task re-adds
|
|
11
|
+
// the error — a permanent correction with no feedback loop. It misleads humans
|
|
12
|
+
// directly too: GET /tasks/:id leads with the raw field, and a session quoted task
|
|
13
|
+
// 1002572 to the owner as "60 min" when its calibrated value is 9.
|
|
14
|
+
//
|
|
15
|
+
// What we prove (PURE — no DB, no network):
|
|
16
|
+
// 1. An out-of-scale estimate is flagged, with the calibrated equivalent, the
|
|
17
|
+
// ratio, and the SAMPLE SIZE it rests on — evidence, not an assertion.
|
|
18
|
+
// 2. An in-scale estimate returns null. Silence is the common case; an advisory
|
|
19
|
+
// that fires on every create is noise and gets ignored.
|
|
20
|
+
// 3. It never AUTO-CORRECTS. The submitted value is reported, not replaced —
|
|
21
|
+
// silently rewriting the author's number is worse than the current silence,
|
|
22
|
+
// because then nobody learns the scale either.
|
|
23
|
+
// 4. The ratio source mirrors pms_calibrated_minutes' own thresholds (>=5 for a
|
|
24
|
+
// kind, else the overall population), so advisory and calibration can never
|
|
25
|
+
// disagree about which number applies — and it says WHICH basis it used.
|
|
26
|
+
// 5. Missing / hostile / thin-sample input yields null, never a guess.
|
|
27
|
+
//
|
|
28
|
+
// Run: node tests/est_advisory.mjs
|
|
29
|
+
|
|
30
|
+
import { strict as assert } from 'node:assert';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const { adviseEstimate, pickRatio, buildEstAdvisory, MIN_MATERIAL_DELTA, SCALE_OK_RATIO } =
|
|
36
|
+
require('../modules/lifecycle/est-advisory.js');
|
|
37
|
+
|
|
38
|
+
let passed = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
function t(name, fn) {
|
|
41
|
+
try { const r = fn(); if (r && r.then) return r.then(
|
|
42
|
+
() => { passed++; console.log(` ok ${name}`); },
|
|
43
|
+
(e) => { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); });
|
|
44
|
+
passed++; console.log(` ok ${name}`);
|
|
45
|
+
} catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The real shape pms_calibration_summary() returns, with the real medians.
|
|
50
|
+
const SUMMARY = [
|
|
51
|
+
{ kind: 'feature', median_ratio: '0.0780', sample_size: 489 },
|
|
52
|
+
{ kind: 'bug', median_ratio: '0.1500', sample_size: 214 },
|
|
53
|
+
{ kind: 'infra', median_ratio: '0.0970', sample_size: 144 },
|
|
54
|
+
{ kind: 'cleanup', median_ratio: '0.1330', sample_size: 89 },
|
|
55
|
+
{ kind: 'blocker-resolution', median_ratio: '0.1280', sample_size: 2 }, // below the kind floor
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ---- 1. the flag, with its evidence ----
|
|
59
|
+
|
|
60
|
+
await t('an out-of-scale bug estimate is flagged with the calibrated equivalent', () => {
|
|
61
|
+
const a = adviseEstimate({ estMinutes: 60, kind: 'bug', summary: SUMMARY, taskId: 1002572 });
|
|
62
|
+
assert.ok(a, 'must advise');
|
|
63
|
+
assert.equal(a.submitted_minutes, 60);
|
|
64
|
+
assert.equal(a.calibrated_minutes, 9, '60 * 0.15 = 9 — the real number for task 1002572');
|
|
65
|
+
assert.equal(a.basis, 'bug');
|
|
66
|
+
assert.equal(a.sample_size, 214, 'quotes the sample the claim rests on');
|
|
67
|
+
assert.equal(a.median_ratio, 0.15);
|
|
68
|
+
assert.equal(a.kind_factor, 6.7, 'the historical factor is a property of the KIND');
|
|
69
|
+
assert.match(a.message, /AGENT-minutes/, 'names the unit, which is the actual defect');
|
|
70
|
+
assert.match(a.next_step, /PATCH \/tasks\/1002572 \{"est_minutes": 9\}/, 'gives the exact fix');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await t('the advisory reports, never auto-corrects', () => {
|
|
74
|
+
const a = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY, taskId: 5 });
|
|
75
|
+
assert.equal(a.submitted_minutes, 300, 'the submitted value is preserved verbatim');
|
|
76
|
+
assert.equal(a.calibrated_minutes, 45);
|
|
77
|
+
// Nothing in the advisory mutates anything — it is a pure verdict object.
|
|
78
|
+
assert.equal(typeof a, 'object');
|
|
79
|
+
assert.ok(!('task' in a), 'no task payload — it cannot rewrite the row');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- 2. silence is the common case ----
|
|
83
|
+
|
|
84
|
+
await t('an already-agent-scale estimate returns null (no noise on a good create)', () => {
|
|
85
|
+
// The trigger is a MATERIAL translation, not a ratio — a ratio is constant by
|
|
86
|
+
// construction (see MIN_MATERIAL_DELTA) and would fire on correct estimates too.
|
|
87
|
+
assert.equal(adviseEstimate({ estMinutes: 9, kind: 'bug', summary: SUMMARY }), null,
|
|
88
|
+
'9 min for a bug is already agent-scale — translating it says nothing useful');
|
|
89
|
+
assert.equal(adviseEstimate({ estMinutes: 2, kind: 'bug', summary: SUMMARY }), null);
|
|
90
|
+
assert.equal(adviseEstimate({ estMinutes: 4, kind: 'feature', summary: SUMMARY }), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await t('the loop closes at the POPULATION level, not per task', () => {
|
|
94
|
+
// The tempting claim — "adopt the suggestion and it goes quiet" — is FALSE, and
|
|
95
|
+
// asserting it is how a wrong comment would have shipped. The calibration is a
|
|
96
|
+
// constant multiply, so the adopted value gets translated too:
|
|
97
|
+
const loud = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY });
|
|
98
|
+
assert.ok(loud, 'a human-hours estimate is flagged');
|
|
99
|
+
assert.ok(adviseEstimate({ estMinutes: loud.calibrated_minutes, kind: 'bug', summary: SUMMARY }),
|
|
100
|
+
'adopting the value does NOT silence it — the multiplier re-applies');
|
|
101
|
+
|
|
102
|
+
// What DOES retire the advisory: the population correcting, which raises the
|
|
103
|
+
// median ratio toward 1.0 (pms_calibration_summary recomputes from shipped
|
|
104
|
+
// rows). Then even a large estimate translates to nearly itself.
|
|
105
|
+
const corrected = [{ kind: 'bug', median_ratio: '0.9500', sample_size: 400 }];
|
|
106
|
+
assert.equal(adviseEstimate({ estMinutes: 300, kind: 'bug', summary: corrected }), null,
|
|
107
|
+
'once estimates and actuals agree, the advisory has nothing to say — at ANY size');
|
|
108
|
+
assert.equal(adviseEstimate({ estMinutes: 5000, kind: 'bug', summary: corrected }), null,
|
|
109
|
+
'and size alone must never trigger it — this is a SCALE advisory, not a big-number one');
|
|
110
|
+
assert.ok(MIN_MATERIAL_DELTA > 0 && SCALE_OK_RATIO > 0 && SCALE_OK_RATIO < 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---- 3/4. which ratio, and why ----
|
|
114
|
+
|
|
115
|
+
await t('pickRatio uses the KIND when it has enough samples, and says so', () => {
|
|
116
|
+
const p = pickRatio({ kind: 'feature', summary: SUMMARY });
|
|
117
|
+
assert.equal(p.basis, 'feature');
|
|
118
|
+
assert.equal(p.sample_size, 489);
|
|
119
|
+
assert.equal(p.ratio, 0.078);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t('pickRatio falls back off a THIN kind rather than quoting 2 samples', () => {
|
|
123
|
+
// blocker-resolution has n=2, below the >=5 floor pms_calibrated_minutes uses.
|
|
124
|
+
const p = pickRatio({ kind: 'blocker-resolution', summary: SUMMARY });
|
|
125
|
+
assert.notEqual(p.basis, 'blocker-resolution', 'must not quote a 2-sample median as authority');
|
|
126
|
+
assert.equal(p.basis, 'all kinds');
|
|
127
|
+
assert.ok(p.sample_size > 900, 'the overall basis carries the whole population');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t('an unknown kind still gets the overall basis', () => {
|
|
131
|
+
const p = pickRatio({ kind: 'nonexistent-kind', summary: SUMMARY });
|
|
132
|
+
assert.equal(p.basis, 'all kinds');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- 5. fail closed ----
|
|
136
|
+
|
|
137
|
+
await t('no estimate, no history, or hostile input ⇒ null, never a guess', () => {
|
|
138
|
+
assert.equal(adviseEstimate({ estMinutes: null, kind: 'bug', summary: SUMMARY }), null);
|
|
139
|
+
assert.equal(adviseEstimate({ estMinutes: 0, kind: 'bug', summary: SUMMARY }), null);
|
|
140
|
+
assert.equal(adviseEstimate({ estMinutes: -5, kind: 'bug', summary: SUMMARY }), null);
|
|
141
|
+
assert.equal(adviseEstimate({ estMinutes: 'sixty', kind: 'bug', summary: SUMMARY }), null);
|
|
142
|
+
// Thin overall population: nothing to claim.
|
|
143
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: '0.15', sample_size: 3 }] }), null);
|
|
144
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [] }), null);
|
|
145
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: null }), null);
|
|
146
|
+
// Garbage rows must not crash or be trusted.
|
|
147
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: 'x', sample_size: 'y' }] }), null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await t('buildEstAdvisory is NON-BLOCKING — a db failure yields null, not a throw', async () => {
|
|
151
|
+
const thrower = { calibrationSummary: async () => { throw new Error('pg down'); } };
|
|
152
|
+
assert.equal(await buildEstAdvisory({ db: thrower, task: { id: 1, kind: 'bug', est_minutes: 600 } }), null);
|
|
153
|
+
// No estimate on the task at all: nothing to judge, and no db call needed.
|
|
154
|
+
let called = false;
|
|
155
|
+
const spy = { calibrationSummary: async () => { called = true; return SUMMARY; } };
|
|
156
|
+
assert.equal(await buildEstAdvisory({ db: spy, task: { id: 1, kind: 'bug', est_minutes: null } }), null);
|
|
157
|
+
assert.equal(called, false, 'must not query when there is no estimate to judge');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await t('buildEstAdvisory returns the advisory on the happy path', async () => {
|
|
161
|
+
const db = { calibrationSummary: async () => SUMMARY };
|
|
162
|
+
const a = await buildEstAdvisory({ db, task: { id: 77, kind: 'feature', est_minutes: 90 } });
|
|
163
|
+
assert.ok(a);
|
|
164
|
+
assert.equal(a.calibrated_minutes, 7, '90 * 0.078 = 7');
|
|
165
|
+
assert.equal(a.basis, 'feature');
|
|
166
|
+
assert.match(a.next_step, /PATCH \/tasks\/77/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('the route attaches est_advisory to the 201 body, non-blocking', () => {
|
|
170
|
+
// Mirrors the goal_advisory contract test. The call is folded into the response
|
|
171
|
+
// literal because modules/lifecycle/routes/tasks.js is over the size ratchet's
|
|
172
|
+
// cap and main sits exactly AT the baseline — so this feature had to land at
|
|
173
|
+
// net-zero lines in that file. Asserting the shape keeps the fold honest.
|
|
174
|
+
const src = fs.readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
|
|
175
|
+
const body201 = (src.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
176
|
+
assert.ok(body201, 'found the 201 response body');
|
|
177
|
+
assert.match(body201, /est_advisory:/, 'est_advisory rides the create response');
|
|
178
|
+
assert.match(body201, /buildEstAdvisory\(\{ db, task \}\)/, 'and delegates rather than inlining logic');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(`est_advisory: ${passed} passed, ${failed} failed`);
|
|
183
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -117,7 +117,12 @@ await test('db.js resolves the catch-all through generalGoalTitle — no second
|
|
|
117
117
|
|
|
118
118
|
await test('the route delegates to buildGoalAdvisory in one line — no inline logic to drift', () => {
|
|
119
119
|
assert.match(tasksSrc, /const goal_advisory = await buildGoalAdvisory\(\{ db, versionId: body\.version_id, task \}\);/);
|
|
120
|
-
|
|
120
|
+
// task 1003148: was /goal_advisory,\s*\}\);/ — it required goal_advisory to be
|
|
121
|
+
// the LAST key, which the sibling est_advisory broke. The assertion's intent is
|
|
122
|
+
// "present in the 201 body", so assert that, not the key order.
|
|
123
|
+
const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
124
|
+
assert.ok(body201, 'found the 201 response body');
|
|
125
|
+
assert.match(body201, /goal_advisory,/, 'included in the 201 body');
|
|
121
126
|
});
|
|
122
127
|
|
|
123
128
|
// ---- buildGoalAdvisory: the real function, driven with a stub db ----
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// tests/hall_error_envelope.mjs — task 1002462.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT. The server returns the ADR 0116 envelope via res.fail():
|
|
4
|
+
// `{ error: { code, message, details } }`. The code is NESTED, so a bare
|
|
5
|
+
// `data.error === 'CODE'` test compares a string to an OBJECT and silently never
|
|
6
|
+
// matches — every switch falls through to its generic default. The builder gets
|
|
7
|
+
// "Could not complete that (409). Try again." for a refusal the server described
|
|
8
|
+
// precisely.
|
|
9
|
+
//
|
|
10
|
+
// It survived because it degrades into a wrong-but-PLAUSIBLE toast rather than a
|
|
11
|
+
// visible break, so nothing failed and no smoke test could catch it. The same
|
|
12
|
+
// defect appeared independently on four hall surfaces.
|
|
13
|
+
//
|
|
14
|
+
// The fix puts the readers in dom-utils.js — the shared toolkit every hall page
|
|
15
|
+
// already loads first — so a fifth copy is never the easiest option.
|
|
16
|
+
//
|
|
17
|
+
// What we prove, executing the REAL files in a vm (the tests/hall_signout.mjs and
|
|
18
|
+
// goal_criterion_confirm_ui.mjs precedent — there is no jsdom here):
|
|
19
|
+
// 1. dom-utils' errCode / errMessage / errDetails read BOTH wire shapes and
|
|
20
|
+
// survive hostile input. This is now the single point of failure for four
|
|
21
|
+
// surfaces, so it is tested directly rather than through a page.
|
|
22
|
+
// 2. END TO END on the real task.js: a 409 carrying the envelope renders the
|
|
23
|
+
// SERVER'S OWN sentence in the toast, not the generic fallback. This is the
|
|
24
|
+
// user-visible regression, driven through a real button click.
|
|
25
|
+
// 3. Neither consumer still reads the flat shape anywhere.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/hall_error_envelope.mjs
|
|
28
|
+
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import vm from 'node:vm';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
37
|
+
|
|
38
|
+
let passed = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
function t(name, fn) {
|
|
41
|
+
try {
|
|
42
|
+
const r = fn();
|
|
43
|
+
if (r && typeof r.then === 'function') {
|
|
44
|
+
return r.then(
|
|
45
|
+
() => { passed++; console.log(` ok ${name}`); },
|
|
46
|
+
(e) => { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
passed++; console.log(` ok ${name}`);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- 1. the shared readers, from the real dom-utils.js ----------------------
|
|
57
|
+
|
|
58
|
+
function loadOTB() {
|
|
59
|
+
const sandbox = {
|
|
60
|
+
window: {},
|
|
61
|
+
document: {
|
|
62
|
+
createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, appendChild() {}, remove() {} }),
|
|
63
|
+
body: { appendChild() {} },
|
|
64
|
+
querySelector: () => null,
|
|
65
|
+
querySelectorAll: () => [],
|
|
66
|
+
getElementById: () => null,
|
|
67
|
+
},
|
|
68
|
+
setTimeout, clearTimeout, console,
|
|
69
|
+
};
|
|
70
|
+
sandbox.globalThis = sandbox;
|
|
71
|
+
vm.createContext(sandbox);
|
|
72
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'dom-utils.js'), 'utf8'), sandbox, { filename: 'dom-utils.js' });
|
|
73
|
+
return sandbox.window.OTB;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await t('dom-utils exports the envelope readers on window.OTB', () => {
|
|
77
|
+
const OTB = loadOTB();
|
|
78
|
+
for (const fn of ['errCode', 'errMessage', 'errDetails']) {
|
|
79
|
+
assert.equal(typeof OTB[fn], 'function', `${fn} must be on the shared toolkit`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await t('errCode reads the ADR 0116 envelope AND the legacy flat shape', () => {
|
|
84
|
+
const { errCode } = loadOTB();
|
|
85
|
+
assert.equal(errCode({ error: { code: 'ALREADY_CLAIMED', message: 'x' } }), 'ALREADY_CLAIMED');
|
|
86
|
+
assert.equal(errCode({ error: 'ALREADY_CLAIMED' }), 'ALREADY_CLAIMED', 'an older server must still map');
|
|
87
|
+
assert.equal(errCode({ code: 'ALREADY_CLAIMED' }), 'ALREADY_CLAIMED');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await t('errCode never returns an object — the whole bug in one assertion', () => {
|
|
91
|
+
const { errCode } = loadOTB();
|
|
92
|
+
const code = errCode({ error: { code: 'TOUCHES_CONFLICT', details: { conflicts: [] } } });
|
|
93
|
+
assert.equal(typeof code, 'string');
|
|
94
|
+
assert.notEqual(typeof code, 'object', 'returning the object is what made every switch case miss');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('errMessage prefers the envelope message over a top-level one', () => {
|
|
98
|
+
const { errMessage } = loadOTB();
|
|
99
|
+
assert.equal(errMessage({ error: { code: 'c', message: 'the real sentence' } }), 'the real sentence');
|
|
100
|
+
assert.equal(errMessage({ message: 'legacy sentence' }), 'legacy sentence');
|
|
101
|
+
assert.equal(errMessage({ error: 'c' }), '');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('errDetails takes error.details, else falls back to the BODY (flat shape)', () => {
|
|
105
|
+
const { errDetails } = loadOTB();
|
|
106
|
+
assert.deepEqual(errDetails({ error: { code: 'c', details: { conflicts: [{ title: 'A' }] } } }).conflicts, [{ title: 'A' }]);
|
|
107
|
+
// On the flat shape the extra fields sat at the top level.
|
|
108
|
+
assert.deepEqual(errDetails({ error: 'c', conflicts: [{ title: 'B' }] }).conflicts, [{ title: 'B' }]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await t('the readers survive hostile / empty input without throwing', () => {
|
|
112
|
+
const { errCode, errMessage, errDetails } = loadOTB();
|
|
113
|
+
for (const bad of [undefined, null, {}, { error: null }, { error: 0 }, { error: [] }, 'str', 42]) {
|
|
114
|
+
assert.equal(typeof errCode(bad), 'string', `errCode(${JSON.stringify(bad)})`);
|
|
115
|
+
assert.equal(typeof errMessage(bad), 'string');
|
|
116
|
+
assert.equal(typeof errDetails(bad), 'object');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---- 2. end to end through the REAL task.js --------------------------------
|
|
121
|
+
|
|
122
|
+
// Minimal DOM: only what task.js touches (getElementById/querySelector by id,
|
|
123
|
+
// addEventListener, innerHTML, textContent). Elements are plain objects with a
|
|
124
|
+
// click() that invokes the registered handler.
|
|
125
|
+
// The DOM handlers call runLifecycle without returning its promise, so the toast
|
|
126
|
+
// lands several microtasks after the click. Flush rather than guess a timeout.
|
|
127
|
+
async function flush(n = 12) {
|
|
128
|
+
for (let i = 0; i < n; i += 1) await new Promise((r) => setImmediate(r));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makeEl(id) {
|
|
132
|
+
return {
|
|
133
|
+
id, innerHTML: '', textContent: '', disabled: false, dataset: {}, style: {},
|
|
134
|
+
_handlers: {},
|
|
135
|
+
addEventListener(ev, fn) { this._handlers[ev] = fn; },
|
|
136
|
+
click() { return this._handlers.click && this._handlers.click(); },
|
|
137
|
+
querySelector: () => null,
|
|
138
|
+
querySelectorAll: () => [],
|
|
139
|
+
classList: { add() {}, remove() {}, toggle() {} },
|
|
140
|
+
setAttribute() {}, removeAttribute() {}, appendChild() {}, remove() {},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function bootTaskPage({ task, failWith }) {
|
|
145
|
+
const els = new Map();
|
|
146
|
+
const el = (id) => {
|
|
147
|
+
const key = String(id).replace(/^#/, '');
|
|
148
|
+
if (!els.has(key)) els.set(key, makeEl(key));
|
|
149
|
+
return els.get(key);
|
|
150
|
+
};
|
|
151
|
+
const toasts = [];
|
|
152
|
+
const document = {
|
|
153
|
+
_ready: null,
|
|
154
|
+
// renderTask does document.title.split(' — ') — it must be a real string.
|
|
155
|
+
title: '#0 — task — Cloud Bongos',
|
|
156
|
+
addEventListener(ev, fn) { if (ev === 'DOMContentLoaded') this._ready = fn; },
|
|
157
|
+
getElementById: (id) => el(id),
|
|
158
|
+
querySelector: (sel) => el(sel),
|
|
159
|
+
querySelectorAll: () => [],
|
|
160
|
+
createElement: () => makeEl('tmp'),
|
|
161
|
+
body: makeEl('body'),
|
|
162
|
+
};
|
|
163
|
+
const sandbox = {
|
|
164
|
+
document,
|
|
165
|
+
console,
|
|
166
|
+
setTimeout,
|
|
167
|
+
clearTimeout,
|
|
168
|
+
location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, href: '', assign() {} },
|
|
169
|
+
window: {
|
|
170
|
+
OTBBranding: { taskCreditValue: () => null, taskCreditLabel: () => 'credits' },
|
|
171
|
+
confirm: () => true,
|
|
172
|
+
location: { hostname: 'builders.example.com', pathname: `/task/${task.id}`, assign() {} },
|
|
173
|
+
// The generated typed client's result mode: { status, ok, data }.
|
|
174
|
+
BongosClient: {
|
|
175
|
+
createClient: () => ({
|
|
176
|
+
request: async (method, urlPath) => {
|
|
177
|
+
if (method === 'GET' && urlPath.includes('/me')) {
|
|
178
|
+
return { ok: true, status: 200, data: { builder: { id: 1, rank: 'archon' } } };
|
|
179
|
+
}
|
|
180
|
+
if (method === 'GET') return { ok: true, status: 200, data: { task, last_claim: null } };
|
|
181
|
+
// The lifecycle POST — refuse it with the shape under test.
|
|
182
|
+
return { ok: false, status: failWith.status, data: failWith.body };
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
sandbox.window.document = document;
|
|
189
|
+
sandbox.globalThis = sandbox;
|
|
190
|
+
vm.createContext(sandbox);
|
|
191
|
+
// dom-utils FIRST, in this same context — exactly the real page's script order,
|
|
192
|
+
// and required because dom-utils' `$` closes over the document it sees at
|
|
193
|
+
// definition time.
|
|
194
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'dom-utils.js'), 'utf8'), sandbox, { filename: 'dom-utils.js' });
|
|
195
|
+
const realToast = sandbox.window.OTB.toast;
|
|
196
|
+
sandbox.window.OTB = { ...sandbox.window.OTB, toast: (m) => { toasts.push(String(m)); } };
|
|
197
|
+
void realToast;
|
|
198
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'task.js'), 'utf8'), sandbox, { filename: 'task.js' });
|
|
199
|
+
assert.ok(document._ready, 'task.js registered a DOMContentLoaded handler');
|
|
200
|
+
await document._ready();
|
|
201
|
+
return { els, el, toasts };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const VERIFY_TASK = { id: 4242, kind: 'verify', title: 'a verify task', status: 'completed', description: 'x' };
|
|
205
|
+
|
|
206
|
+
await t('END TO END: a 409 envelope renders the SERVER\'S sentence, not the generic fallback', async () => {
|
|
207
|
+
const serverSentence = "Task #4242 is a verification task (kind='verify') — confirm it only AFTER the verification ran.";
|
|
208
|
+
const { el, toasts } = await bootTaskPage({
|
|
209
|
+
task: VERIFY_TASK,
|
|
210
|
+
failWith: {
|
|
211
|
+
status: 409,
|
|
212
|
+
body: { error: { code: 'verify_attestation_required', message: serverSentence, details: {} } },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const btn = el('confirm-btn');
|
|
216
|
+
assert.ok(btn._handlers.click, 'the confirm control rendered and is wired');
|
|
217
|
+
await btn.click();
|
|
218
|
+
await flush();
|
|
219
|
+
const shown = toasts.join(' | ');
|
|
220
|
+
assert.ok(
|
|
221
|
+
shown.includes(serverSentence),
|
|
222
|
+
`the toast must carry the server's own sentence. got: ${shown}`
|
|
223
|
+
);
|
|
224
|
+
assert.ok(
|
|
225
|
+
!/Could not complete that/.test(shown),
|
|
226
|
+
`the generic fallback is exactly the bug — got: ${shown}`
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await t('END TO END: the legacy FLAT shape still maps (older server)', async () => {
|
|
231
|
+
const { el, toasts } = await bootTaskPage({
|
|
232
|
+
// status 'confirmed' + a supervisor is what renders the SHIP control.
|
|
233
|
+
task: { ...VERIFY_TASK, kind: 'feature', status: 'confirmed' },
|
|
234
|
+
failWith: { status: 409, body: { error: 'task_not_confirmed' } },
|
|
235
|
+
});
|
|
236
|
+
const btn = el('ship-btn');
|
|
237
|
+
assert.ok(btn._handlers.click, 'the ship control rendered');
|
|
238
|
+
await btn.click();
|
|
239
|
+
await flush();
|
|
240
|
+
const shown = toasts.join(' | ');
|
|
241
|
+
assert.match(shown, /only be shipped once it is confirmed/, `got: ${shown}`);
|
|
242
|
+
assert.ok(!/Could not complete that/.test(shown), `got: ${shown}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---- 3. no flat reads left in either consumer -------------------------------
|
|
246
|
+
|
|
247
|
+
await t('neither work.js nor task.js reads the flat error shape any more', () => {
|
|
248
|
+
for (const rel of ['work.js', 'task.js']) {
|
|
249
|
+
const src = fs.readFileSync(path.join(HALL, rel), 'utf8');
|
|
250
|
+
const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
251
|
+
assert.doesNotMatch(code, /data\s*&&\s*data\.error\b/, `${rel}: still reads data.error directly`);
|
|
252
|
+
assert.doesNotMatch(code, /err\.data\.error\s*===/, `${rel}: still compares err.data.error to a string`);
|
|
253
|
+
assert.doesNotMatch(code, /\bdata\.conflicts\b/, `${rel}: still reads conflicts off the body`);
|
|
254
|
+
assert.doesNotMatch(code, /\bdata\.what_to_do\b/, `${rel}: still reads what_to_do off the body`);
|
|
255
|
+
// And it must pull the shared readers rather than redefine a fifth copy.
|
|
256
|
+
assert.match(src, /window\.OTB/, `${rel}: uses the shared toolkit`);
|
|
257
|
+
assert.match(src, /errCode/, `${rel}: goes through errCode`);
|
|
258
|
+
assert.doesNotMatch(code, /function errCode\s*\(/, `${rel}: must NOT define its own copy`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
console.log('');
|
|
263
|
+
console.log(`hall_error_envelope: ${passed} passed, ${failed} failed`);
|
|
264
|
+
process.exit(failed === 0 ? 0 : 1);
|