@bongos/core 1.20.83 → 1.21.2

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 (96) hide show
  1. package/.bongos-core.json +186 -81
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +60 -59
  14. package/docs/copy-registry.json +72 -63
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +9 -3
  19. package/docs/page-readings.json +427 -410
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/migrations/core_270_craft_achievements.sql +128 -0
  23. package/modules/economy/achievements.js +199 -4
  24. package/modules/economy/credits.js +16 -0
  25. package/modules/economy/reward.js +3 -0
  26. package/modules/hall-ui/public/builders.js +1 -1
  27. package/modules/hall-ui/public/genesis-home.js +17 -3
  28. package/modules/hall-ui/public/hall-render.js +24 -2
  29. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  30. package/modules/hall-ui/public/roster.css +21 -0
  31. package/modules/hall-ui/public/roster.html +3 -0
  32. package/modules/hall-ui/public/roster.js +121 -21
  33. package/modules/hall-ui/public/roster.states.json +26 -1
  34. package/modules/hall-ui/records/builders-roster.md +20 -0
  35. package/modules/lifecycle/publish-reconciler.js +25 -0
  36. package/modules/lifecycle/workflow-dispatch.js +21 -11
  37. package/modules/npm-release/release.js +10 -1
  38. package/modules/onboarding/founding-birth.js +116 -0
  39. package/modules/onboarding/genesis-home.js +72 -5
  40. package/modules/onboarding/port.js +9 -2
  41. package/modules/onboarding/routes/onboarding.js +8 -1
  42. package/modules/provisioning/birth.js +64 -0
  43. package/modules/provisioning/dns-resolves.js +54 -0
  44. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  45. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  46. package/modules/provisioning/provisioning.js +7 -6
  47. package/modules/provisioning/routes/provisioning.js +6 -2
  48. package/modules/provisioning/tests/liveness.mjs +48 -0
  49. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  50. package/modules/public-landing/public/projects.html +40 -15
  51. package/modules/sessions/db.js +58 -0
  52. package/modules/sessions/routes/sessions.js +97 -0
  53. package/modules/status-ui/public/status.js +39 -7
  54. package/modules/status-ui/public/style.css +24 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  56. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  57. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  58. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  59. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +45 -0
  63. package/scripts/gds/provision.js +77 -53
  64. package/scripts/gds/session-digest.js +11 -3
  65. package/scripts/gds/session-transcript-build.js +155 -0
  66. package/scripts/gds/session-transcript-upload.js +29 -0
  67. package/scripts/gds/ship-session-upload.js +6 -1
  68. package/scripts/gds/upgrade-commit-identity.js +32 -0
  69. package/scripts/gds/upgrade.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +2 -0
  71. package/src/bongos/db.js +13 -0
  72. package/src/bongos/role-stats.js +36 -2
  73. package/src/bongos/routes/public.js +13 -2
  74. package/src/bongos/routes.js +3 -0
  75. package/src/branding.js +9 -0
  76. package/src/module-api.js +1 -1
  77. package/tests/achievements_reconcile.mjs +13 -0
  78. package/tests/craft_achievements.mjs +349 -0
  79. package/tests/founding_birth.mjs +200 -0
  80. package/tests/founding_builders.mjs +2 -1
  81. package/tests/founding_mode.mjs +3 -1
  82. package/tests/genesis_home.mjs +71 -2
  83. package/tests/hall_builders_page_pass.mjs +2 -1
  84. package/tests/hub_map_orb_states.mjs +5 -1
  85. package/tests/leaderboard_roles.mjs +272 -0
  86. package/tests/npm_release_release.mjs +37 -2
  87. package/tests/projects_hub.mjs +5 -3
  88. package/tests/projects_hub_dns_ready.mjs +153 -0
  89. package/tests/projects_hub_pre_uat.mjs +9 -5
  90. package/tests/provision_dns_first.mjs +171 -0
  91. package/tests/provision_settings_apply.mjs +6 -1
  92. package/tests/provisioning_settings_env.mjs +49 -0
  93. package/tests/session_transcript.mjs +273 -0
  94. package/tests/ui_design_kit.mjs +1 -1
  95. package/tests/upgrade_commit_identity.mjs +89 -0
  96. package/tests/wizard_demo.mjs +29 -3
@@ -91,6 +91,58 @@ test('a plan out of range is refused with the reason', () => {
91
91
  assert.equal(gh.parsePlan({ expected_builders: 0 }).reason, 'bad_expected_builders');
92
92
  });
93
93
 
