@bongos/core 1.20.48 → 1.20.50

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.
Files changed (54) hide show
  1. package/.bongos-core.json +84 -54
  2. package/.claude/skills/new-project/SKILL.md +5 -0
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/api/openapi.json +3 -0
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +434 -400
  7. package/docs/copy-registry.json +816 -508
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-inventory.json +11 -2
  10. package/docs/page-readings.json +733 -690
  11. package/modules/hall-ui/public/criterion-uat-ui.js +280 -0
  12. package/modules/hall-ui/public/goal-map.html +5 -3
  13. package/modules/hall-ui/public/goals-page.js +23 -44
  14. package/modules/hall-ui/public/goals-render.js +24 -15
  15. package/modules/hall-ui/public/goals.css +45 -0
  16. package/modules/hall-ui/public/goals.html +5 -2
  17. package/modules/hall-ui/public/goals.states.json +38 -0
  18. package/modules/hall-ui/public/sky-data.js +9 -1
  19. package/modules/hall-ui/public/sky-interaction.js +1 -1
  20. package/modules/hall-ui/public/sky-panel.js +1 -1
  21. package/modules/hall-ui/public/thinking.html +5 -3
  22. package/modules/lifecycle/done-when.js +3 -1
  23. package/modules/lifecycle/sky.js +23 -8
  24. package/modules/platform-identity/routes/sso.js +5 -0
  25. package/modules/provisioning/migrations/provisioning_033_terms_ack.sql +33 -0
  26. package/modules/provisioning/onboard-plan.js +5 -0
  27. package/modules/provisioning/provisioning.js +7 -4
  28. package/modules/provisioning/routes/provisioning.js +3 -3
  29. package/modules/provisioning/terms-ack.js +36 -0
  30. package/modules/public-landing/public/projects.html +226 -77
  31. package/modules/public-landing/public/projects.probes.json +8 -2
  32. package/modules/public-landing/public/projects.states.json +16 -12
  33. package/modules/ui-design/kit/serve.js +6 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/hall-preview/server.js +2 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/claim_action.mjs +6 -1
  40. package/tests/criterion_uat_ui.mjs +113 -0
  41. package/tests/goal_criterion_confirm_ui.mjs +188 -91
  42. package/tests/goal_map_page.mjs +2 -1
  43. package/tests/hall_sky.mjs +16 -0
  44. package/tests/helpers.mjs +3 -1
  45. package/tests/projects_hub.mjs +4 -2
  46. package/tests/projects_hub_app_status.mjs +1 -1
  47. package/tests/projects_hub_app_step.mjs +9 -9
  48. package/tests/projects_hub_module_picker.mjs +7 -7
  49. package/tests/projects_hub_pre_uat.mjs +4 -1
  50. package/tests/provisioning_terms_ack.mjs +216 -0
  51. package/tests/sky_read.mjs +7 -1
  52. package/tests/wizard_draft_resume.mjs +4 -4
  53. package/tests/wizard_front_door.mjs +271 -0
  54. package/tests/wizard_intent_resume.mjs +31 -19
@@ -1,4 +1,6 @@
1
- // tests/goal_criterion_confirm_ui.mjs — in-hall criterion confirmation (task 1002164).
1
+ // tests/goal_criterion_confirm_ui.mjs — in-hall criterion confirmation (task 1002164),
2
+ // now the in-hall UAT (task 1004402, ADR 0351): the bare Confirm button (a POST
3
+ // /satisfy) is gone, and a criterion awaiting UAT is tested and signed off here.
2
4
  //
3
5
  // The goal's bx-hall criterion says core task actions must work in the builders
4
6
  // hall WITHOUT invoking the LLM. Criterion confirmation violated that: the goal
@@ -47,20 +49,39 @@ const GOALS_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/go
47
49
  const GOAL_FOREST_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-render.js'), 'utf8');
48
50
  const GOALS_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-render.js'), 'utf8');
49
51
 
52
+ const UAT_UI_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/criterion-uat-ui.js'), 'utf8');
53
+
50
54
  const { test, summary } = makeRunner();
51
55
 
56
+ // task 1004402: the server sends each criterion's UAT state (ADR 0351). The
57
+ // "legacy" set is a server too old to send it.
52
58
  const CRIT = {
53
- satisfied: { id: 501, criterion_id: 'c-done', criterion_md: 'Already confirmed', satisfied: true, pending_review: false },
54
- pending: { id: 502, criterion_id: 'c-pending', criterion_md: 'All its tasks shipped', satisfied: false, pending_review: true },
55
- open: { id: 503, criterion_id: 'c-open', criterion_md: 'Still has open work', satisfied: false, pending_review: false },
59
+ satisfied: { id: 501, criterion_id: 'c-done', criterion_md: 'Already confirmed', satisfied: true, pending_review: false, uat_state: 'satisfied_uat' },
60
+ pending: { id: 502, criterion_id: 'c-pending', criterion_md: 'All its tasks shipped', satisfied: false, pending_review: true, uat_state: 'awaiting_uat', awaiting_uat: true },
61
+ open: { id: 503, criterion_id: 'c-open', criterion_md: 'Still has open work', satisfied: false, pending_review: false, uat_state: 'open' },
56
62
  };
