@bongos/core 1.21.4 → 1.21.6

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 (126) hide show
  1. package/.bongos-core.json +233 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -0
@@ -1,5 +1,8 @@
1
1
  // tests/projects_hub_module_picker.mjs — the create wizard's module picker,
2
2
  // EXECUTED (BV1.R20, task 1002339; goal 1000046 criterion `proj-module-select`).
3
+ // Since task 1004504 the picker is the review's Extras row: its controls moved into the
4
+ // row whole, "Looks right" / "Use this" confirm the set on screen, and there is no Skip —
5
+ // Create waits for every row, this one included.
3
6
  //
4
7
  // tests/projects_hub.mjs pins this surface statically, on purpose (C7: "real
5
8
  // markup, not JS-built"). But the picker is the one part of the wizard whose
@@ -11,10 +14,12 @@
11
14
  // The precedent is tests/task_detail_ui.mjs (there is no jsdom in this repo).
12
15
  // The difference: task-detail.js is a whole file, while the picker lives inside
13
16
  // projects.html's one big inline <script> — so the block is sliced out by its
14
- // own comment sentinels and run with the five things it closes over ($, root,
15
- // esc, state, saveDraft/show) supplied by the harness. That slice is itself an
16
- // assertion: rename the sentinels and this file fails loudly rather than
17
- // quietly testing nothing.
17
+ // own comment sentinel (up to the repo picker that follows it) and run with the
18
+ // things it closes over ($, root, esc, state, saveDraft, paintSetup) supplied by the
19
+ // harness. The review's row machinery (ROWS … confirmRow and its delegated click) is
20
+ // sliced out beside it, so confirming the row runs the page's own code. Both slices
21
+ // are assertions: rename an edge and this file fails loudly rather than quietly
22
+ // testing nothing.
18
23
  //
19
24
  // Run: node --test --test-reporter=tap tests/projects_hub_module_picker.mjs
20
25
 
@@ -41,12 +46,21 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
41
46
  const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
42
47
 
43
48
  const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
44
- const END = "$('skipModules').addEventListener('click', function () { show(8, true); });";
49
+ const END = '/* ── GitHub-integrated repo picker';
45
50
  const from = HUB.indexOf(START);
46
- const to = HUB.indexOf(END);
51
+ const to = HUB.indexOf(END, from);
47
52
  assert.ok(from > -1, 'the picker block opens with its sentinel comment');
48
- assert.ok(to > from, 'the picker block closes with the skip wiring');
49
- const PICKER = HUB.slice(from, to + END.length).replace(/\r\n/g, '\n');
53
+ assert.ok(to > from, 'the picker block closes where the repo picker begins');
54
+ const PICKER = HUB.slice(from, to).replace(/\r\n/g, '\n');
55
+ assert.ok(PICKER.includes('function extrasRow()'), 'and it carries the row text the review reads');
56
+ assert.doesNotMatch(HUB, /id="skipModules"|id="panel-modules"/, 'the old panel and its Skip are gone (task 1004504)');
57
+ // the review's rows: which apply, confirming one, and the delegated click
58
+ const RSTART = ' var ROWS = [';
59
+ const REND = ' /* what each row says: its value';
60
+ const rfrom = HUB.indexOf(RSTART), rto = HUB.indexOf(REND, rfrom);
61
+ assert.ok(rfrom > -1 && rto > rfrom, 'the review rows are sliced by ROWS and rowText');
62
+ const REVIEW = HUB.slice(rfrom, rto).replace(/\r\n/g, '\n');
63
+ assert.ok(REVIEW.includes('function confirmRow(key)'), 'and they carry confirmRow');
50
64
 
51
65
  // The payload GET /provisioning/starter-bundles serves (ADR 0237 + task 1002339).
52
66
  // Deliberately a FIXTURE and not a live require: this asserts the picker reads
@@ -128,9 +142,20 @@ function makeEl(id) {
128
142
  setAttribute(k, v) { attrs.set(k, String(v)); },
129
143
  getAttribute(k) { return attrs.has(k) ? attrs.get(k) : null; },
130
144
  addEventListener(type, fn) { this._handlers[type] = fn; },
145
+ focus() {},
131
146
  };
132
147
  }
133
148
 
149
+ // One review row, shaped like the markup: its hidden .opts, Looks right and Change.
150
+ function makeRow(key) {
151
+ const row = makeEl('row-' + key);
152
+ row.setAttribute('data-row', key);
153
+ const parts = { '.opts': makeEl('opts-' + key), '[data-row-ok]': makeEl('ok-' + key), '[data-row-chg]': makeEl('chg-' + key) };
154
+ parts['.opts'].hidden = true;
155
+ row.querySelector = (sel) => parts[sel] || null;
156
+ return row;
157
+ }
158
+
134
159
  // Parse the rows the picker WROTE, so a selector resolves only when the markup
135
160
  // is genuinely there — the point of the harness.