94
+ // ── the demo's expedited plan (task 1004506) ────────────────────────────────
95
+
96
+ test('a demo\'s stages are minutes, fitted to a fifth of its time box (10 min to 2 h)', () => {
97
+ const sum = (m) => Object.values(m).reduce((a, b) => a + b, 0);
98
+ assert.deepEqual(gh.demoMinutes(4), { scope: 14, strategize: 10, charter: 10, legalize: 5, legislation: 10 },
99
+ '3 people × 4 hours: about 48 minutes of genesis, then building');
100
+ assert.deepEqual(Object.keys(gh.demoMinutes(1)), STAGES5);
101
+ for (const h of [1, 2, 4, 8, 20, 40]) {
102
+ const total = sum(gh.demoMinutes(h));
103
+ assert.ok(total <= h * 60 * 0.25, `${h}h: genesis (${total} min) leaves most of the time box for building`);
104
+ assert.ok(total >= 8 && total <= 125, `${h}h: ${total} min is inside the bounds`);
105
+ assert.ok(Object.values(gh.demoMinutes(h)).every((m) => Number.isInteger(m) && m >= 1));
106
+ }
107
+ });
108
+
109
+ test('the demo plan is the settled one: Monarchy / BDFL, one pass, no advisors, the demo\'s people', () => {
110
+ const r = gh.parsePlan(gh.demoPlan({ people: 3, hours_each: 4 }));
111
+ assert.equal(r.ok, true, JSON.stringify(r));
112
+ assert.equal(r.plan.government, 'monarchy');
113
+ assert.equal(r.plan.passes, 1);
114
+ assert.equal(r.plan.advisors, 'none');
115
+ assert.equal(r.plan.expected_builders, 3);
116
+ assert.equal(r.plan.existing, false);
117
+ assert.deepEqual(r.plan.demo, { people: 3, hours_each: 4 });
118
+ assert.deepEqual(r.plan.minutes, gh.demoMinutes(4));
119
+ assert.equal(gh.parsePlan({ demo: { people: 0, hours_each: 4 } }).reason, 'bad_demo');
120
+ assert.equal(gh.parsePlan({ minutes: { scope: 5 } }).reason, 'bad_minutes', 'every stage needs its minutes');
121
+ assert.equal(gh.parsePlan({}).plan.minutes, undefined, 'a real project\'s plan stays in days');
122
+ });
123
+
124
+ test('the plan route does not name the demo fields, so only the birth can write a demo plan', () => {
125
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
126
+ const schema = src.slice(src.indexOf("router.post('/founding/plan'"), src.indexOf("router.post('/founding/stages"));
127
+ assert.ok(schema.includes('validateOrRespond'));
128
+ assert.doesNotMatch(schema, /\bdemo\b|\bminutes\b/);
129
+ });
130
+
131
+ test('a demo genesis reads in minutes: each stage due minutes after it began', () => {
132
+ const plan = gh.parsePlan(gh.demoPlan({ people: 2, hours_each: 4 })).plan;
133
+ const g = gh.shapeGenesis({
134
+ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
135
+ plan, stageAt: { scope: '2026-10-01T09:00:00.000Z' },
136
+ });
137
+ assert.deepEqual(g.demo, { people: 2, hours_each: 4 });
138
+ assert.equal(g.stages[0].minutes, 14);
139
+ assert.equal(g.stages[0].due, '2026-10-01T09:14:00.000Z');
140
+ const real = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan, stageAt: { scope: '2026-10-01T09:00:00.000Z' } });
141
+ assert.equal(real.demo, null);
142
+ assert.equal(real.stages[0].minutes, null);
143
+ assert.equal(real.stages[0].due, '2026-10-08T09:00:00.000Z', 'a real project is unchanged: days');
144
+ });
145
+
94
146
  // ── the flow, end to end over fakes ──────────────────────────────────────────
95
147
 
96
148
  function world({ withGovernment = true } = {}) {
@@ -232,7 +284,7 @@ test('every genesis write is the project owner\'s; the read is any signed-in bui
232
284
 
233
285
  const CARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
234
286
 
235
- function bootCard({ founding }) {
287
+ function bootCard({ founding, data = { genesis: null } }) {
236
288
  const calls = [];
237
289
  let widget = null;
238
290
  let observer = null;
@@ -240,7 +292,7 @@ function bootCard({ founding }) {
240
292
  const sandbox = {
241
293
  window: {
242
294
  OTB: { escapeHtml: (s) => String(s), contributeHallWidget: (w) => { widget = w; } },
243
- BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data: { genesis: null } }; } }) },
295
+ BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data }; } }) },
244
296
  },
245
297
  document: { documentElement: { getAttribute: (k) => attrs[k] || null } },
246
298
  MutationObserver: class { constructor(fn) { observer = fn; } observe() {} disconnect() {} },
@@ -277,6 +329,23 @@ test('founding mode stamped after mount still starts the card, once', async () =
277
329
  assert.deepEqual(b.calls, ['GET /api/bongos/founding/genesis']);
278
330
  });
279
331
 