63
+ const LEGACY = Object.values(CRIT).map(({ uat_state, awaiting_uat, ...c }) => c);
64
+
65
+ // A fake element the UAT wiring can query: data-* lookups answer from `parts`.
66
+ function fakeNode(parts = {}) {
67
+ return {
68
+ dataset: {}, hidden: false, textContent: '', disabled: false, isConnected: true, children: [],
69
+ querySelector: (sel) => {
70
+ for (const [k, v] of Object.entries(parts)) if (sel.includes(k)) return v;
71
+ return null;
72
+ },
73
+ appendChild(child) { this.children.push(child); return child; },
74
+ setAttribute() {},
75
+ closest() { return null; },
76
+ };
77
+ }
57
78
 
58
79
  // Boot goals-page.js against a fake API and return what it rendered + recorded.
59
80
  // `rank` drives the client-side control gate; `goalStatus` drives the goal state.
60
- async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT), satisfyResponse, criterionlessTaskCount } = {}) {
61
- const calls = { requests: [], toasts: [] };
81
+ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT), criterionlessTaskCount, uatPost, uatView, upload } = {}) {
82
+ const calls = { requests: [], toasts: [], fetches: [] };
62
83
  const els = new Map();
63
- const listeners = new Map(); // selector -> [handler]
84
+ const listeners = new Map(); // selector -> { click: [], submit: [] }
64
85
 
65
86
  const makeEl = (key) => {
66
87
  const el = {
@@ -68,11 +89,10 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
68
89
  innerHTML: '',
69
90
  classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
70
91
  setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
71
- appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; },
92
+ appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; }, querySelector() { return null; },
72
93
  addEventListener(type, fn) {
73
- if (type !== 'click') return;
74
- if (!listeners.has(key)) listeners.set(key, []);
75
- listeners.get(key).push(fn);
94
+ if (!listeners.has(key)) listeners.set(key, { click: [], submit: [] });
95
+ if (listeners.get(key)[type]) listeners.get(key)[type].push(fn);
76
96
  },
77
97
  };
78
98
  return el;
@@ -87,18 +107,17 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
87
107
  const detail = { goal, members: [], criteria, dependencies: [], criterionless_task_count: criterionlessTaskCount };
88
108
 
89
109
  const api = {
90
- request(method, p) {
91
- calls.requests.push([method, p]);
92
- const reply = (data, status = 200) => Promise.resolve({ status, ok: true, data });
110
+ request(method, p, opts) {
111
+ calls.requests.push([method, p, opts && opts.body]);
112
+ const reply = (data, status = 200) => Promise.resolve({ status, ok: status < 400, data });
93
113
  if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: 1, rank }, active_claims: [] });
94
114
  if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals: [goal] });
95
115
  if (method === 'GET' && /\/goals\/7$/.test(p)) return reply(detail);
96
116
  if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks: [] });
97
117
  if (method === 'GET' && /\/conflicts$/.test(p)) return reply({ pairs: [] });
98
118
  if (method === 'GET' && /\/requests$/.test(p)) return reply({ requests: [] });
99
- if (method === 'POST' && /\/done-when\/\d+\/satisfy$/.test(p)) {
100
- return Promise.resolve(satisfyResponse || { status: 200, ok: true, data: { criterion: {}, goal_achieved: false, goal: null } });
101
- }
119
+ if (method === 'GET' && /\/done-when\/\d+\/uat$/.test(p)) return Promise.resolve(uatView || reply({ criterion: { id: 502 }, uat_state: 'awaiting_uat', backend_only: false, checks: { code: true, live: { available: false }, uat: null } }));
120
+ if (method === 'POST' && /\/done-when\/\d+\/uat$/.test(p)) return Promise.resolve(uatPost || { status: 201, ok: true, data: { ok: true, criterion_closed: true, achieved_goals: [] } });
102
121
  return reply({});
103
122
  },
104
123
  };
@@ -146,11 +165,17 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
146
165
  getElementById: (id) => $(`#${id}`),
147
166
  querySelector: (sel) => $(sel),
148
167
  querySelectorAll: () => [],
149
- createElement: () => makeEl('created'),
168
+ createElement: () => ({ ...makeEl('created'), remove() {} }),
150
169
  addEventListener() {},
151
170
  };
171
+ // The recording upload is a raw fetch (the API client only speaks JSON).
172
+ const fetchStub = async (url, init) => {
173
+ calls.fetches.push([url, init]);
174
+ const out = upload || { status: 201, body: { ok: true, recording: 'uat-502-0123456789abcdef.webm' } };
175
+ return { ok: out.status < 400, status: out.status, json: async () => out.body };
176
+ };
152
177
 
153
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
178
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise, fetch: fetchStub, CSS: { escape: (s) => String(s) } };
154
179
  sandbox.globalThis = sandbox;
155
180
  vm.createContext(sandbox);
156
181
  // task 1003458: the page loads claim-action.js before this script; the stub
@@ -161,37 +186,62 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
161
186
  vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' });
162
187
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
163
188
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
189
+ // task 1004402: criterion-uat-ui.js loads before goals-render.js (goals.html).
190
+ vm.runInContext(UAT_UI_SRC, sandbox, { filename: 'criterion-uat-ui.js' });
164
191
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
165
192
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
166
193
 
