@bongos/core 1.19.271 → 1.19.273
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 +22 -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/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/context-pack.js +62 -4
- package/src/module-api.js +5 -1
- package/tests/context_pack.mjs +105 -2
- package/tests/hall_error_envelope.mjs +264 -0
package/tests/context_pack.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
const {
|
|
18
18
|
apiContract,
|
|
19
19
|
resolveWorktreeRoot,
|
|
20
|
+
worktreeGuidance,
|
|
20
21
|
osFacts,
|
|
21
22
|
normalizeClaims,
|
|
22
23
|
buildContextPack,
|
|
@@ -177,6 +178,104 @@ t('buildContextPack: composes worktree + claims + api + os from injected runners
|
|
|
177
178
|
assert.equal(pack.os.platform, 'linux');
|
|
178
179
|
});
|
|
179
180
|
|
|
181
|
+
// ---- task 1002573: which tree is this, and does the guidance tell the truth ----
|
|
182
|
+
//
|
|
183
|
+
// The pack used to print the root and then assert "this is the session's tree,
|
|
184
|
+
// not the main checkout" UNCONDITIONALLY. Claiming from the main checkout
|
|
185
|
+
// therefore produced a flatly false sentence, and the tooling told the builder to
|
|
186
|
+
// do the one thing that makes the ship unmergeable (the receiving tree is the one
|
|
187
|
+
// holding the branch — task 1002572). The signal was already computed elsewhere
|
|
188
|
+
// (claim.js detectWorktreeName) and simply never surfaced.
|
|
189
|
+
//
|
|
190
|
+
// Stubs mirror real git: in the MAIN checkout `--git-dir` and `--git-common-dir`
|
|
191
|
+
// both answer '.git'; in a LINKED worktree they diverge.
|
|
192
|
+
const gitMain = (args) => {
|
|
193
|
+
const a = args.join(' ');
|
|
194
|
+
if (a.includes('--show-toplevel')) return '/repo\n';
|
|
195
|
+
if (a.includes('--git-common-dir')) return '.git\n';
|
|
196
|
+
if (a.includes('--git-dir')) return '.git\n';
|
|
197
|
+
throw new Error(`unexpected git call: ${a}`);
|
|
198
|
+
};
|
|
199
|
+
const gitLinked = (args) => {
|
|
200
|
+
const a = args.join(' ');
|
|
201
|
+
if (a.includes('--show-toplevel')) return '/repo/.claude/worktrees/task-1\n';
|
|
202
|
+
if (a.includes('--git-common-dir')) return '/repo/.git\n';
|
|
203
|
+
if (a.includes('--git-dir')) return '/repo/.git/worktrees/task-1\n';
|
|
204
|
+
throw new Error(`unexpected git call: ${a}`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
t('resolveWorktreeRoot: identical git-dir and common-dir ⇒ the MAIN checkout', () => {
|
|
208
|
+
const r = resolveWorktreeRoot({ runGit: gitMain, cwd: '/elsewhere' });
|
|
209
|
+
assert.equal(r.root, '/repo');
|
|
210
|
+
assert.equal(r.degraded, false);
|
|
211
|
+
assert.equal(r.isMainCheckout, true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
t('resolveWorktreeRoot: diverging git-dir ⇒ a LINKED worktree', () => {
|
|
215
|
+
const r = resolveWorktreeRoot({ runGit: gitLinked, cwd: '/elsewhere' });
|
|
216
|
+
assert.equal(r.root, '/repo/.claude/worktrees/task-1');
|
|
217
|
+
assert.equal(r.isMainCheckout, false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
t('resolveWorktreeRoot: unresolvable tree kind is UNKNOWN (null), never a guess', () => {
|
|
221
|
+
// toplevel works, but the dir probes fail — the kind must not default to either.
|
|
222
|
+
const partial = (args) => {
|
|
223
|
+
if (args.join(' ').includes('--show-toplevel')) return '/repo\n';
|
|
224
|
+
throw new Error('no');
|
|
225
|
+
};
|
|
226
|
+
assert.equal(resolveWorktreeRoot({ runGit: partial, cwd: '/c' }).isMainCheckout, null);
|
|
227
|
+
// And a total git failure still degrades to cwd without throwing.
|
|
228
|
+
const dead = resolveWorktreeRoot({ runGit: () => { throw new Error('no git'); }, cwd: '/here' });
|
|
229
|
+
assert.equal(dead.root, '/here');
|
|
230
|
+
assert.equal(dead.degraded, true);
|
|
231
|
+
assert.equal(dead.isMainCheckout, null);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
t('worktreeGuidance: the main-checkout text DIFFERS from the worktree text', () => {
|
|
235
|
+
// The task's own verify criterion: the guidance must not read the same in both
|
|
236
|
+
// trees. Anything else means the builder is being told the same thing whether
|
|
237
|
+
// it is true or not.
|
|
238
|
+
const mainText = worktreeGuidance({ isMainCheckout: true }).join('\n');
|
|
239
|
+
const wtText = worktreeGuidance({ isMainCheckout: false }).join('\n');
|
|
240
|
+
assert.notEqual(mainText, wtText);
|
|
241
|
+
// The old, wrong sentence must NOT appear in the main checkout.
|
|
242
|
+
assert.doesNotMatch(mainText, /not the main checkout/i,
|
|
243
|
+
'the main checkout must never be told it is not the main checkout');
|
|
244
|
+
assert.match(wtText, /not the main checkout/i, 'a real worktree keeps the original line');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
t('worktreeGuidance: the main-checkout warning names the CONSEQUENCE, not just the fact', () => {
|
|
248
|
+
const text = worktreeGuidance({ isMainCheckout: true }).join('\n');
|
|
249
|
+
assert.match(text, /MAIN CHECKOUT/);
|
|
250
|
+
assert.match(text, /auto-merge/i, 'says what breaks');
|
|
251
|
+
assert.match(text, /confirmed/, 'and where the ship strands');
|
|
252
|
+
assert.match(text, /git worktree add/, 'and gives the command that avoids it');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
t('worktreeGuidance: unknown tree kind hedges instead of asserting either', () => {
|
|
256
|
+
for (const w of [{ isMainCheckout: null }, {}, null, undefined]) {
|
|
257
|
+
const text = worktreeGuidance(w).join('\n');
|
|
258
|
+
assert.match(text, /Could not tell/i, `hedged for ${JSON.stringify(w)}`);
|
|
259
|
+
assert.doesNotMatch(text, /THIS IS THE MAIN CHECKOUT/, 'never assert main');
|
|
260
|
+
assert.doesNotMatch(text, /This is the session's tree/, 'never assert worktree');
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
t('formatContextPack: renders the main-checkout WARNING when that is the truth', () => {
|
|
265
|
+
const pack = buildContextPack({
|
|
266
|
+
activeClaims: [{ task_id: 942, title: 'x' }],
|
|
267
|
+
currentTaskId: 942,
|
|
268
|
+
cwd: '/fallback',
|
|
269
|
+
platform: 'linux',
|
|
270
|
+
runGit: gitMain,
|
|
271
|
+
runWhich: fakeWhich({ timeout: '/usr/bin/timeout', psql: '/usr/bin/psql' }),
|
|
272
|
+
});
|
|
273
|
+
const out = formatContextPack(pack);
|
|
274
|
+
assert.match(out, /THIS IS THE MAIN CHECKOUT/);
|
|
275
|
+
assert.doesNotMatch(out, /not the main checkout/i);
|
|
276
|
+
assert.ok(!/undefined/.test(out), 'no undefined leaked into the rendered pack');
|
|
277
|
+
});
|
|
278
|
+
|
|
180
279
|
// ---- formatting ------------------------------------------------------------
|
|
181
280
|
t('formatClaims: empty vs held, with the (this one) marker', () => {
|
|
182
281
|
assert.equal(formatClaims([]), '(none held)');
|
|
@@ -194,16 +293,20 @@ t('formatOs: names the macOS no-timeout case in prose', () => {
|
|
|
194
293
|
});
|
|
195
294
|
|
|
196
295
|
t('formatContextPack: includes worktree root, prefix, a real kind, and warns the wrong-tree footgun', () => {
|
|
296
|
+
// task 1002573: uses a LINKED-worktree stub. It previously passed an
|
|
297
|
+
// args-ignoring stub, which returned the same path for every probe — i.e. the
|
|
298
|
+
// main checkout — while asserting the "not the main checkout" line. The test
|
|
299
|
+
// was encoding the bug.
|
|
197
300
|
const pack = buildContextPack({
|
|
198
301
|
activeClaims: [{ task_id: 942, title: 'x' }],
|
|
199
302
|
currentTaskId: 942,
|
|
200
303
|
cwd: '/fallback',
|
|
201
304
|
platform: 'linux',
|
|
202
|
-
runGit:
|
|
305
|
+
runGit: gitLinked,
|
|
203
306
|
runWhich: fakeWhich({ timeout: '/usr/bin/timeout', psql: '/usr/bin/psql' }),
|
|
204
307
|
});
|
|
205
308
|
const out = formatContextPack(pack);
|
|
206
|
-
assert.match(out,
|
|
309
|
+
assert.match(out, /task-1/);
|
|
207
310
|
assert.match(out, /\/api\/bongos/);
|
|
208
311
|
assert.match(out, /feature/); // a real kind from VALID_KINDS
|
|
209
312
|
assert.match(out, /not the main checkout/i);
|
|
@@ -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);
|