332
+ test('a demo\'s genesis card: no decide form, the settled plan in one line, each stage in minutes (task 1004506)', async () => {
333
+ const genesis = gh.shapeGenesis({
334
+ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
335
+ plan: gh.parsePlan(gh.demoPlan({ people: 3, hours_each: 4 })).plan,
336
+ stageAt: { scope: '2026-10-01T09:00:00.000Z' },
337
+ });
338
+ const { host } = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
339
+ await new Promise((r) => setImmediate(r));
340
+ await new Promise((r) => setImmediate(r));
341
+ assert.equal(host.hidden, false);
342
+ assert.match(host.innerHTML, /Monarchy \/ BDFL, 3 people, 4 hours each/);
343
+ assert.match(host.innerHTML, /about 49 minutes/);
344
+ assert.match(host.innerHTML, /14 min, due /, 'the stage in progress is due at a time, not a date');
345
+ assert.match(host.innerHTML, /Stage 1 of 5 · scope · demo/);
346
+ assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
347
+ });
348
+
280
349
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
281
350
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
282
351
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -174,7 +174,8 @@ test('A: the quiet fill is a defined variant, not an inline colour', () => {
174
174
  // capture the rowHtml it hands makeLedger, and call the real function.
175
175
  async function rosterRowHtml(rows) {
176
176
  let rowFn = null;
177
- const elStub = { innerHTML: '', className: '', textContent: '', hidden: false };
177
+ // addEventListener/querySelectorAll: the role toggle wires its chips (task 1004437).
178
+ const elStub = { innerHTML: '', className: '', textContent: '', hidden: false, addEventListener() {}, querySelectorAll: () => [] };
178
179
  const sandbox = {
179
180
  window: {
180
181
  OTB: { fmtInt: (n) => String(n == null ? 0 : n), rankLabel: (r) => r },
@@ -66,6 +66,10 @@ const PA_START = SCRIPT.indexOf('function planetAttrs(');
66
66
  const PA_END = SCRIPT.indexOf('var PLANET_SHAPE_WORDS');
67
67
  assert.ok(PA_START > 0 && PA_END > PA_START, 'planetAttrs must still be sliceable');
68
68
  const PLANET_ATTRS = SCRIPT.slice(PA_START, PA_END);
69
+ // The row's site door (task 1004505) and the rule it asks, sliced the same way.
70
+ const ADDRESS_READY = (SCRIPT.match(/function addressReady\(inst\) \{[^\n]*\}/) || [''])[0];
71
+ const SITE_HREF = (SCRIPT.match(/function siteHref\(p\) \{[^\n]*\}/) || [''])[0];
72
+ assert.ok(ADDRESS_READY && SITE_HREF, 'addressReady and siteHref must still be sliceable');
69
73
 
70
74
  // ── the DOM stub ─────────────────────────────────────────────────────────────
71
75
  // Narrow on purpose: it supports exactly what the region touches, so it stays
@@ -111,7 +115,7 @@ function boot() {
111
115
  listedDate: (iso) => String(iso),
112
116
  };
113
117
  vm.createContext(sandbox);
114
- vm.runInContext(PLANET_ATTRS + '\n' + REGION, sandbox);
118
+ vm.runInContext(PLANET_ATTRS + '\n' + ADDRESS_READY + '\n' + SITE_HREF + '\n' + REGION, sandbox);
115
119
  return {
116
120
  sandbox,
117
121
  sky,
@@ -0,0 +1,272 @@
1
+ // tests/leaderboard_roles.mjs — the leaderboard, the roster and the status page
2
+ // tell the roles apart (task 1004437 / WA6.RP07, goal 1000095, criterion
3
+ // wa6-roles-read-true-in-public).
4
+ //
5
+ // The done-when: "all three lists show roles per the mock and no browser-only
6
+ // artist shows a misleading 0". Run for real:
7
+ //
8
+ // 1. THE READ. readRolesForBuilders over a page of builders: the profile's own
9
+ // main/shown rules (chosen, else earned, else preferred), a craft shown only
10
+ // once it earned, two reads for the whole page and never one per row.
11
+ // 2. THE ROUTE. GET /public/leaderboard carries each row's roles and per-craft
12
+ // credits (the figure a role ranks by is in the row, ADR 0255), and a roles
13
+ // failure costs the roles, never the board.
14
+ // 3. THE ROSTER. The REAL roster.js over the harness's public fixture: a Roles
15
+ // column, the Everyone / Engineers / Artists / Ideators toggle ranking by
16
+ // the credits that craft earned, and a dash, not a 0, for a builder with no
17
+ // AI sessions.
18
+ // 4. THE STANDING CARD and 5. THE STATUS PAGE: the real functions, run.
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { makeRunner } from './helpers.mjs';
27
+
28
+ process.env.NODE_ENV = 'test';
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
31
+ const { test, summary } = makeRunner();
32
+ const seams = require('../src/module-seams.js');
33
+ const creditsByCraftLib = require('../modules/economy/credits-by-craft.js');
34
+ const FIX = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/public__leaderboard.json'), 'utf8'));
35
+ const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\r\n/g, '\n');
36
+
37
+ // economy's own fold over hand-written grouped rows: what the ledger statement returns
38
+ function rewardOver(grouped, calls) {
39
+ return {
40
+ craftForPreference: creditsByCraftLib.craftForPreference,
41
+ creditsByCraft: async (ids) => { calls.push(['credits', ids]); return creditsByCraftLib.foldCraftRows(ids, grouped); },
42
+ };
43
+ }
44
+
45
+ // ---- 1. the read ------------------------------------------------------------------
46
+
47
+ await test('a page of builders: chosen, earned and preferred main roles, and a craft shown only once it earned', async () => {
48
+ const { readRolesForBuilders } = require('../src/bongos/role-stats.js');
49
+ const calls = [];
50
+ seams._reset();
51
+ seams.registerProvider('reward', rewardOver([
52
+ { builder_id: '1', discipline: 'engineer', amount: 900 },
53
+ { builder_id: '1', discipline: 'ideator', amount: 20 },
54
+ { builder_id: '2', discipline: 'artist', amount: 370 },
55
+ { builder_id: '2', discipline: 'ideator', amount: 42 },
56
+ { builder_id: '3', discipline: 'ideator', amount: 186 },
57
+ ], calls));
58
+ const choices = new Map([
59
+ ['2', { preferred_disciplines: ['artist', 'ideator'], main_discipline: 'artist' }],
60
+ ['3', { preferred_disciplines: ['ideator', 'artist'], main_discipline: null }],
61
+ ['4', { preferred_disciplines: ['ui'], main_discipline: null }],
62
+ ]);
63
+ const out = await readRolesForBuilders(['1', '2', 3, '4', 'x', '2'], { getRoleChoices: async (ids) => { calls.push(['choices', ids]); return choices; } });
64
+ assert.deepEqual([...out.keys()], ['1', '2', '3', '4'], 'every valid id once, a bad one dropped');
65
+ const r = (id) => JSON.parse(JSON.stringify(out.get(id)));
66
+ assert.deepEqual([r('1').main, r('1').main_basis, r('1').shown], ['engineer', 'earned', ['engineer', 'ideator']]);
67
+ assert.deepEqual([r('2').main, r('2').main_basis, r('2').shown], ['artist', 'chosen', ['artist', 'ideator']]);
68
+ assert.deepEqual(r('3').shown, ['ideator'], 'a preferred craft that earned nothing does not show');
69
+ assert.deepEqual([r('4').main, r('4').main_basis, r('4').shown], ['artist', 'preferred', ['artist']], 'ui is the artist\'s craft, as on the profile');
70
+ assert.equal(r('2').credits_by_craft.artist, 370);
71
+ assert.equal(r('2').credits_by_craft.total, 412);
72
+ assert.deepEqual(calls.map((c) => c[0]).sort(), ['choices', 'credits'], 'two reads for the whole page, never one per row');
73
+ seams._reset();
74
+ });
75
+
76
+ // ---- 2. the route -----------------------------------------------------------------
77
+
78
+ async function bootPublic({ rolesThrow = false } = {}) {
79
+ const db = require('../src/bongos/db.js');
80
+ const realChoices = db.getBuilderRoleChoices;
81
+ db.getBuilderRoleChoices = async () => {
82
+ if (rolesThrow) throw new Error('db down');
83
+ return new Map([['2001', { preferred_disciplines: ['artist'], main_discipline: 'artist' }]]);
84
+ };
85
+ seams._reset();
86
+ seams.registerProvider('reward', {
87
+ craftForPreference: creditsByCraftLib.craftForPreference,
88
+ getLeaderboardWithAchievements: async () => [
89
+ { id: '1', github_login: 'eng', total_credits: 900, tokens_used: '5000' },
90
+ { id: '2001', github_login: 'art', total_credits: 370, tokens_used: '0' },
91
+ ],
92
+ creditsByCraft: async (ids) => creditsByCraftLib.foldCraftRows(ids, [
93
+ { builder_id: '1', discipline: 'engineer', amount: 900 },
94
+ { builder_id: '2001', discipline: 'artist', amount: 370 },
95
+ ]),
96
+ });
97
+ const express = require('express');
98
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
99
+ const app = express();
100
+ app.use(attachFail);
101
+ app.use(require('../src/bongos/routes/public.js')());
102
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
103
+ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: res.status, body: await res.json() }; };
104
+ const close = async () => {
105
+ db.getBuilderRoleChoices = realChoices;
106
+ seams._reset();
107
+ server.closeAllConnections();
108
+ await new Promise((r) => server.close(r));
109
+ await new Promise((r) => setTimeout(r, 50));
110
+ };
111
+ return { get, close };
112
+ }
113
+
114
+ await test('GET /public/leaderboard: every row names its roles and the credits each craft earned', async () => {
115
+ const s = await bootPublic();
116
+ try {
117
+ const { status, body } = await s.get('/public/leaderboard');
118
+ assert.equal(status, 200);
119
+ const [eng, art] = body.leaderboard;
120
+ assert.deepEqual(eng.roles.shown, ['engineer']);
121
+ assert.deepEqual(art.roles.shown, ['artist']);
122
+ assert.equal(art.roles.main_basis, 'chosen');
123
+ assert.equal(art.roles.credits_by_craft.artist, 370, 'the figure an Artists toggle ranks by is in the row');
124
+ } finally { await s.close(); }
125
+ });
126
+
127
+ await test('a roles failure costs the roles, never the board', async () => {
128
+ const s = await bootPublic({ rolesThrow: true });
129
+ try {
130
+ const { status, body } = await s.get('/public/leaderboard');
131
+ assert.equal(status, 200);
132
+ assert.equal(body.leaderboard.length, 2, 'the rows still go out');
133
+ assert.ok(body.leaderboard.every((r) => r.roles === undefined), 'and simply carry no roles');
134
+ } finally { await s.close(); }
135
+ });
136
+
137
+ await test('the public list carries karma, so the roster can read it and keep its karma column', () => {
138
+ const sql = read('modules/economy/achievements.js');
139
+ const q = sql.slice(sql.indexOf('async function getLeaderboardWithAchievements'), sql.indexOf('async function getLeaderboardWithAchievements') + 1200);
140
+ assert.match(q, /\bb\.karma\b/);
141
+ });
142
+
143
+ // ---- 3. the roster ----------------------------------------------------------------
144
+
145
+ function bootRoster() {
146
+ const els = {};
147
+ const el = (id) => (els[id] = els[id] || {
148
+ id, innerHTML: '', textContent: '', className: '', hidden: true,
149
+ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; },
150
+ querySelectorAll: () => [],
151
+ });
152
+ let ledgerOpts = null;
153
+ let rendered = [];
154
+ const kit = {
155
+ esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
156
+ makePager: () => ({ page: 1 }),
157
+ makeFilterBar: () => {},
158
+ makeLedger: (opts) => { ledgerOpts = opts; return { render: (rows) => { rendered = rows; } }; },
159
+ };
160
+ let resolveLoaded;
161
+ const loaded = new Promise((r) => { resolveLoaded = r; });
162
+ const sandbox = {
163
+ window: { OTBKit: kit, OTB: { fmtInt: (n) => String(Number(n) || 0), rankLabel: (r) => r } },
164
+ document: { getElementById: el },
165
+ fetch: async (url) => { sandbox.__url = url; return { ok: true, json: async () => FIX }; },
166
+ console, Promise, setTimeout,
167
+ };
168
+ sandbox.globalThis = sandbox;
169
+ vm.createContext(sandbox);
170
+ vm.runInContext(read('modules/hall-ui/public/roster.js'), sandbox, { filename: 'roster.js' });
171
+ setTimeout(resolveLoaded, 30);
172
+ return {
173
+ loaded, sandbox, els,
174
+ get opts() { return ledgerOpts; },
175
+ get rows() { return rendered; },
176
+ click(roleKey) {
177
+ els['roster-roles'].listeners.click({ target: { closest: () => ({ dataset: { role: roleKey } }) } });
178
+ },
179
+ };
180
+ }
181
+
182
+ await test('the roster reads the public list and draws a Roles column, main role first', async () => {
183
+ const r = bootRoster();
184
+ await r.loaded;
185
+ assert.equal(r.sandbox.__url, '/api/bongos/public/leaderboard', 'the list that carries the roles');
186
+ const labels = JSON.parse(JSON.stringify(r.opts.columns.map((c) => (typeof c === 'string' ? c : c.label))));
187
+ assert.deepEqual(labels, ['Place', 'Builder', 'Roles', 'Rank', 'Credits', 'Karma', 'Tokens']);
188
+ const art = r.rows.find((b) => String(b.id) === '2001');
189
+ const html = r.opts.rowHtml(art);
190
+ assert.match(html, /<span class="lb-role lb-role--lead">Artist<\/span><span class="lb-role">Ideator<\/span>/);
191
+ assert.match(r.els['roster-roles'].innerHTML, /Everyone[\s\S]*Engineers[\s\S]*Artists[\s\S]*Ideators/);
192
+ });
193
+
194
+ await test('a builder with no AI sessions reads a dash and a sentence, never a 0 of tokens', async () => {
195
+ const r = bootRoster();
196
+ await r.loaded;
197
+ const art = r.rows.find((b) => String(b.id) === '2001');
198
+ const cells = r.opts.rowHtml(art).split('</td>');
199
+ const tokensCell = cells[6];
200
+ assert.ok(!/>0</.test(tokensCell), 'no bare 0 in the tokens cell');
201
+ assert.match(tokensCell, /—/);
202
+ assert.match(tokensCell, /No AI sessions recorded/, 'the tooltip says why, instead of a tokens-per-credit figure');
203
+ const eng = r.rows.find((b) => Number(b.tokens_used) > 0);
204
+ assert.match(r.opts.rowHtml(eng), /tokens per credit/, 'the efficiency figure still shows where there are tokens');
205
+ });
206
+
207
+ await test('the Artists toggle shows only artists, ranked by the credits that craft earned, with its own places', async () => {
208
+ const r = bootRoster();
209
+ await r.loaded;
210
+ r.click('artist');
211
+ const ids = r.rows.map((b) => String(b.id));
212
+ const artists = FIX.leaderboard.filter((b) => b.roles.shown.includes('artist'));
213
+ assert.deepEqual(ids.sort(), artists.map((b) => String(b.id)).sort(), 'exactly the builders shown as artists');
214
+ const credits = r.rows.map((b) => b.roles.credits_by_craft.artist);
215
+ assert.deepEqual(credits, [...credits].sort((a, b) => b - a), 'ranked by artist credits, not total credits');
216
+ assert.equal(String(r.rows[0].id), '2001', 'the browser-only artist leads the artists');
217
+ const labels = r.opts.columns.map((c) => (typeof c === 'string' ? c : c.label));
218
+ assert.equal(labels[4], 'Credits as artist', 'the header names the craft');
219
+ assert.match(r.opts.rowHtml(r.rows[0]), /ledger__place">1</, 'the place is the place among artists');
220
+ assert.match(r.opts.rowHtml(r.rows[0]), /<td class="ledger__num">370<\/td>/, 'and the figure is the artist credits');
221
+ r.click('');
222
+ assert.equal(r.rows.length, FIX.leaderboard.length, 'Everyone brings every builder back');
223
+ });
224
+
225
+ // ---- 4. the standing card ---------------------------------------------------------
226
+
227
+ function extract(src, start, end) {
228
+ const a = src.indexOf(start);
229
+ const b = src.indexOf(end, a);
230
+ assert.ok(a !== -1 && b !== -1, `found ${start}`);
231
+ return src.slice(a, b);
232
+ }
233
+
234
+ await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
235
+ const src = read('modules/hall-ui/public/hall-render.js');
236
+ const code = extract(src, 'const lbState = { rows: [] };', '// task 1003459');
237
+ const sandbox = { rows: FIX.leaderboard };
238
+ vm.createContext(sandbox);
239
+ vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
240
+ const art = sandbox.place('builder-7');
241
+ assert.equal(art.role.craft, 'artist');
242
+ assert.equal(sandbox.text(art), `#1 as an artist`);
243
+ const top = sandbox.place(FIX.leaderboard[0].github_login);
244
+ assert.equal(sandbox.text(top), '', 'the top engineer is #1 either way, so nothing is added');
245
+ assert.match(src, /setText\('stat-rank-sub', rollPlace \? \[`#\$\{rollPlace\.place\} on the leaderboard`, rolePlaceText\(rollPlace\)\]/);
246
+ assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
247
+ });
248
+
249
+ // ---- 5. the status page -----------------------------------------------------------
250
+
251
+ await test('the status leaderboard names each builder\'s roles, main one first, as text', () => {
252
+ const src = read('modules/status-ui/public/status.js');
253
+ const code = extract(src, 'const ROLE_WORD', 'async function loadLeaderboard');
254
+ const made = [];
255
+ const node = (tag) => {
256
+ const n = { tag, className: '', textContent: '', children: [], attrs: {}, appendChild(c) { this.children.push(c); }, setAttribute(k, v) { this.attrs[k] = v; } };
257
+ made.push(n);
258
+ return n;
259
+ };
260
+ const sandbox = { document: { createElement: node } };
261
+ vm.createContext(sandbox);
262
+ vm.runInContext(`${code}; this.renderRoles = renderRoles;`, sandbox);
263
+ const row = sandbox.renderRoles({ shown: ['artist', 'ideator', 'wizard'] });
264
+ assert.deepEqual(row.children.map((c) => [c.className, c.textContent]), [
265
+ ['leader__role leader__role--lead', 'Artist'],
266
+ ['leader__role', 'Ideator'],
267
+ ], 'a word the page does not know is dropped, not printed');
268
+ assert.equal(sandbox.renderRoles(null), null, 'a row without roles draws nothing');
269
+ assert.match(src, /const roles = renderRoles\(b\.roles\);\s*if \(roles\) name\.appendChild\(roles\);/);
270
+ });
271
+
272
+ summary();
@@ -99,6 +99,41 @@ test('an unreadable registry and a missing dispatcher both release nothing, and
99
99
  assert.equal((await requestRelease({ version: '1.20.73', registry: REG, dispatch: null })).body.error.code, 'dispatch_unavailable');
100
100
  });
101
101
 
102
+ // ── the refusal names the credential the server actually uses (task 1004518) ─────────
103
+
104
+ test('a server on the access token is told to edit the ACCESS TOKEN', async () => {
105
+ const saved = process.env.GITHUB_PUSH_TOKEN;
106
+ process.env.GITHUB_PUSH_TOKEN = 'PAT';
107
+ try {
108
+ const d = await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { appToken: async () => null, repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) });
109
+ assert.equal(d.credential, 'token');
110
+ const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => d });
111
+ assert.match(r.body.error.message, /access token \(GITHUB_PUSH_TOKEN/);
112
+ assert.doesNotMatch(r.body.error.message, /GitHub App/);
113
+ } finally {
114
+ if (saved === undefined) delete process.env.GITHUB_PUSH_TOKEN; else process.env.GITHUB_PUSH_TOKEN = saved;
115
+ }
116
+ });
117
+
118
+ test('a server on a GitHub App is told to edit the APP — the App wins over a token, as resolveToken orders them', async () => {
119
+ const saved = process.env.GITHUB_PUSH_TOKEN;
120
+ process.env.GITHUB_PUSH_TOKEN = 'PAT';
121
+ try {
122
+ let auth = null;
123
+ const d = await gp.dispatchWorkflow({ workflow: 'publish.yml' }, {
124
+ appToken: async () => 'APPTOK', repoInfo: REPO,
125
+ fetchImpl: async (_u, opts) => { auth = opts.headers.Authorization || opts.headers.authorization; return { ok: false, status: 403 }; },
126
+ });
127
+ assert.equal(d.credential, 'app');
128
+ assert.match(String(auth), /APPTOK/, 'the App token is the one sent');
129
+ const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => d });
130
+ assert.match(r.body.error.message, /the server's GitHub App needs/);
131
+ assert.doesNotMatch(r.body.error.message, /access token/);
132
+ } finally {
133
+ if (saved === undefined) delete process.env.GITHUB_PUSH_TOKEN; else process.env.GITHUB_PUSH_TOKEN = saved;
134
+ }
135
+ });
136
+
102
137
  test('GitHub refusing for want of Actions: write is named as exactly that', async () => {
103
138
  const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => ({ ok: false, code: 'REFUSED', status: 403 }) });