136
161
  function rowsOf(html) {
@@ -159,23 +184,28 @@ function whyOf(html) {
159
184
  function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
160
185
  const els = new Map();
161
186
  const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
162
- const form = makeEl('panel-modules-form');
163
- const state = { step: 7, projType: null, teamShape: null, modules: null, ...seed };
164
- const shown = [];
187
+ // step 4 is the review, where the Extras row lives (task 1004504)
188
+ const state = { step: 4, projType: null, teamShape: null, modules: null, confirmed: {}, ...seed };
189
+ const rows = {};
190
+ const rowFor = (sel) => { const m = /data-row="([a-z]+)"/.exec(sel); return m ? (rows[m[1]] = rows[m[1]] || makeRow(m[1])) : null; };
165
191
  let saves = 0;
166
192
  let fetches = 0;
167
193
  let reviews = 0;
168
194
 
169
195
  const sandbox = {
170
196
  $,
171
- root: { querySelector: (sel) => (sel === '#panel-modules form' ? form : null) },
197
+ root: { querySelector: (sel) => (sel.indexOf('#reviewRows .setRow') === 0 ? rowFor(sel) : null) },
172
198
  esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
173
199
  state,
174
200
  saveDraft: () => { saves++; },
175
- show: (step) => { shown.push(step); },
176
- // the block repaints the review when the read lands on step 7 (a resumed
177
- // draft can reach it having never opened the picker)
178
- renderReview: () => { reviews++; },
201
+ // the block repaints the review's rows when the read lands on step 4 (a resumed
202
+ // draft can reach it having never opened the Extras row)
203
+ paintSetup: () => { reviews++; },
204
+ // the review's other rows close over checks this file does not drive
205
+ physSkips: () => false, appSkipped: () => false, physAnswered: () => true,
206
+ phys: () => ({ answers: { framing: 'managed' } }),
207
+ validateCode: () => true, validateAddress: () => true, validateRender: () => true,
208
+ checkSlugAvailable: () => {}, renderAddrPanel: () => {}, syncHome: () => {}, syncAppStep: () => {}, syncRenderSetup: () => {},
179
209
  API: '/api/bongos',
180
210
  fetch: () => { fetches++; return Promise.resolve(fetchOk
181
211
  ? { ok: true, json: () => Promise.resolve(payload) }
@@ -184,11 +214,17 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
184
214
  };
185
215
  sandbox.globalThis = sandbox;
186
216
  vm.createContext(sandbox);
187
- vm.runInContext(PICKER, sandbox, { filename: 'projects.html#module-picker' });
217
+ vm.runInContext(PICKER + '\n' + REVIEW, sandbox, { filename: 'projects.html#module-picker' });
188
218
 
189
219
  const band = $('modCards');
220
+ // a click inside the review, through its one delegated listener, as the browser sends it
221
+ const rowClick = (attr) => {
222
+ const row = rowFor('#reviewRows .setRow[data-row="extras"]');
223
+ const t = { hasAttribute: (a) => a === attr, closest: (sel) => (sel === '.setRow' ? row : null) };
224
+ $('reviewRows')._handlers.click({ target: { closest: () => t } });
225
+ };
190
226
  return {
191
- state, shown, sandbox, form,
227
+ state, sandbox,
192
228
  saves: () => saves,
193
229
  fetches: () => fetches,
194
230
  reviews: () => reviews,
@@ -210,8 +246,12 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
210
246
  };
211
247
  band._handlers.click({ target });
212
248
  },
213
- next: () => form._handlers.submit({}),
214
- skip: () => $('skipModules')._handlers.click(),
249
+ // "Looks right" on the Extras row, "Change" to open it, "Use this" inside it
250
+ looksRight: () => rowClick('data-row-ok'),
251
+ change: () => rowClick('data-row-chg'),
252
+ use: () => rowClick('data-row-use'),
253
+ confirmed: () => !!(state.confirmed && state.confirmed.extras),
254
+ unconfirmed: () => [...sandbox.unconfirmedRows()],
215
255
  forCreate: () => sandbox.modulesForCreate(),
216
256
  picked: () => sandbox.pickedModules(),
217
257
  // BV1.R21 (task 1002340) — the search box. type() is the real `input` event
@@ -302,41 +342,42 @@ test('switching every module off is a real answer, not a reset to the bundle', a
302
342
  assert.deepEqual(h.rows().filter((r) => r.pressed), [], 'and the panel shows it');
303
343
  });
304
344
 
305
- test('Skip walks past WITHOUT answering, so the server applies the bundle the panel showed', async () => {
345
+ test('an unconfirmed row answers NOTHING, and Create waits for it (no Skip since task 1004504)', async () => {
306
346
  // Arrange
307
347
  const h = boot({ state: { projType: 'game' } });
308
348
  await h.open();
309
349
 
310
- // Act
311
- h.skip();
312
-
313
- // Assert — skipping is the one path that must leave nothing said, because the
314
- // preselect and the server's fallback are the same declaration (ADR 0237)
315
- assert.deepEqual(h.shown, [8], 'it advances to the Home step (8 since task 1004418, 7 since 1004417)');
316
- assert.equal(h.state.modules, null, 'and answers nothing');
317
- assert.equal(h.forCreate(), undefined);
318
- assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they were shown is still the set they get');
350
+ // Assert — the row is shown prefilled, and until it is confirmed nothing is said:
351
+ // the preselect and the server's fallback are the same declaration (ADR 0237)
352
+ assert.equal(h.state.modules, null, 'nothing is answered on the owner\'s behalf');
353
+ assert.equal(h.forCreate(), undefined, 'so a create now would carry no modules key');
354
+ assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they are shown is still the set they get');
355
+ assert.ok(h.unconfirmed().includes('extras'), 'and the review holds Continue until the row is confirmed');
319
356
  });
320
357
 
321
- test('Skip does not erase a toggle already made', async () => {
322
- // The About step's rule: a visitor who changed something and then pressed Skip
323
- // meant "I'm done here", not "throw that away".
358
+ test('Change, a toggle, then Use this: the toggle stands as the answer', async () => {
359
+ // A visitor who changed something and then confirmed meant "this", not "throw that away".
324
360
  const h = boot({ state: { projType: 'game' } });
325
361
  await h.open();
362
+ h.change();
363
+ assert.equal(h.sandbox.$('modCards').innerHTML.length > 0, true, 'opening the row paints the picker');
326
364
  h.click('agents');
327
- h.skip();
365
+ h.use();
328
366
 
329
367
  assert.deepEqual(h.state.modules, ['agents', 'npm-release', 'discord']);
368
+ assert.equal(h.confirmed(), true, 'and the row is confirmed');
330
369
  });
331
370
 
332
- test('Next CONFIRMS the set on screen — looking at it and accepting it is not the same as never being asked', async () => {
371
+ test('Looks right CONFIRMS the set on screen — looking at it and accepting it is not the same as never being asked', async () => {
333
372
  const h = boot({ state: { projType: 'game' } });
334
373
  await h.open();
335
374
 
336
- h.next();
375
+ h.looksRight();
337
376
 
338
377
  assert.deepEqual(h.state.modules, ['npm-release', 'discord'], 'the preselect becomes the answer');
339
378
  assert.deepEqual(h.forCreate(), ['npm-release', 'discord'], 'and rides the create request');
379
+ assert.equal(h.confirmed(), true);
380
+ assert.ok(!h.unconfirmed().includes('extras'), 'and Create no longer waits on it');
340
381
  });
341
382
 
342
383
  test('changing the project type re-preselects while nothing has been said, and never after', async () => {
@@ -376,12 +417,18 @@ test('a catalog that will not load degrades to a clause — it never blocks a pr
376
417
  assert.deepEqual(h.rows(), [], 'no rows are invented');
377
418
  assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
378
419
  assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
379
- h.skip();
380
- assert.deepEqual(h.shown, [8], 'and the wizard still walks on — the server applies the bundle');
420
+ // The row still has to be confirmable, or a failed read would block Create. But the row
421
+ // said "the suggested set", so confirming it must not file an EMPTY list — that would
422
+ // switch off the very bundle the server would otherwise apply.
423
+ h.looksRight();
424
+ assert.equal(h.confirmed(), true, 'the wizard still walks on');
425
+ // (through JSON: a vm-born array never strictly equals one born here, so a bare
426
+ // notDeepEqual would pass whatever the page did)
427
+ assert.notEqual(JSON.stringify(h.forCreate()), '[]', 'and confirming "the suggested set" never files "none"');
381
428
  });
382
429
 
383
430
  test('every rendered value goes through the page escaper', async () => {
384
- // The rows are built with innerHTML (renderReview's existing idiom in this
431
+ // The rows are built with innerHTML (the old review ledger's idiom in this
385
432
  // file), so the escaping is the safety property and it is asserted, not assumed.
386
433
  const h = boot({ state: { projType: 'game' } });
387
434
  await h.open();
@@ -410,12 +457,12 @@ test('the catalog read is LAZY and asked once — /projects serves four views fr
410
457
  assert.equal(h.fetches(), 1, 'entering the step asks exactly once, however often it is entered');
411
458
  });
412
459
 
413
- test('the review names the set even when the picker was never opened (task 1002339)', async () => {
460
+ test('the Extras row names the set even when the picker was never opened (task 1002339)', async () => {
414
461
  // A resumed draft lands straight on the review, so the catalog has not loaded
415
462
  // and the module NAMES are not known. "None — just the core" would be a false
416
- // promise on the last screen before Create: the server is about to apply the
417
- // type's bundle. The row says that instead, then repaints when the read lands.
418
- const h = boot({ state: { projType: 'game', step: 12 } }); // 12 = the review since task 1004418 (11 since 1004421, 10 since 1004417, 8 since 1004351)
463
+ // promise before Create: the server is about to apply the type's bundle. The row
464
+ // says that instead, then repaints when the read lands.
465
+ const h = boot({ state: { projType: 'game', step: 4 } }); // 4 = the review since task 1004504 (12 since 1004418)
419
466
 
420
467
  // Arrange + Act — the review asks for the value before the read can answer
421
468
  const first = h.extrasRow();
@@ -425,27 +472,31 @@ test('the review names the set even when the picker was never opened (task 10023
425
472
  assert.equal(h.fetches(), 1, 'and asking for it is what starts the read');
426
473
 
427
474
  await h.settle();
428
- assert.equal(h.reviews(), 1, 'the review repaints itself once the names are known');
475
+ assert.equal(h.reviews(), 1, 'the review repaints its rows once the names are known');
429
476
  assert.equal(h.extrasRow(), 'npm releases, Discord', 'and then it names them');
477
+ const elsewhere = boot({ state: { projType: 'game', step: 3 } });
478
+ elsewhere.extrasRow();
479
+ await elsewhere.settle();
480
+ assert.equal(elsewhere.reviews(), 0, 'off the review, a landing read repaints no rows');
430
481
  });
431
482
 
432
- test('the review reads an answered picker back, empty included', async () => {
433
- const chosen = boot({ state: { projType: 'game', modules: ['agents'], step: 8 } });
483
+ test('the Extras row reads an answered picker back, empty included', async () => {
484
+ const chosen = boot({ state: { projType: 'game', modules: ['agents'], step: 3 } });
434
485
  // before the catalog lands the row says the true thing without the names —
435
486
  // never the raw module keys, which are jargon on an owner-facing screen
436
487
  assert.equal(chosen.extrasRow(), 'The extras you picked');
437
488
  await chosen.settle();
438
489
  assert.equal(chosen.extrasRow(), 'Extra agents', 'and names them once it has');
439
490
 
440
- const none = boot({ state: { projType: 'game', modules: [], step: 8 } });
491
+ const none = boot({ state: { projType: 'game', modules: [], step: 3 } });
441
492
  none.extrasRow();
442
493
  await none.settle();
443
494
  assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
444
495
  });
445
496
 
446
497
  // ─────────────────────────────────────────────────────────────────────────────
447
- // BV1.R22 (task 1002341) — the About step's answer reaches the preselect, and
448
- // the panel says why. ADR 0243.
498
+ // BV1.R22 (task 1002341) — the team shape (the About step's answer, the review's
499
+ // Team size row since task 1004504) reaches the preselect, and the row says why. ADR 0243.
449
500
  // ─────────────────────────────────────────────────────────────────────────────
450
501
 
451
502
  test('the team shape adjusts the type bundle, and the panel says why in the owner\'s words', async () => {
@@ -487,9 +538,9 @@ test('an added module is preselected and explained', async () => {
487
538
  assert.deepEqual(h.why(), ['You said an open community is building this, so somewhere for it to gather is switched on.']);
488
539
  });
489
540
 
490
- test('answering the About step later re-preselects, with no second read', async () => {
491
- // The two questions live on two earlier panels and are both changeable. The
492
- // whole table arrived in one read precisely so going back costs nothing.
541
+ test('changing the team shape later re-preselects, with no second read', async () => {
542
+ // The kind and the team are both changeable. The whole table arrived in one read
543
+ // precisely so a change costs nothing.
493
544
  const h = boot({ state: { projType: 'research' }, payload: TAILORED });
494
545
  await h.open();
495
546
  assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'unanswered: the raw bundle');
@@ -501,19 +552,18 @@ test('answering the About step later re-preselects, with no second read', async
501
552
  assert.equal(h.fetches(), 1, 'and the answer cost no round trip');
502
553
  });
503
554
 
504
- test('Skip after an answered About step still yields the set the panel showed', async () => {
555
+ test('after a team answer the unconfirmed row still answers nothing, and confirming takes the tailored set', async () => {
505
556
  // ADR 0240 §2's property, extended to the interview's answer (ADR 0243): the
506
- // preselect and the server's fallback are the same declaration, so skipping
507
- // must still answer NOTHING and still land the tailored set.
557
+ // preselect and the server's fallback are the same declaration, so an unconfirmed
558
+ // row must still answer NOTHING, and confirming it must take the tailored set.
508
559
  const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
509
560
  await h.open();
510
561
 
511
- h.skip();
512
-
513
- assert.deepEqual(h.shown, [8]);
514
- assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
515
- assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
562
+ assert.equal(h.state.modules, null, 'unconfirmed answers nothing, tailored or not');
563
+ assert.equal(h.forCreate(), undefined, 'so the create request would carry no modules key');
516
564
  assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
565
+ h.looksRight();
566
+ assert.deepEqual(h.forCreate(), ['discord'], 'and confirming files exactly that set');
517
567
  });
518
568
 
519
569
  test('once the owner has answered, the reasons go — the panel stops arguing', async () => {
@@ -820,8 +870,8 @@ test('with nothing switched on the count says only what is shown', async () => {
820
870
  assert.equal(h.filterNote(), 'Showing 3 of 3.', 'no "0 extras are switched on" noise');
821
871
  });
822
872
 
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: 12 } }); // 12 = the review since task 1004418 (11 since 1004421, 10 since 1004417, 8 since 1004351)
873
+ test('the Extras row names the hidden picks too — a filter never reaches the create request', async () => {
874
+ const h = boot({ state: { projType: 'game', step: 4 } }); // 4 = the review since task 1004504 (12 since 1004418)
825
875
  await h.open();
826
876
  h.click('agents');
827
877
  assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
@@ -229,14 +229,21 @@ test('the wizard’s 401 recoveries keep the repo scope they already granted', (
229
229
  assert.match(fn('signInAgain'), /currentView === 'new' \? '\/projects%3Fview%3Dnew&amp;repos=1'/, 'signInAgain from the wizard');
230
230
  });
231
231
 
232
- test('a resumed draft past Home still probes repo access before Create', () => {
232
+ test('a resumed draft past the code row still probes repo access before Create', () => {
233
+ // task 1004504: the Home step is the review's code row, so the review probes on entry —
234
+ // and a draft that resumes on the look screen (past the review, before done) must still
235
+ // probe, or Create files a repo it promised to make but never did (ensureFreshRepo skips
236
+ // the create whenever repoAccess is not true)
237
+ const REVIEW = STEP['panel-review'];
238
+ assert.match(SCRIPT, new RegExp(`if \\(step === ${REVIEW}\\) \\{[\\s\\S]*?if \\(authKnown\\) ensureRepoAccess\\(\\);`),
239
+ 'the review screen probes on entry');
233
240
  assert.ok(SCRIPT.includes(
234
- `else if (step > ${STEP['panel-home']} && step < ${DONE} && authKnown && repoAccess === null) ensureRepoAccess();`),
235
- 'every step between Home and the done panel re-probes');
241
+ `else if (step > ${REVIEW} && step < ${DONE} && authKnown && repoAccess === null) ensureRepoAccess();`),
242
+ 'every screen between the review and the done panel re-probes');
236
243
  });
237
244
 
238
- test('a half-typed custom domain says why Next is dead; a taken handle silences the format cheer', () => {
239
- assert.match(SCRIPT, /That doesn’t look like a domain yet \(e\.g\. mercury\.example\.com\) — fix it, or clear the field to use the address above\./);
245
+ test('a half-typed custom domain says why Use this is dead; a taken handle silences the format cheer', () => {
246
+ assert.match(SCRIPT, /That doesn’t look like a domain yet \(e\.g\. moonbase\.example\.com\) — fix it, or clear the field to use the address above\./);
240
247
  assert.match(SCRIPT, /if \(avail === 'taken' \|\| avail === 'yours' \|\| avail === 'reserved'\) \{ var hs = \$\('h-slug'\); hs\.innerHTML = '&nbsp;'; hs\.className = 'hint'; \}/);
241
248
  });
242
249
 
@@ -245,12 +252,14 @@ test('no wall-clock promises, no operator vocabulary on the stranger’s path',
245
252
  assert.doesNotMatch(HUB, /about five minutes/, 'the empty state');
246
253
  assert.doesNotMatch(HUB, /a runner does the real work/, 'the mine fine print');
247
254
  assert.doesNotMatch(SCRIPT, /pinned platform core/, 'the review Shape row');
248
- /* Since ADR 0345 (task 1004351) the review names the HALL as hosted by us and gives the
249
- APP its own row. The assertion still guards the same property this test is about:
250
- owner vocabulary, and no wall-clock promise. */
251
- assert.ok(SCRIPT.includes("['Builders\\' hall', 'Hosted by us, on the platform’s current release'],"), 'the review names the hall as hosted by us');
255
+ /* Since ADR 0345 (task 1004351) the APP has its own row; since task 1004504 the review is
256
+ the row-by-row confirm, and that row speaks the owner's words. The assertion still
257
+ guards the same property this test is about: owner vocabulary, and no wall-clock promise. */
258
+ assert.match(HUB, /<div class="setRow" data-row="app">\s*<span class="k">App hosting<\/span>/, 'the review names the app\'s hosting in plain words');
259
+ assert.ok(SCRIPT.includes("return { v: appPlatformChoice() === 'render' ? 'Render' : 'Decide later', s: appRowDetail() };"), 'its value is a place or "Decide later", never a release name');
260
+ assert.doesNotMatch(SCRIPT, /current release|release channel/, 'no release vocabulary on the stranger\'s path');
252
261
  assert.doesNotMatch(HUB, /\/new-project<\/code> assistant/, 'the done panel’s closing line');
253
- assert.match(HUB, /Sign-in switches on after the GitHub steps|switch sign-in on/, 'the address step does not promise sign-in "just works"');
262
+ assert.match(HUB, /[Ss]ign-in switches on after the GitHub steps|switch sign-in on/, 'the address step does not promise sign-in "just works"');
254
263
  assert.doesNotMatch(HUB, /Sign-in, sharing, everything just works/);
255
264
  });
256
265
 
@@ -465,10 +474,19 @@ test('decision 5 — the Type step says where the type can be changed (task 1003
465
474
  // for now", because no owner-facing route could change a declared type. Task 1003504
466
475
  // built that route and the manage page's "Its kind" picker on it, so the sentence is
467
476
  // earned back — and it now has to name WHERE, or it is a promise with no address.
468
- const type = (HUB.match(/<section class="panel" id="panel-type"[\s\S]*?<\/section>/) || [''])[0];
469
- assert.ok(type, '#panel-type exists');
470
- assert.match(type, /<p class="lede">Pick the shape that fits best — it’s how the project is listed on the map, and you can change it later from the project’s own page\. Not sure is a fine answer\.<\/p>/);
471
- assert.doesNotMatch(type, /stays as you pick it/, 'the narrowed promise must not survive beside the wider one');
477
+ // task 1004504 folded the Type step into "What are you making?" as its Kind field, in the
478
+ // design of record's words, which make no promise about changing it. What must hold: the
479
+ // narrowed promise never comes back, and any change-later promise carries its address.
480
+ assert.doesNotMatch(HUB, /id="panel-type"/, 'the Type step is gone');
481
+ const what = (HUB.match(/<section class="panel" id="panel-what"[\s\S]*?<\/section>/) || [''])[0];
482
+ assert.ok(what, '#panel-what exists');
483
+ assert.match(what, /<span id="typeLbl">Kind<\/span>/, 'the kind is asked on the What screen');
484
+ assert.doesNotMatch(what, /stays as you pick it/, 'the narrowed promise must not survive');
485
+ if (/change it later/.test(what)) {
486
+ assert.match(what, /change it later from the project’s own page/, 'a change-later promise names WHERE — a promise with an address');
487
+ }
488
+ // and the address it would name is real: the manage page's "Its kind" picker
489
+ assert.match(HUB, /<h2>Its kind<\/h2>/, 'the project’s own page carries the kind picker');
472
490
  });
473
491
 
474
492
  test('decision 8 — a card carrying a red error paragraph never tallies under Live', () => {
@@ -542,23 +560,17 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
542
560
  assert.equal((SCRIPT.match(/manifestMsg: '', manifestCode: '', appSlug: null/g) || []).length, 2, 'both resets carry the code');
543
561
  });
544
562
 
545
- test('the rail at the done panel keeps its marker on Create until the project answers', () => {
546
- const rail = fn('paintRail');
547
- assert.ok(rail.includes(`var landed = step === ${DONE} && !!(live && live.instStatus === 'active');`),
548
- 'the rail only finishes on the done panel');
549
- assert.ok(rail.includes(`var cur = step === ${DONE} ? ${DONE - 1} : step;`),
550
- 'the done panel keeps the marker on the last numbered beat');
551
- assert.match(rail, /li\.classList\.toggle\('done', landed \|\| n < cur\);/);
552
- assert.match(rail, /if \(now\) li\.setAttribute\('aria-current', 'step'\); else li\.removeAttribute\('aria-current'\);/);
553
- assert.match(fn('show'), /paintRail\(step\);/, 'show() paints through it');
554
- assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
555
- assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
556
- 'the done-panel poll repaints when the status moves');
557
- // the first NUMBERED beat carries it — since task 1004417 the front door's two screens come
558
- // before the rail, and the card statically wears wizFront so a no-script visitor sees no rail there
559
- // (since task 1004418 that beat is planet physics, the first numbered step)
560
- assert.match(HUB, new RegExp(`<li class="now" data-step="${STEP['panel-physics']}" aria-current="step"><span class="snum">1</span>`), 'the static rail starts with the same marker');
561
- 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');
563
+ test('the progress rail is gone (task 1004504): no painter, no repaint at the done panel, no static rail', () => {
564
+ // task 1003244 kept the rail's marker on Create until the project answered; task 1004504
565
+ // retired the rail outright, so nothing on the page may still paint or carry one
566
+ assert.doesNotMatch(SCRIPT, /function paintRail|paintRail\(/, 'no rail painter, and no caller');
567
+ assert.doesNotMatch(fn('show'), /paintRail|classList\.toggle\('done'/, 'show() marks no rail of any kind');
568
+ assert.doesNotMatch(HUB, /data-step="\d+"/, 'no static rail beat ships in the markup');
569
+ assert.doesNotMatch(HUB, /<ol class="steps"/, 'no rail list ships in the markup');
570
+ // the night room sits behind the card; the card opens on the eyebrow and the one Back, then the wizard
571
+ assert.match(HUB, /<section id="view-new" hidden>[\s\S]*?<\/div>\s*<div class="wrap viewWrap">\s*<div class="card[^"]*">\s*(?:<!--[\s\S]*?-->\s*)?<div class="wizTop">[\s\S]*?<\/div>\s*<div class="createSplit">/, 'the card opens on its top row, then straight on the wizard');
572
+ const head = HUB.slice(HUB.indexOf('<section id="view-new"'), HUB.indexOf('<div class="createSplit">'));
573
+ assert.doesNotMatch(head, /class="steps|data-step=/, 'no rail between the room and the wizard');
562
574
  });
563
575
 
564
576
  test('every action result announces, nothing under the pointer is deleted, and focus stays where it was', () => {
@@ -682,16 +694,11 @@ test('the hub has one main landmark, a heading for the map, a footer, and a skip
682
694
  assert.doesNotMatch(HUB, /<main class="card">/, 'the old inner main is a div');
683
695
  });
684
696
 
685
- test('completed rail steps are a way back, the repo picker is honest ARIA, the card inerts the page', () => {
686
- assert.ok(fn('paintRail').includes(`if (step < ${DONE} && n < cur) {`),
687
- 'only answered steps become buttons, and none once the project is filed');
688
- assert.ok(fn('paintRail').includes("li.setAttribute('data-goto', String(n));"),
689
- 'and an answered step carries the goto the delegated handler reads');
690
- assert.match(SCRIPT, /railEl\.addEventListener\('click'/);
691
- assert.match(SCRIPT, /railEl\.addEventListener\('keydown'/);
692
- assert.match(SCRIPT, /show\(Number\(li\.getAttribute\('data-goto'\)\), true\)/, 'a deliberate step: focus moves');
693
- assert.match(COSMOS, /\.steps li\[role=button\]\{cursor:pointer;\}/);
694
- assert.match(COSMOS, /\.steps li\[role=button\]:focus-visible\{outline:2px solid var\(--accent\);/);
697
+ test('the rail\'s way-back handlers left with it, the repo picker is honest ARIA, the card inerts the page', () => {
698
+ // task 1003246 made answered rail steps a way back; task 1004504 retired the rail, so its
699
+ // delegated handlers and their CSS must not linger as dead code
700
+ assert.doesNotMatch(SCRIPT, /railEl|data-goto/, 'no delegated rail handler is left');
701
+ assert.doesNotMatch(COSMOS, /\.steps li\[role=button\]/, 'and no rail-button CSS');
695
702
  assert.doesNotMatch(HUB, /role="listbox"/);
696
703
  assert.doesNotMatch(SCRIPT, /role="option"/);
697
704
  assert.match(HUB, /<div id="ghList" class="ghlist" role="group" aria-label="Your repositories"><\/div>/, 'the label survives the listbox role');
@@ -1074,6 +1074,84 @@ await ta('scaffoldStandaloneRepo: a REGISTRY-pinned consumer control plane PACKS
1074
1074
  }
1075
1075
  });
1076
1076
 
1077
+ // ── new projects start on the RELEASED core, not the hall's candidate (task 1004299) ──
1078
+ // ADR 0361: merges publish as candidates and the platform hall runs them, so the core
1079
+ // this hall runs is routinely AHEAD of the registry's `latest` label. A new project —
1080
+ // and the pin refresh that runs in the same provisioning pass — must get the release.
1081
+ t('projectCoreDecision: the hall ahead of the release → the release; level or unreadable → the running core', () => {
1082
+ assert.deepEqual(
1083
+ (({ version, source }) => ({ version, source }))(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50' })),
1084
+ { version: '1.19.50', source: 'released' });
1085
+ assert.equal(RP.projectCoreDecision({ running: '1.19.50', released: '1.19.50' }).source, 'running', 'level → byte-exact running core, no registry pack');
1086
+ assert.equal(RP.projectCoreDecision({ running: '1.19.40', released: '1.19.50' }).version, '1.19.40', 'never moves a project PAST what the hall has run');
1087
+ assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: null }).version, '1.19.60', 'no label → fail-open to the running core');
1088
+ assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50-rc.1' }).source, 'running', 'a prerelease label is not a release');
1089
+ assert.equal(RP.projectCoreDecision({ running: '1.19.60', released: '1.19.50;touch /tmp/x' }).source, 'running', 'a non-semver label never reaches a shell');
1090
+ });
1091
+
1092
+ // A registry-installed control plane at 1.19.60 whose registry says `latest` = 1.19.50.
1093
+ function candidateHall({ latest }) {
1094
+ const cp = mkdtempSync(join(tmpdir(), 'cpdir-cand-'));
1095
+ mkdirSync(join(cp, 'node_modules', '@bongos', 'core'), { recursive: true });
1096
+ writeFileSync(join(cp, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.19.60' }));
1097
+ writeFileSync(join(cp, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.19.60' } }));
1098
+ const { deps, cmds } = recorder({ apply: true });
1099
+ const exec = (cmd, o) => { cmds.push(cmd); return /^npm view @bongos\/core dist-tags\.latest$/.test(cmd) ? { ok: true, stdout: `${latest}\n` } : { ok: true, stdout: '' }; };
1100
+ return { cp, cmds, deps: { ...deps, exec, instanceRoot: cp } };
1101
+ }
1102
+
1103
+ await ta('scaffoldStandaloneRepo: a hall running an UNRELEASED candidate births the project on the released core (task 1004299)', async () => {
1104
+ const { cp, cmds, deps } = candidateHall({ latest: '1.19.50' });
1105
+ const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
1106
+ const origBase = P.CONFIG.standaloneBase;
1107
+ P.CONFIG.standaloneBase = base;
1108
+ try {
1109
+ await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
1110
+ const joined = cmds.join('\n');
1111
+ assert.match(joined, /npm pack @bongos\/core@1\.19\.50 --pack-destination /, 'packs the RELEASED version from the registry');
1112
+ assert.match(joined, /--vendor-core \S*bongos-core-1\.19\.50\.tgz/, 'the child is pinned to the release');
1113
+ assert.ok(!/1\.19\.60\.tgz|bongos-core-pack-1\.19\.60/.test(joined), 'the candidate the hall runs is never vended');
1114
+ assert.match(joined, /on a pinned @bongos\/core 1\.19\.50 /, 'the scaffold commit names the version it really pinned');
1115
+ assert.ok(joined.indexOf('rm -rf') > joined.indexOf('--vendor-core'), 'the temp pack dir is cleaned after init');
1116
+ } finally {
1117
+ P.CONFIG.standaloneBase = origBase;
1118
+ [cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
1119
+ }
1120
+ });
1121
+
1122
+ await ta('scaffoldStandaloneRepo: a hall running the release itself keeps the byte-exact running core (task 1004299)', async () => {
1123
+ const { cp, cmds, deps } = candidateHall({ latest: '1.19.60' });
1124
+ const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
1125
+ const origBase = P.CONFIG.standaloneBase;
1126
+ P.CONFIG.standaloneBase = base;
1127
+ try {
1128
+ await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
1129
+ const joined = cmds.join('\n');
1130
+ assert.ok(!/npm pack @bongos\/core@/.test(joined), 'no registry pack when nothing is unreleased');
1131
+ assert.match(joined, /--vendor-core \S*bongos-core-1\.19\.60\.tgz/);
1132
+ } finally {
1133
+ P.CONFIG.standaloneBase = origBase;
1134
+ [cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
1135
+ }
1136
+ });
1137
+
1138
+ await ta('refreshStandaloneCorePin: a project pinned to the release is NOT bumped to the hall\'s candidate in the same run (task 1004299)', async () => {
1139
+ const { cp, cmds, deps } = candidateHall({ latest: '1.19.50' });
1140
+ const base = mkdtempSync(join(tmpdir(), 'cpdir-standalone-'));
1141
+ const origBase = P.CONFIG.standaloneBase;
1142
+ P.CONFIG.standaloneBase = base;
1143
+ try {
1144
+ mkdirSync(join(base, 'demo'));
1145
+ writeFileSync(join(base, 'demo', 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': 'file:vendor/bongos-core-1.19.50.tgz' } }));
1146
+ const res = await P.refreshStandaloneCorePin({ slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo' }, deps);
1147
+ assert.equal(res.action, 'current', 'the released pin is already the core this project is given');
1148
+ assert.ok(!cmds.some((c) => /npm pack/.test(c)), 'nothing is packed or re-vendored');
1149
+ } finally {
1150
+ P.CONFIG.standaloneBase = origBase;
1151
+ [cp, base].forEach((d) => rmSync(d, { recursive: true, force: true }));
1152
+ }
1153
+ });
1154
+
1077
1155
  // ── scaffold push via the owner OAuth token (task 1002317) ────────────────────────
1078
1156
  // Deploy keys are per-repo; a shared key can't write to a NEW project repo. Push with the
1079
1157
  // owner's token (which created the repo) instead — WITHOUT leaking it into a logged command.
@@ -327,7 +327,8 @@ test('the create-repo response keeps the html_url it already returned', async ()
327
327
 
328
328
  test('the no-address opt-out is visible at the step, not folded under "a domain I own" (N8)', async () => {
329
329
  const { body } = await request('/projects', 'game.test.local');
330
- const panel = (body.match(/<div class="addrConfirm" id="addrConfirm">([\s\S]*?)\n {14}<\/div>/) || [, ''])[1];
330
+ // since task 1004504 the address step is the review's Address row; the box moved in whole
331
+ const panel = (body.match(/<div class="addrConfirm" id="addrConfirm">([\s\S]*?)\n {22}<\/div>/) || [, ''])[1];
331
332
  assert.ok(panel, 'the address confirmation box must be extractable');
332
333
  const fold = (panel.match(/<details id="addrCustom">[\s\S]*?<\/details>/) || [''])[0];
333
334
  assert.ok(fold, 'the bring-your-own-domain disclosure still exists');
@@ -338,8 +339,10 @@ test('the no-address opt-out is visible at the step, not folded under "a domain
338
339
  'the opt-out sits after the disclosure, in the open');
339
340
  // all three choices legible at once: assigned address, own domain, no address
340
341
  assert.match(panel, /id="addrAssigned"/);
341
- assert.match(fold, /Use a domain I own instead/);
342
- assert.match(panel, /Start without an address/);
342
+ assert.match(fold, /<summary>A domain I own<\/summary>/);
343
+ assert.match(panel, /No address yet\. Sign-in can’t be switched on until it has one\./,
344
+ 'the opt-out is offered in the open, and says what going without costs');
345
+ assert.match(body, /<div class="setRow" data-row="address">[\s\S]*?id="addrConfirm"/, 'and the box lives on the review\'s Address row');
343
346
  });
344
347
 
345
348
  test('the ownership promise is gated on the availability answer, never on format (N10)', async () => {
@@ -375,9 +378,12 @@ test('the availability check is debounced, generation-guarded, and fails to "unk
375
378
 
376
379
  test('only a DEFINITE negative blocks the step — unknown and checking never gate it (N10)', async () => {
377
380
  const { body } = await request('/projects', 'game.test.local');
378
- const fn = (body.match(/function validateStep\(step\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
379
- assert.ok(fn, 'validateStep() must be extractable');
380
- const gate = (fn.match(/if \(!state\.domain && !state\.noAddress\) \{[\s\S]*?\n {8}\}/) || [''])[0];
381
+ // since task 1004504 the address step's Next check is validateAddress(), run when the
382
+ // review's Address row is confirmed
383
+ const fn = (body.match(/function validateAddress\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
384
+ assert.ok(fn, 'validateAddress() must be extractable');
385
+ assert.match(body, /if \(key === 'address'\) return validateAddress\(\);/, 'and the Address row cannot be confirmed past it');
386
+ const gate = (fn.match(/if \(!state\.domain && !state\.noAddress\) \{[\s\S]*?\r?\n {6}\}/) || [''])[0];
381
387
  assert.ok(gate, 'the availability gate must be extractable');
382
388
  assert.match(gate, /av === 'taken' \|\| av === 'yours' \|\| av === 'reserved'/,
383
389
  'exactly the three definite negatives block');