167
194
  // Let boot()'s promise chain settle (me → goals → details → route → render).
168
- for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
169
-
170
- const clickConfirm = async (critId) => {
171
- const handlers = listeners.get('#goal-completion-body') || [];
172
- assert.ok(handlers.length > 0, 'a click listener must be wired on the completion band');
173
- const btn = { disabled: false, textContent: 'Confirm', dataset: { confirmCrit: String(critId) } };
174
- const ev = {
175
- target: { closest: (sel) => (sel === '[data-confirm-crit]' ? btn : null) },
176
- preventDefault() {},
177
- };
178
- for (const fn of handlers) fn(ev);
179
- for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
180
- return btn;
195
+ const settle = async () => { for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0)); };
196
+ await settle();
197
+
198
+ const band = () => listeners.get('#goal-completion-body') || { click: [], submit: [] };
199
+
200
+ // Click "Test and sign off" on a criterion: the panel is appended to its row.
201
+ const openPanel = async (critId) => {
202
+ const row = fakeNode({});
203
+ const btn = { dataset: { uatOpen: String(critId) }, setAttribute() {}, closest: (sel) => (sel === '.crit' ? row : null) };
204
+ assert.ok(band().click.length > 0, 'a click listener must be wired on the completion band');
205
+ for (const fn of band().click) fn({ target: { closest: (sel) => (sel === '[data-uat-open]' ? btn : null) }, preventDefault() {} });
206
+ await settle();
207
+ return row;
208
+ };
209
+
210
+ // Submit a sign-off form: `file` given (even null = none chosen) = a UAT form
211
+ // with its file input; omitted = a backend sign-off form, which has none.
212
+ const submitForm = async ({ critId = 502, file, note = '', attest = null }) => {
213
+ const error = { hidden: true, textContent: '' };
214
+ const submitBtn = { disabled: false, textContent: 'Sign off', isConnected: true };
215
+ const parts = { 'button[type="submit"]': submitBtn, '[data-uat-note]': { value: note } };
216
+ if (file !== undefined) parts['[data-uat-file]'] = { files: file ? [file] : [] };
217
+ if (attest !== null) parts['[data-uat-attest]'] = { checked: attest };
218
+ const form = { ...fakeNode(parts), dataset: { uatForm: String(critId) } };
219
+ documentObj.querySelector = (sel) => (sel.includes('data-uat-error') ? error : $(sel));
220
+ const root = els.get('#goal-completion-body');
221
+ root.querySelector = (sel) => (sel.includes('data-uat-error') ? error : null);
222
+ for (const fn of band().submit) fn({ target: { closest: (sel) => (sel === '[data-uat-form]' ? form : null) }, preventDefault() {} });
223
+ await settle();
224
+ return { error, submitBtn };
181
225
  };
182
226
 
183
- return { calls, band: $('#goal-completion-body').innerHTML, full: $('#goal-completion-full').innerHTML, clickConfirm };
227
+ return {
228
+ calls,
229
+ band: $('#goal-completion-body').innerHTML,
230
+ full: $('#goal-completion-full').innerHTML,
231
+ openPanel, submitForm,
232
+ };
184
233
  }
185
234
 
235
+ const posts = (calls, re) => calls.requests.filter(([m, p]) => m === 'POST' && re.test(p));
236
+
186
237
  // =========================================================================
187
- // The three criterion states
238
+ // The criterion states
188
239
  // =========================================================================
189
240
 