104
139
  assert.equal(r.status, 502);
@@ -113,7 +148,7 @@ test('dispatchWorkflow POSTs ref + inputs to the workflow\'s dispatches endpoint
113
148
  let captured = null;
114
149
  const fetchImpl = async (url, opts) => { captured = { url, opts, body: JSON.parse(opts.body) }; return { ok: false, status: 204 }; };
115
150
  const r = await gp.dispatchWorkflow({ workflow: 'publish.yml', ref: 'main', inputs: { release_version: '1.20.73' } }, { token: 'T', repoInfo: REPO, fetchImpl });
116
- assert.deepEqual(r, { ok: true });
151
+ assert.deepEqual(r, { ok: true, credential: 'token' });
117
152
  assert.equal(captured.url, 'https://api.github.com/repos/someowner/somerepo/actions/workflows/publish.yml/dispatches');
118
153
  assert.equal(captured.opts.method, 'POST');
119
154
  assert.deepEqual(captured.body, { ref: 'main', inputs: { release_version: '1.20.73' } });
@@ -135,7 +170,7 @@ test('dispatchWorkflow: no credential is UNCONFIGURED; a 403 is REFUSED with its
135
170
  } finally {
136
171
  if (saved !== undefined) process.env.GITHUB_PUSH_TOKEN = saved;
137
172
  }
138
- assert.deepEqual(await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) }), { ok: false, code: 'REFUSED', status: 403 });
173
+ assert.deepEqual(await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) }), { ok: false, code: 'REFUSED', status: 403, credential: 'token' });
139
174
  assert.equal((await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => { throw new Error('down'); } })).code, 'UNREACHABLE');
140
175
  });
141
176
 
@@ -765,7 +765,9 @@ test('the table is in the world: a masthead, then ledger rows — no panel, no s
765
765
  assert.match(COSMOS, /\.tbl \.tblRow:hover\{border-top-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
766
766
  assert.match(COSMOS, /\.rowGo\{[^}]*border:1px solid var\(--rule\);border-radius:var\(--radius-pill\);/, 'the row\'s door is the mine row\'s hairline pill');
767
767
  assert.match(HUB, /<a class="rowGo" href="\/">view<\/a>/, 'the self row\'s door');
768
- assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(safeHttpUrl\(p\.origin\)\)/, 'the generated rows\' door');
768
+ assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(siteHref\(p\)\)/, 'the generated rows\' door');
769
+ assert.match(SCRIPT, /function siteHref\(p\) \{ return p\._inst && !addressReady\(p\._inst\) \? '' : safeHttpUrl\(p\.origin\); \}/,
770
+ 'scheme-checked, and none for your own project until its address answers (task 1004505)');
769
771
  assert.match(COSMOS, /\.mastObj\{display:block;--dia:clamp\(96px,10vw,132px\);[^}]*background-image:var\(--plate-set-cut\);/, 'the object is the set\'s small bead by token, in both modes');
770
772
  assert.doesNotMatch(COSMOS, /mix-blend-mode:var\(--mast/, 'the cutout needs no blend — the view wrap is a stacking context');
771
773
  });