190
- await test('the goal detail renders all three criterion states distinguishably', async () => {
241
+ await test('the goal detail renders the criterion states distinguishably', async () => {
191
242
  const { band } = await render();
192
- assert.match(band, /crit--done/, 'a confirmed criterion keeps its done styling');
193
- assert.match(band, /crit--pending/, 'a met-but-unconfirmed criterion gets its own state');
194
- // The open criterion is the one with neither modifier.
243
+ assert.match(band, /crit--done/, 'a closed criterion keeps its done styling');
244
+ assert.match(band, /crit--pending/, 'an awaiting-UAT criterion gets its own state');
195
245
  const items = band.match(/<li class="crit[^"]*"/g) || [];
196
246
  assert.equal(items.length, 3, `three criteria render — got ${JSON.stringify(items)}`);
197
247
  assert.equal(items.filter((s) => /crit--done/.test(s)).length, 1, 'exactly one done');
@@ -199,58 +249,82 @@ await test('the goal detail renders all three criterion states distinguishably',
199
249
  assert.equal(items.filter((s) => !/crit--(done|pending)/.test(s)).length, 1, 'exactly one plain/open');
200
250
  });
201
251
 
202
- await test('a pending criterion is the ONLY one offering Confirm (Metic)', async () => {
252
+ await test('each state reads in the server\'s own words (Awaiting UAT, Satisfied (UAT))', async () => {
253
+ const { band } = await render();
254
+ assert.match(band, /class="crit-state crit-state--awaiting_uat">Awaiting UAT</);
255
+ assert.match(band, /class="crit-state crit-state--satisfied_uat">Satisfied \(UAT\)</);
256
+ assert.doesNotMatch(band, /crit-state--open/, 'an open criterion carries no pill: the empty mark already says so');
257
+ });
258
+
259
+ await test('a Backend-only criterion carries a visible tag', async () => {
260
+ const { band } = await render({ criteria: [{ ...CRIT.pending, backend_only: true }] });
261
+ assert.match(band, /class="crit-tag"[^>]*>Backend-only</);
262
+ });
263
+
264
+ await test('an Awaiting UAT criterion is the ONLY one offering "Test and sign off" (Metic)', async () => {
203
265
  const { band } = await render({ rank: 'metic' });
204
- const buttons = band.match(/data-confirm-crit="(\d+)"/g) || [];
205
- assert.equal(buttons.length, 1, `exactly one Confirm control — got ${JSON.stringify(buttons)}`);
206
- assert.match(buttons[0], new RegExp(`"${CRIT.pending.id}"`), 'it targets the pending criterion');
266
+ const buttons = band.match(/data-uat-open="(\d+)"[^>]*>Test and sign off/g) || [];
267
+ assert.equal(buttons.length, 1, `exactly one sign-off control — got ${JSON.stringify(buttons)}`);
268
+ assert.match(buttons[0], new RegExp(`"${CRIT.pending.id}"`), 'it targets the awaiting criterion');
269
+ assert.match(band, /data-uat-open="501"[^>]*>See the UAT/, 'a UAT-closed criterion offers its evidence instead');
207
270
  });
208
271
 
209
- await test('an Archon also gets Confirm (Metic+ means at-or-above, not equals)', async () => {
272
+ await test('an Archon also gets the control (Metic+ means at-or-above, not equals)', async () => {
210
273
  const { band } = await render({ rank: 'archon' });
211
- assert.match(band, /data-confirm-crit/, 'an Archon outranks Metic and keeps the control');
274
+ assert.match(band, />Test and sign off</);
212
275
  });
213
276
 
214
- await test('a Xenos sees the pending STATE but never the Confirm control', async () => {
277
+ await test('a Xenos sees the Awaiting UAT state but never the sign-off control', async () => {
215
278
  const { band } = await render({ rank: 'xenos' });
216
279
  assert.match(band, /crit--pending/, 'the state is still visible to everyone');
217
- assert.doesNotMatch(band, /data-confirm-crit/, 'the action is hidden below Metic');
218
- assert.match(band, /crit__flag/, 'a read-only "Ready" flag stands in for the button');
280
+ assert.doesNotMatch(band, />Test and sign off</, 'the action is hidden below Metic');
281
+ assert.match(band, /class="crit__flag"[^>]*>Awaiting UAT</, 'a read-only flag stands in for the button');
219
282
  });
220
283
 
221
- await test('a thetes (below Metic) also gets no Confirm control', async () => {
284
+ await test('a thetes (below Metic) also gets no sign-off control', async () => {
222
285
  const { band } = await render({ rank: 'thetes' });
223
- assert.doesNotMatch(band, /data-confirm-crit/, 'thetes is below metic in the ladder');
286
+ assert.doesNotMatch(band, />Test and sign off</);
224
287
  });
225
288
 
226
- await test('an achieved goal offers no Confirm (nothing left to confirm)', async () => {
227
- const { band } = await render({ goalStatus: 'achieved' });
228
- assert.doesNotMatch(band, /data-confirm-crit/, 'a closed goal is not actionable');
289
+ await test('an achieved or archived goal offers no sign-off', async () => {
290
+ for (const goalStatus of ['achieved', 'archived']) {
291
+ const { band } = await render({ goalStatus });
292
+ assert.doesNotMatch(band, />Test and sign off</, goalStatus);
293
+ }
229
294
  });
230
295
 
231
- await test('an archived goal offers no Confirm', async () => {
232
- const { band } = await render({ goalStatus: 'archived' });
233
- assert.doesNotMatch(band, /data-confirm-crit/, 'an archived goal is not actionable');
296
+ await test('THE BYPASS IS GONE: nothing on the page can POST /satisfy', async () => {
297
+ for (const criteria of [Object.values(CRIT), LEGACY]) {
298
+ const { band, full } = await render({ rank: 'archon', criteria });
299
+ assert.doesNotMatch(band + full, /data-confirm-crit|\/satisfy/, 'no bare Confirm survives, on a new or an old server');
300
+ }
301
+ assert.doesNotMatch(SRC, /\/satisfy`/, 'goals-page.js holds no /satisfy call at all');
234
302
  });
235
303
 
236
304
  // =========================================================================
237
- // The summary line — the /goal-review punt is gone
305
+ // The summary line
238
306
  // =========================================================================
239
307
 
240
308
  await test('the summary no longer sends the builder to the /goal-review CLI skill', async () => {
241
309
  const { band } = await render();
242
- assert.doesNotMatch(band, /goal-review/, 'the LLM/CLI punt is removed — this is the whole point of the task');
310
+ assert.doesNotMatch(band, /goal-review/);
243
311
  });
244
312
 
245
- await test('the summary counts what is actually ready to confirm', async () => {
313
+ await test('the summary counts what is awaiting UAT', async () => {
246
314
  const { band } = await render();
247
- assert.match(band, /1 criterion is met and ready to confirm/, 'singular phrasing for one pending criterion');
315
+ assert.match(band, /1 criterion is awaiting UAT: the work has shipped/, 'singular phrasing for one awaiting criterion');
248
316
  });
249
317
 
250
- await test('with nothing pending, the summary explains when a criterion becomes confirmable', async () => {
318
+ await test('with nothing awaiting, the summary explains when a criterion is ready for its UAT', async () => {
251
319
  const { band } = await render({ criteria: [CRIT.satisfied, CRIT.open] });
252
- assert.doesNotMatch(band, /ready to confirm/, 'no false "ready" claim when nothing is pending');
253
- assert.match(band, /once every task linked to it has shipped/, 'explains the precondition instead');
320
+ assert.doesNotMatch(band, /awaiting UAT:/);
321
+ assert.match(band, /ready for its UAT once every task linked to it has shipped/);
322
+ });
323
+
324
+ await test('an older server (no uat_state) keeps the old sentence and draws no UAT control', async () => {
325
+ const { band } = await render({ criteria: LEGACY });
326
+ assert.match(band, /1 criterion is met and ready to confirm/);
327
+ assert.doesNotMatch(band, /data-uat-open|crit-state/, 'no UAT vocabulary the server did not send');
254
328
  });
255
329
 
256
330
  // =========================================================================
@@ -289,47 +363,70 @@ await test('an achieved goal keeps its own summary line', async () => {
289
363
  });
290
364
 
291
365
  // =========================================================================
292
- // The action itself
366
+ // The action itself — the real wiring, driven by events
293
367
  // =========================================================================
294
368
 
295
- await test('clicking Confirm POSTs /done-when/:id/satisfy for that criterion', async () => {
296
- const { calls, clickConfirm } = await render();
297
- await clickConfirm(CRIT.pending.id);
298
- const posts = calls.requests.filter(([m, p]) => m === 'POST' && /\/done-when\/\d+\/satisfy$/.test(p));
299
- assert.equal(posts.length, 1, `exactly one satisfy POST — got ${JSON.stringify(calls.requests)}`);
300
- assert.match(posts[0][1], new RegExp(`/done-when/${CRIT.pending.id}/satisfy$`), 'targets the clicked criterion');
369
+ await test('opening a criterion reads GET /done-when/:id/uat and draws the panel under its row', async () => {
370
+ const { calls, openPanel } = await render();
371
+ const row = await openPanel(CRIT.pending.id);
372
+ assert.ok(calls.requests.some(([m, p]) => m === 'GET' && /\/done-when\/502\/uat$/.test(p)), 'the UAT view is read');
373
+ assert.equal(row.children.length, 1, 'one panel appended to the row');
374
+ assert.match(row.children[0].innerHTML, /data-uat-form="502"/, 'with the sign-off form');
375
+ assert.match(row.children[0].innerHTML, /I tried this on the live site/, 'and the attestation, since this site cannot read its deploy');
301
376
  });
302
377
 
303
- await test('confirming re-reads the goal so the row updates in place (no manual refresh)', async () => {
304
- const { calls, clickConfirm } = await render();
378
+ await test('a UAT uploads the recording as raw bytes, THEN signs off naming it, then re-reads the goal', async () => {
379
+ const { calls, submitForm } = await render();
305
380
  const before = calls.requests.filter(([m, p]) => m === 'GET' && /\/goals\/7$/.test(p)).length;
306
- await clickConfirm(CRIT.pending.id);
381
+ const file = { type: 'video/webm', size: 1024 };
382
+ await submitForm({ file, note: 'made a rank, assigned it', attest: true });
383
+ assert.equal(calls.fetches.length, 1, 'one upload');
384
+ assert.match(calls.fetches[0][0], /\/done-when\/502\/uat\/recording$/);
385
+ assert.equal(calls.fetches[0][1].body, file, 'the file itself is the body, not base64');
386
+ assert.equal(calls.fetches[0][1].headers['Content-Type'], 'video/webm');
387
+ const signed = posts(calls, /\/done-when\/502\/uat$/);
388
+ assert.equal(signed.length, 1);
389
+ // Compared through JSON: an object born in the vm is never strictly deepEqual.
390
+ assert.equal(JSON.stringify(signed[0][2]), JSON.stringify({ kind: 'uat', recording: 'uat-502-0123456789abcdef.webm', note: 'made a rank, assigned it', live_attested: true }));
307
391
  const after = calls.requests.filter(([m, p]) => m === 'GET' && /\/goals\/7$/.test(p)).length;
308
- assert.ok(after > before, 'the detail is force-reloaded after a successful confirm');
392
+ assert.ok(after > before, 'the detail is force-reloaded so the row closes in place');
393
+ assert.ok(calls.toasts.some((t) => /criterion is closed/.test(t)));
309
394
  });
310
395
 
311
- await test('confirming the LAST criterion announces the goal auto-achieved', async () => {
312
- const { calls, clickConfirm } = await render({
313
- satisfyResponse: { status: 200, ok: true, data: { criterion: {}, goal_achieved: true, goal: { id: 7, status: 'achieved' } } },
314
- });
315
- await clickConfirm(CRIT.pending.id);
316
- assert.ok(calls.toasts.some((t) => /achieved/i.test(t)), `the auto-achieve is surfaced — got ${JSON.stringify(calls.toasts)}`);
396
+ await test('a backend sign-off sends no recording and requires saying how it was checked', async () => {
397
+ const empty = await render();
398
+ const { error } = await empty.submitForm({ note: ' ' });
399
+ assert.equal(error.textContent, 'Say how you checked it.');
400
+ assert.equal(posts(empty.calls, /\/uat$/).length, 0, 'nothing is sent without it');
401
+ const ok = await render();
402
+ await ok.submitForm({ note: 'the audit log has the row' });
403
+ assert.equal(JSON.stringify(posts(ok.calls, /\/done-when\/502\/uat$/)[0][2]), JSON.stringify({ kind: 'backend_signoff', note: 'the audit log has the row' }));
404
+ assert.equal(ok.calls.fetches.length, 0, 'no upload for a backend sign-off');
317
405
  });
318
406
 
319
- await test('an ordinary confirm reports success without claiming the goal is achieved', async () => {
320
- const { calls, clickConfirm } = await render();
321
- await clickConfirm(CRIT.pending.id);
322
- assert.ok(calls.toasts.some((t) => /Criterion confirmed\.$/.test(t)), `plain confirmation — got ${JSON.stringify(calls.toasts)}`);
323
- assert.ok(!calls.toasts.some((t) => /achieved/i.test(t)), 'no false auto-achieve claim');
407
+ await test('the page refuses before sending: no file, wrong type, over 100 MB, attestation unticked', async () => {
408
+ const cases = [
409
+ [{ file: null, attest: true }, 'Choose the recording first.'],
410
+ [{ file: { type: 'image/png', size: 10 }, attest: true }, 'A UAT recording must be an mp4 or webm video.'],
411
+ [{ file: { type: 'video/mp4', size: 100 * 1024 * 1024 + 1 }, attest: true }, /over the 100 MB cap/],
412
+ [{ file: { type: 'video/mp4', size: 10 }, attest: false }, 'Confirm that you tried it on the live site.'],
413
+ ];
414
+ for (const [input, expected] of cases) {
415
+ const r = await render();
416
+ const { error } = await r.submitForm(input);
417
+ if (expected instanceof RegExp) assert.match(error.textContent, expected); else assert.equal(error.textContent, expected);
418
+ assert.equal(r.calls.fetches.length, 0, 'nothing was uploaded');
419
+ assert.equal(posts(r.calls, /\/uat$/).length, 0, 'nothing was signed');
420
+ }
324
421
  });
325
422
 
326
- await test('a double-click fires the satisfy POST only once', async () => {
327
- const { calls, clickConfirm } = await render();
328
- const handlersFired = clickConfirm(CRIT.pending.id);
329
- await clickConfirm(CRIT.pending.id);
330
- await handlersFired;
331
- const posts = calls.requests.filter(([m, p]) => m === 'POST' && /\/satisfy$/.test(p));
332
- assert.equal(posts.length, 1, `in-flight guard holds — got ${posts.length} POSTs`);
423
+ await test('a server refusal is shown in the server\'s own words, and the goal achieved on the last one is announced', async () => {
424
+ const refused = await render({ uatPost: { status: 403, ok: false, data: { error: { code: 'signer_shipped_this_work', message: 'You shipped work linked to this criterion, so someone else has to sign it off.' } } } });
425
+ const { error } = await refused.submitForm({ note: 'checked' });
426
+ assert.equal(error.textContent, 'You shipped work linked to this criterion, so someone else has to sign it off.');
427
+ const last = await render({ uatPost: { status: 201, ok: true, data: { ok: true, criterion_closed: true, achieved_goals: [7] } } });
428
+ await last.submitForm({ note: 'checked' });
429
+ assert.ok(last.calls.toasts.some((t) => /goal is achieved/.test(t)), JSON.stringify(last.calls.toasts));
333
430
  });
334
431
 
335
432
  summary();
@@ -427,7 +427,8 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
427
427
  assert.match(html, /<h1>the sky <em id="lensWord">— the whole project<\/em><\/h1>/, 'the h1 is in static markup, so every state has one');
428
428
  assert.equal(/shell\.js|style\.css/.test(html), false, 'no hall chrome yet — /thinking joined it (task 1004327), the project sky did not');
429
429
  const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
430
- assert.deepEqual(scripts(html).map((s) => s[0]), ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
430
+ // criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
431
+ assert.deepEqual(scripts(html).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
431
432
  // /thinking also loads the shell (task 1004327); the SKY's files are the same.
432
433
  const skyScripts = (h) => scripts(h).filter((s) => s[0] !== 'shell.js');
433
434
  assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
@@ -497,4 +497,20 @@ await test('a capped trail is spoken, never a silent slice', async () => {
497
497
  });
498
498
 
499
499
  console.log('');
500
+ await test('a ring carries its UAT state in the goal page words, and says it on hover and in the panel (task 1004402)', async () => {
501
+ const w = well({ criteria: [
502
+ { id: '70', criterion_id: 'c1', criterion_md: 'Awaiting ring', satisfied: false, pending_review: true, uat_state: 'awaiting_uat', backend_only: true, task_ids: ['500'] },
503
+ { id: '71', criterion_id: 'c2', criterion_md: 'Closed ring', satisfied: true, pending_review: false, uat_state: 'closed_before_uat', task_ids: ['500'] },
504
+ { id: '72', criterion_id: 'c3', criterion_md: 'Open ring', satisfied: false, pending_review: false, uat_state: 'open', task_ids: ['501'] },
505
+ ] });
506
+ const p = await boot({ sky: sky([w]) });
507
+ const crit = p.S().goals[0].crit;
508
+ assert.equal(crit[0].uatLabel, 'Awaiting UAT');
509
+ assert.equal(crit[0].backendOnly, true);
510
+ assert.equal(crit[1].uatLabel, 'Closed before UAT', 'every state, not only awaiting');
511
+ assert.equal(crit[2].uatLabel, null, 'an open ring needs no word: its open ring already says so');
512
+ const src = FS.readFileSync('modules/hall-ui/public/sky-interaction.js', 'utf8') + FS.readFileSync('modules/hall-ui/public/sky-panel.js', 'utf8');
513
+ assert.equal((src.match(/esc\(c\.uatLabel\)/g) || []).length, 2, 'the hover card and the panel both print the label, escaped');
514
+ });
515
+
500
516
  summary();
package/tests/helpers.mjs CHANGED
@@ -767,7 +767,9 @@ export const settle = () => new Promise((r) => setTimeout(r, 0));
767
767
  // carry the same markup and the same scripts and differ in the body's
768
768
  // `data-sky-lens` (which lens is home). A test names the `page`; each is parsed
769
769
  // from its OWN file, so a button lost from either one fails.
770
- const SKY_FILES = ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
770
+ // criterion-uat-ui.js (task 1004402): both sky pages load it before sky-data,
771
+ // which reads the rings' UAT state words from it.
772
+ const SKY_FILES = ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
771
773
  const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
772
774
  const SKY_PAGES = {};
773
775
  function skyPage(page) {
@@ -714,7 +714,9 @@ test('a renumbering of the wizard invalidates old drafts — the key carries the
714
714
  assert.match(key, /-v\d+$/, 'the draft key is versioned');
715
715
  const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
716
716
  const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
717
- assert.equal(count, 10, 'gate + eight numbered steps + done — bump DRAFT_KEY when this changes');
717
+ // task 1004417 put the front door's two screens (terms, fork) in front: the sixth renumbering
718
+ assert.equal(count, 12, 'gate + the front door\'s two + eight numbered steps + done — bump DRAFT_KEY when this changes');
719
+ assert.equal(key, 'cb-create-draft-v6', 'and the key moved with the front door');
718
720
  });
719
721
 
720
722
  test('every row lays the same five parts — the ledger has no ragged row', () => {
@@ -2182,7 +2184,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2182
2184
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2183
2185
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2184
2186
  assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
2185
- assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="7">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2187
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="9">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2186
2188
  assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
2187
2189
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2188
2190
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
@@ -240,7 +240,7 @@ test('a repaint never lands on top of an open Retry — from this watch or a fre
240
240
  });
241
241
 
242
242
  test('both surfaces use it: the done panel and the project page', () => {
243
- assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 9; \}, 5000\);/);
243
+ assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 11; \}, 5000\);/);
244
244
  assert.match(HUB, /<div id="doneApp" hidden><\/div>/);
245
245
  assert.match(HUB, /<div class="card mSec" id="mAppCard" hidden>\n\s*<h2>Where its app runs<\/h2>\n\s*<div id="mApp"><\/div>/);
246
246
  assert.match(HUB, /loadAppStatus\(inst\.id \|\| asked, inst\);/);
@@ -142,17 +142,17 @@ test('the panel is real markup: Render and Decide later, Decide later the defaul
142
142
  assert.doesNotMatch(PANEL, /Coming soon/, 'and is no longer marked soon');
143
143
  assert.match(PANEL, /<input type="radio" name="appPlatform" value="later" checked>/, 'Decide later is the default');
144
144
  assert.match(PANEL, /We host your Builders' hall/, 'the step says the hall is ours to host (ADR 0345 D1), in the page\'s one spelling of it');
145
- assert.match(PANEL, /<form data-next="8">/, 'Next leads to the review');
146
- assert.match(PANEL, /data-back="6"/, 'Back returns to the address step');
145
+ assert.match(PANEL, /<form data-next="10">/, 'Next leads to the review');
146
+ assert.match(PANEL, /data-back="8"/, 'Back returns to the address step');
147
147
  });
148
148
 
149
149
  test('the step sits between Address and Review in the panels map and on the rail', () => {
150
- assert.match(HUB, /6: 'panel-address', 7: 'panel-app', 8: 'panel-review', 9: 'panel-done'/);
151
- assert.match(HUB, /<li data-step="7"><span class="snum">7<\/span><span class="slbl">App<\/span><\/li>/);
152
- assert.match(HUB, /<li data-step="8"><span class="snum">8<\/span><span class="slbl">Create<\/span><\/li>/);
153
- assert.match(HUB, /data-back="7">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the app step");
154
- assert.match(HUB, /if \(step === 7\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 8\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
155
- assert.match(HUB, /if \(step === 7\) return validateRender\(\);/, 'Next on the step runs the Render check');
150
+ assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-review', 11: 'panel-done'/);
151
+ assert.match(HUB, /<li data-step="9"><span class="snum">7<\/span><span class="slbl">App<\/span><\/li>/);
152
+ assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Create<\/span><\/li>/);
153
+ assert.match(HUB, /data-back="9">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the app step");
154
+ assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
155
+ assert.match(HUB, /if \(step === 9\) return validateRender\(\);/, 'Next on the step runs the Render check');
156
156
  });
157
157
 
158
158
  test('a fresh draft reads "decide later", and the Render setup stays hidden', () => {
@@ -455,5 +455,5 @@ test('the review names exactly what was chosen, prices included', () => {
455
455
  });
456
456
 
457
457
  test('Create hands the app to Render straight after the project is filed', () => {
458
- assert.match(HUB, /show\(9, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
458
+ assert.match(HUB, /show\(11, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
459
459
  });
@@ -41,7 +41,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
41
41
  const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
42
42
 
43
43
  const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
44
- const END = "$('skipModules').addEventListener('click', function () { show(5, true); });";
44
+ const END = "$('skipModules').addEventListener('click', function () { show(7, true); });";
45
45
  const from = HUB.indexOf(START);
46
46
  const to = HUB.indexOf(END);
47
47
  assert.ok(from > -1, 'the picker block opens with its sentinel comment');
@@ -160,7 +160,7 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
160
160
  const els = new Map();
161
161
  const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
162
162
  const form = makeEl('panel-modules-form');
163
- const state = { step: 4, projType: null, teamShape: null, modules: null, ...seed };
163
+ const state = { step: 6, projType: null, teamShape: null, modules: null, ...seed };
164
164
  const shown = [];
165
165
  let saves = 0;
166
166
  let fetches = 0;
@@ -312,7 +312,7 @@ test('Skip walks past WITHOUT answering, so the server applies the bundle the pa
312
312
 
313
313
  // Assert — skipping is the one path that must leave nothing said, because the
314
314
  // preselect and the server's fallback are the same declaration (ADR 0237)
315
- assert.deepEqual(h.shown, [5], 'it advances to the Home step');
315
+ assert.deepEqual(h.shown, [7], 'it advances to the Home step (7 since task 1004417)');
316
316
  assert.equal(h.state.modules, null, 'and answers nothing');
317
317
  assert.equal(h.forCreate(), undefined);
318
318
  assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they were shown is still the set they get');
@@ -377,7 +377,7 @@ test('a catalog that will not load degrades to a clause — it never blocks a pr
377
377
  assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
378
378
  assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
379
379
  h.skip();
380
- assert.deepEqual(h.shown, [5], 'and the wizard still walks on — the server applies the bundle');
380
+ assert.deepEqual(h.shown, [7], 'and the wizard still walks on — the server applies the bundle');
381
381
  });
382
382
 
383
383
  test('every rendered value goes through the page escaper', async () => {
@@ -415,7 +415,7 @@ test('the review names the set even when the picker was never opened (task 10023
415
415
  // and the module NAMES are not known. "None — just the core" would be a false
416
416
  // promise on the last screen before Create: the server is about to apply the
417
417
  // type's bundle. The row says that instead, then repaints when the read lands.
418
- const h = boot({ state: { projType: 'game', step: 8 } }); // 8 = the review since task 1004351
418
+ const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
419
419
 
420
420
  // Arrange + Act — the review asks for the value before the read can answer
421
421
  const first = h.extrasRow();
@@ -510,7 +510,7 @@ test('Skip after an answered About step still yields the set the panel showed',
510
510
 
511
511
  h.skip();
512
512
 
513
- assert.deepEqual(h.shown, [5]);
513
+ assert.deepEqual(h.shown, [7]);
514
514
  assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
515
515
  assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
516
516
  assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
@@ -821,7 +821,7 @@ test('with nothing switched on the count says only what is shown', async () => {
821
821
  });
822
822
 
823
823
  test('the review names the hidden picks too — a filter never reaches the create request', async () => {
824
- const h = boot({ state: { projType: 'game', step: 8 } }); // 8 = the review since task 1004351
824
+ const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
825
825
  await h.open();
826
826
  h.click('agents');
827
827
  assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
@@ -550,7 +550,10 @@ test('the rail at the done panel keeps its marker on Create until the project an
550
550
  assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
551
551
  assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
552
552
  'the done-panel poll repaints when the status moves');
553
- assert.match(HUB, /<li class="now" data-step="1" aria-current="step">/, 'the static rail starts with the same marker');
553
+ // the first NUMBERED beat carries it — since task 1004417 the front door's two screens come
554
+ // before the rail, and the card statically wears wizFront so a no-script visitor sees no rail there
555
+ assert.match(HUB, new RegExp(`<li class="now" data-step="${STEP['panel-name']}" aria-current="step"><span class="snum">1</span>`), 'the static rail starts with the same marker');
556
+ assert.match(HUB, /<section id="view-new" hidden>\s*<div class="wrap viewWrap">\s*<div class="card wizFront">/, 'and steps aside on the static first screen');
554
557
  });
555
558
 
556
559
  test('every action result announces, nothing under the pointer is deleted, and focus stays where it was', () => {