@@ -1426,7 +1428,7 @@ test('the save sends the choice and shows the platform’s receipt verbatim —
1426
1428
  test('what the project RUNS comes only from its own manifest, and every answer is said', () => {
1427
1429
  const runs = fnHub('loadVisRuns');
1428
1430
  const manifest = fnHub('ensureManifest');
1429
- assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
1431
+ assert.match(runs, /inst\.status !== 'active' \|\| !addressReady\(inst\)/, 'only a live project whose address answers can be asked (task 1004505)');
1430
1432
  assert.match(manifest, /'https:\/\/' \+ domain \+ '\/api\/bongos\/instance'/,
1431
1433
  'the same public GET /instance the platform’s runner reads after a restart');
1432
1434
  assert.doesNotMatch(runs, /fetch\(/, 'the card fires no GET of its own — one manifest read serves the pair (task 1003554)');
@@ -1631,7 +1633,7 @@ test('the joinability save sends the one key and shows the platform’s receipt
1631
1633
 
1632
1634
  test('what the project RUNS for joinability comes only from its own manifest, and every answer is said', () => {
1633
1635
  const runs = fnHub('loadJoinRuns');
1634
- assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
1636
+ assert.match(runs, /inst\.status !== 'active' \|\| !addressReady\(inst\)/, 'only a live project whose address answers can be asked (task 1004505)');
1635
1637
  assert.match(runs, /ensureManifest\(inst\.domain\)/, 'the same public GET /instance the runner reads, through the shared reader');
1636
1638
  assert.doesNotMatch(runs, /fetch\(/, 'the second card of the pair adds no round trip of its own (task 1003554)');
1637
1639
  assert.match(runs, /p && p\.joinability/, 'the manifest key is brand.project.joinability');
@@ -0,0 +1,153 @@
1
+ // tests/projects_hub_dns_ready.mjs
2
+ //
3
+ // No address link renders before the platform's own lookup answers (task 1004505,
4
+ // BV2.PS17). A founder's first click on a name that does not resolve yet is cached as
5
+ // "not found" by their resolver for the zone's negative TTL (30 minutes on
6
+ // cloudbongos.com), so the hub holds every door to the address until the instance read
7
+ // carries `dns_resolves` (the server's sighting — modules/provisioning/dns-resolves.js)
8
+ // or a verified up probe. The status alone, 'active' included, is never enough.
9
+ //
10
+ // EXECUTED by the vm-slice idiom: the done panel's renderProgress, the card's
11
+ // addressRow and the row's orbFor are sliced verbatim out of projects.html and run
12
+ // under a narrow stub, and the markup they write is read back. Every slice boundary is
13
+ // asserted, so a rename fails loudly instead of testing nothing. vm-born values are
14
+ // compared as primitives or through JSON.
15
+ //
16
+ // The runner half (the record is created first) is tests/provision_dns_first.mjs.
17
+ //
18
+ // Run: node tests/projects_hub_dns_ready.mjs
19
+
20
+ import { test } from 'node:test';
21
+ import { strict as assert } from 'node:assert';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
29
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
30
+
31
+ function slice(from, to) {
32
+ const a = HUB.indexOf(from);
33
+ const b = HUB.indexOf(to, a + 1);
34
+ assert.ok(a > 0, `slice start must exist: ${from}`);
35
+ assert.ok(b > a, `slice end must follow it: ${to}`);
36
+ return HUB.slice(a, b);
37
+ }
38
+
39
+ const PROGRESS = slice('var PROG_FLOWS = {', 'function startProgress(');
40
+ const OVERLAY = slice('function verifiedAt(inst)', 'function chipMarkup(');
41
+ const ADDRESS = slice('var ADDRESS_SETTING_UP', 'function publishRow(');
42
+ const ORB = slice('function orbFor(', '/* ── progress strips');
43
+ for (const [name, region] of [['renderProgress', PROGRESS], ['addressReady', OVERLAY], ['addressRow', ADDRESS], ['orbFor', ORB]]) {
44
+ assert.ok(region.includes(`function ${name}(`), `the slice must carry ${name}`);
45
+ }
46
+
47
+ function boot() {
48
+ const liveProgress = { innerHTML: '' };
49
+ const sandbox = {
50
+ $: (id) => (id === 'liveProgress' ? liveProgress : null),
51
+ esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
52
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
53
+ projectPageLink: (label) => '<a href="?view=manage&id=1">' + label + '</a>',
54
+ relTime: () => '2 minutes ago',
55
+ canRestart: () => false,
56
+ planetOf: () => ({ tpl: 'blob-tint', acc: 'var(--a1)' }),
57
+ planetAttrs: () => '',
58
+ FRESH_MS: 12 * 60 * 1000,
59
+ LIVE_MOMENT_MS: 10 * 60 * 1000,
60
+ lastProgHtml: '',
61
+ };
62
+ vm.createContext(sandbox);
63
+ vm.runInContext(PROGRESS + '\n' + OVERLAY + '\n' + ADDRESS + '\n' + ORB, sandbox);
64
+ return { sandbox, progress: () => liveProgress.innerHTML };
65
+ }
66
+
67
+ const NOW = () => new Date().toISOString();
68
+ const base = { id: 1, slug: 'lantern', status: 'active', domain: 'lantern.cloudbongos.com', last_verified_at: null, last_verified_up: null, dns_note: null };
69
+ const ADDRESS_LINK = /<a [^>]*href="https:\/\/lantern\.cloudbongos\.com/;
70
+
71
+ test('active but not yet resolved: the done panel holds the link, says so, and keeps watching', () => {
72
+ const h = boot();
73
+ const terminal = h.sandbox.renderProgress({ ...base, dns_resolves: false });
74
+ assert.equal(terminal, false, 'the panel keeps polling until the lookup answers');
75
+ assert.doesNotMatch(h.progress(), ADDRESS_LINK);
76
+ assert.doesNotMatch(h.progress(), /lantern\.cloudbongos\.com/, 'and does not even name it to be typed');
77
+ assert.match(h.progress(), /your address is being set up/);
78
+ });
79
+
80
+ test('a down probe on a never-resolved address is still "being set up", never a link', () => {
81
+ const h = boot();
82
+ h.sandbox.renderProgress({ ...base, dns_resolves: false, last_verified_up: false, last_verified_at: NOW() });
83
+ assert.doesNotMatch(h.progress(), ADDRESS_LINK);
84
+ assert.match(h.progress(), /being set up/);
85
+ });
86
+
87
+ test('once the lookup answers, the done panel links the address', () => {
88
+ const h = boot();
89
+ const terminal = h.sandbox.renderProgress({ ...base, dns_resolves: true });
90
+ assert.equal(terminal, false, 'resolving is not yet answering — the panel still waits for the probe');
91
+ assert.match(h.progress(), ADDRESS_LINK);
92
+ const live = boot();
93
+ assert.equal(live.sandbox.renderProgress({ ...base, dns_resolves: true, last_verified_up: true, last_verified_at: NOW() }), true);
94
+ assert.match(live.progress(), ADDRESS_LINK);
95
+ });
96
+
97
+ test('the card and the manage page: no link and no name before the lookup answers, in every pre-live state', () => {
98
+ const { sandbox } = boot();
99
+ for (const status of ['requested', 'provisioning', 'active']) {
100
+ const row = sandbox.addressRow({ ...base, status, dns_resolves: false });
101
+ assert.doesNotMatch(row, /<a /, `${status}: no link`);
102
+ assert.doesNotMatch(row, /lantern\.cloudbongos\.com/, `${status}: no name`);
103
+ assert.equal(row, sandbox.ADDRESS_SETTING_UP, `${status}: the being-set-up line`);
104
+ assert.doesNotMatch(sandbox.addressRow({ ...base, status, dns_resolves: false }, true), /<a /, `${status}: the manage page too`);
105
+ }
106
+ });
107
+
108
+ test('the card links the address once it resolves, and keeps a verified-up project linked', () => {
109
+ const { sandbox } = boot();
110
+ assert.match(sandbox.addressRow({ ...base, dns_resolves: true }), ADDRESS_LINK);
111
+ assert.match(sandbox.addressRow({ ...base, last_verified_up: true, last_verified_at: NOW() }), ADDRESS_LINK, 'an up probe is the same proof');
112
+ });
113
+
114
+ test('a manual-DNS note still names the address (pointing it is the owner\'s step) but never links it', () => {
115
+ const { sandbox } = boot();
116
+ const note = 'Automatic DNS isn\'t set up on this platform — point it at your server.';
117
+ for (const status of ['provisioning', 'active']) {
118
+ const row = sandbox.addressRow({ ...base, status, dns_resolves: false, dns_note: note });
119
+ assert.match(row, /<code>lantern\.cloudbongos\.com<\/code>/);
120
+ assert.doesNotMatch(row, /<a /);
121
+ }
122
+ });
123
+
124
+ test('the row\'s planet opens the site only once the address answers', () => {
125
+ const { sandbox } = boot();
126
+ assert.match(sandbox.orbFor({ ...base, dns_resolves: false }), /^<span /);
127
+ assert.match(sandbox.orbFor({ ...base, dns_resolves: true }), /^<a [^>]*href="https:\/\/lantern\.cloudbongos\.com"/);
128
+ });
129
+
130
+ test('addressReady: a domain plus a sighting or an up probe — never the status alone', () => {
131
+ const { sandbox } = boot();
132
+ const cases = [
133
+ [{ ...base }, false],
134
+ [{ ...base, dns_resolves: true }, true],
135
+ [{ ...base, last_verified_up: true }, true],
136
+ [{ ...base, last_verified_up: false }, false],
137
+ [{ ...base, domain: null, dns_resolves: true }, false],
138
+ ];
139
+ assert.equal(JSON.stringify(cases.map(([i]) => sandbox.addressReady(i))), JSON.stringify(cases.map(([, want]) => want)));
140
+ });
141
+
142
+ test('every door to the address on the page asks addressReady', () => {
143
+ const SCRIPT = HUB;
144
+ assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">View<\/a>'\)/, 'the row\'s View');
145
+ assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">Open site →<\/a>'\)/, 'the manage page\'s Open site');
146
+ assert.match(SCRIPT, /live\.instStatus !== 'active' \|\| !live\.addrReady \|\| live\.manifest !== 'done'/, 'first sign-in and the hall step stay locked');
147
+ assert.match(SCRIPT, /live\.addrReady = addressReady\(inst\); renderSteps\(\);/, 'and unlock on the tick the lookup answers');
148
+ assert.equal((SCRIPT.match(/inst\.status !== 'active' \|\| !addressReady\(inst\)\) \{ (vis|join)Runs\(''\); return; \}/g) || []).length, 2,
149
+ 'the browser\'s own manifest reads wait for the name too');
150
+ // the only raw address hrefs left are behind one of those gates
151
+ const raw = SCRIPT.match(/href="https:\/\/' \+ esc\(inst\.domain\)/g) || [];
152
+ assert.equal(raw.length, 4, 'orb, the card link, View and Open site — each gated above or in addressRow');
153
+ });