@bongos/core 1.21.5 → 1.21.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +152 -72
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +35 -6
- package/docs/page-readings.json +1056 -1013
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects-hall-live.states.json +216 -0
- package/modules/public-landing/public/projects.html +43 -13
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +64 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/project_invite_ui.mjs +65 -7
- package/tests/projects_hub_dns_ready.mjs +7 -1
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// tests/hub_craft_rollup.mjs — task 1004440 (WA6.RP10, goal 1000095, criterion
|
|
2
|
+
// wa6-roles-read-true-in-public): the hub profile shows each craft's own work.
|
|
3
|
+
//
|
|
4
|
+
// The cross-project rollup used to carry one ship count (with no status filter)
|
|
5
|
+
// and "Roles" from the declared preference alone, so on the hub an artist with
|
|
6
|
+
// forty approved pages, or an ideator the board ratified, read as an engineer
|
|
7
|
+
// with nothing shipped. Three halves, each driven through its real code:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE SENDER (core auth-admission.js reportActivityRollup) counts SHIPPED
|
|
10
|
+
// works only, sends each EARNED craft's counts (the ledger split decides,
|
|
11
|
+
// as on the local profile), derives `disciplines` from that evidence with
|
|
12
|
+
// the declared preference as the fallback, and — because the hub validates
|
|
13
|
+
// the body strictly — re-sends once without `crafts` when an older hub
|
|
14
|
+
// refuses the field, so the totals are never lost.
|
|
15
|
+
// 2. THE HUB ROUTE + upsertActivity accept `crafts` and bind only the
|
|
16
|
+
// sanitised shape: three crafts, named counts, integers or null. Nothing a
|
|
17
|
+
// sender writes can carry text through it.
|
|
18
|
+
// 3. getRollupForBuilder sums each craft over EVERY project row — named or
|
|
19
|
+
// not, exactly as the totals are (the aggregate is not the leak, the name
|
|
20
|
+
// is) — and the profile page draws a tile per craft from that sum.
|
|
21
|
+
//
|
|
22
|
+
// DB-free: the hub_recent_ships.mjs / activity_rollup_as_of.mjs idioms (pinned
|
|
23
|
+
// require.cache, stubbed fetch, a recording fake pool).
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/hub_craft_rollup.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import vm from 'node:vm';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
let passed = 0, failed = 0;
|
|
38
|
+
async function t(name, fn) {
|
|
39
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
40
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockPool(answer = () => ({ rows: [] })) {
|
|
44
|
+
const calls = [];
|
|
45
|
+
const query = async (text, params = []) => {
|
|
46
|
+
calls.push({ text: String(text), params });
|
|
47
|
+
return answer(String(text), params) || { rows: [] };
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
calls, query,
|
|
51
|
+
connect: async () => ({ query, release: () => {} }),
|
|
52
|
+
find: (re) => calls.find((c) => re.test(c.text)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const pin = (rel, exports) => {
|
|
56
|
+
const p = require.resolve(rel);
|
|
57
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---- 1. the sender ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
console.log('\n1. the instance sends each earned craft, and shipped means shipped:');
|
|
63
|
+
|
|
64
|
+
const seams = require('../src/module-seams.js');
|
|
65
|
+
|
|
66
|
+
// One rollup through the real sender. `byCraft` is economy's ledger split for
|
|
67
|
+
// builder 7 (null = no economy module); `ports` the craft stats; `statuses` the
|
|
68
|
+
// HTTP status each successive send answers.
|
|
69
|
+
async function sendRollup({ byCraft = null, preferred = ['engineer'], ports = {}, statuses = [200] } = {}) {
|
|
70
|
+
seams._reset();
|
|
71
|
+
if (byCraft) {
|
|
72
|
+
seams.registerProvider('reward', {
|
|
73
|
+
creditsByCraft: async (ids) => new Map(ids.map((id) => [String(id), byCraft])),
|
|
74
|
+
craftForPreference: (d) => (d === 'ui' ? 'artist' : ['engineer', 'artist', 'ideator'].includes(d) ? d : null),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
for (const [k, fn] of Object.entries(ports)) seams.registerProvider(k, fn);
|
|
78
|
+
const senderPool = mockPool((sql) => {
|
|
79
|
+
if (/FROM builders WHERE id/.test(sql)) {
|
|
80
|
+
return { rows: [{ github_id: 42, total_credits: 900, karma: 2, rank: 'thetes', preferred_disciplines: preferred }] };
|
|
81
|
+
}
|
|
82
|
+
if (/count\(\*\)/.test(sql)) return { rows: [{ n: 5 }] };
|
|
83
|
+
return { rows: [] };
|
|
84
|
+
});
|
|
85
|
+
pin('../src/bongos/pool.js', { pool: senderPool });
|
|
86
|
+
pin('../src/bongos/auth-config.js', {
|
|
87
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
|
|
88
|
+
});
|
|
89
|
+
delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
|
|
90
|
+
const { reportActivityRollup } = require('../src/bongos/auth-admission.js');
|
|
91
|
+
const sent = [];
|
|
92
|
+
const realFetch = globalThis.fetch;
|
|
93
|
+
globalThis.fetch = async (url, opts) => {
|
|
94
|
+
sent.push(JSON.parse(opts.body));
|
|
95
|
+
const status = statuses[Math.min(sent.length - 1, statuses.length - 1)];
|
|
96
|
+
return { ok: status < 300, status, json: async () => ({}) };
|
|
97
|
+
};
|
|
98
|
+
try { await reportActivityRollup({ builderId: 7 }); } finally { globalThis.fetch = realFetch; seams._reset(); }
|
|
99
|
+
return { sent, senderPool };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ARTIST_STATS = async () => ({ pages_approved: 42, pages: 30, surfaces_touched: 3, credits: 1100, this_week: 12, surfaces: [{ label: 'Landing', line: 'secret wording' }] });
|
|
103
|
+
const IDEATOR_STATS = async () => ({ filed: 12, ignited: 4, full_ideas: 2, ratified: 1, size: { total: 9 }, alive: { share: 0.5 } });
|
|
104
|
+
|
|
105
|
+
await t('the ship count asks for SHIPPED works only (the missing status filter)', async () => {
|
|
106
|
+
const { senderPool } = await sendRollup();
|
|
107
|
+
const q = senderPool.find(/count\(\*\)/);
|
|
108
|
+
assert.ok(q, 'the ship count ran');
|
|
109
|
+
assert.match(q.text, /shipped_by = \$1/);
|
|
110
|
+
assert.match(q.text, /status = 'shipped'/, 'a count without the status filter also counts work handed back after shipping');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await t('an artist who is also an ideator sends both crafts\' counts, and only counts', async () => {
|
|
114
|
+
const { sent } = await sendRollup({
|
|
115
|
+
byCraft: { engineer: 0, artist: 1100, ideator: 160, other: 0, total: 1260 },
|
|
116
|
+
preferred: ['ui'],
|
|
117
|
+
ports: { 'copy-desk.artistStats': ARTIST_STATS, 'ideas.ideatorStats': IDEATOR_STATS },
|
|
118
|
+
});
|
|
119
|
+
assert.equal(sent.length, 1);
|
|
120
|
+
const body = sent[0];
|
|
121
|
+
assert.deepEqual(body.crafts, {
|
|
122
|
+
artist: { pages_approved: 42, credits: 1100 },
|
|
123
|
+
ideator: { filed: 12, full_ideas: 2, ratified: 1, credits: 160 },
|
|
124
|
+
}, 'exactly the named counts — no surface line, no size, no wording');
|
|
125
|
+
assert.ok(!JSON.stringify(body).includes('secret wording'), 'no page wording leaves the instance');
|
|
126
|
+
assert.deepEqual(body.disciplines, ['artist', 'ideator'], 'crafts from evidence of work, in preferred order (ui → artist)');
|
|
127
|
+
assert.equal(body.tasks_shipped, 5, 'CONTROL: the totals still ride');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t('an unearned craft sends nothing, and its stats port is never asked', async () => {
|
|
131
|
+
let asked = 0;
|
|
132
|
+
const { sent } = await sendRollup({
|
|
133
|
+
byCraft: { engineer: 900, artist: 0, ideator: 0, other: 0, total: 900 },
|
|
134
|
+
preferred: ['artist', 'engineer'],
|
|
135
|
+
ports: { 'copy-desk.artistStats': async () => { asked++; return {}; } },
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(sent[0].crafts, { engineer: { shipped: 5, credits: 900 } });
|
|
138
|
+
assert.deepEqual(sent[0].disciplines, ['engineer'], 'a declared but unearned role is not shown once work exists elsewhere');
|
|
139
|
+
assert.equal(asked, 0, 'a craft that will not be sent is not read');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('nothing earned, or no economy to ask → the declared preference, and no crafts field', async () => {
|
|
143
|
+
const none = await sendRollup({ byCraft: { engineer: 0, artist: 0, ideator: 0, other: 0, total: 0 }, preferred: ['ideator'] });
|
|
144
|
+
assert.deepEqual(none.sent[0].disciplines, ['ideator'], 'the fallback is the preference');
|
|
145
|
+
assert.ok(!('crafts' in none.sent[0]), 'no crafts field, so an older hub never sees one');
|
|
146
|
+
const vanilla = await sendRollup({ byCraft: null, preferred: ['engineer', 'ui'] });
|
|
147
|
+
assert.deepEqual(vanilla.sent[0].disciplines, ['engineer', 'ui']);
|
|
148
|
+
assert.ok(!('crafts' in vanilla.sent[0]));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await t('a craft whose port cannot be asked sends null counts, never 0', async () => {
|
|
152
|
+
const { sent } = await sendRollup({ byCraft: { engineer: 0, artist: 50, ideator: 0, other: 0, total: 50 }, preferred: [] });
|
|
153
|
+
assert.deepEqual(sent[0].crafts, { artist: { pages_approved: null, credits: 50 } });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await t('an older hub refusing `crafts` (400) gets the report again without it — once', async () => {
|
|
157
|
+
const { sent } = await sendRollup({
|
|
158
|
+
byCraft: { engineer: 0, artist: 1100, ideator: 0, other: 0, total: 1100 },
|
|
159
|
+
ports: { 'copy-desk.artistStats': ARTIST_STATS },
|
|
160
|
+
statuses: [400, 200],
|
|
161
|
+
});
|
|
162
|
+
assert.equal(sent.length, 2, 'one retry');
|
|
163
|
+
assert.ok('crafts' in sent[0]);
|
|
164
|
+
assert.ok(!('crafts' in sent[1]), 'the retry drops only the new field');
|
|
165
|
+
assert.equal(sent[1].tasks_shipped, 5, 'the totals still land');
|
|
166
|
+
assert.deepEqual(sent[1].disciplines, ['artist'], 'the evidence-derived crafts ride the retry too');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('a 400 on a report WITHOUT crafts is not retried (nothing to drop)', async () => {
|
|
170
|
+
const { sent } = await sendRollup({ statuses: [400] });
|
|
171
|
+
assert.equal(sent.length, 1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---- 2. the hub route + upsert ---------------------------------------------
|
|
175
|
+
|
|
176
|
+
console.log('\n2. the hub accepts crafts and binds only the sanitised shape:');
|
|
177
|
+
|
|
178
|
+
const SECRET = 'craft-secret';
|
|
179
|
+
function hubRoute() {
|
|
180
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
181
|
+
const rollupPool = mockPool((sql) => {
|
|
182
|
+
if (/FROM platform_identity_sso_clients/.test(sql)) {
|
|
183
|
+
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://p.example' }] };
|
|
184
|
+
}
|
|
185
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
186
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
|
|
187
|
+
}
|
|
188
|
+
return { rows: [{ id: 1 }] };
|
|
189
|
+
});
|
|
190
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
191
|
+
const realApi = require('../src/module-api.js');
|
|
192
|
+
pin('../src/module-api.js', {
|
|
193
|
+
pool: rollupPool,
|
|
194
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
195
|
+
resolveSession: async () => null,
|
|
196
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
197
|
+
LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
|
|
198
|
+
withTx: realApi.withTx,
|
|
199
|
+
corsPublicGet: () => (_q, _s, next) => next(),
|
|
200
|
+
on: () => () => {},
|
|
201
|
+
hasProvider: () => false,
|
|
202
|
+
registerProvider: () => {},
|
|
203
|
+
});
|
|
204
|
+
delete require.cache[require.resolve('../modules/platform-identity/routes/sso.js')];
|
|
205
|
+
const router = require('../modules/platform-identity/routes/sso.js')();
|
|
206
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
|
|
207
|
+
return { handle: layer.route.stack[0].handle, rollupPool };
|
|
208
|
+
}
|
|
209
|
+
async function postRollup(extra) {
|
|
210
|
+
const { handle, rollupPool } = hubRoute();
|
|
211
|
+
const res = { body: null, failure: null };
|
|
212
|
+
res.json = (b) => { res.body = b; return res; };
|
|
213
|
+
res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
|
|
214
|
+
const req = {
|
|
215
|
+
get: () => undefined,
|
|
216
|
+
body: { client_id: 'c1', client_secret: SECRET, github_id: 42, credits: 10, tasks_shipped: 2, karma: 1, ...extra },
|
|
217
|
+
};
|
|
218
|
+
await handle(req, res, () => {});
|
|
219
|
+
const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
220
|
+
return { res, insert };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await t('a report carrying crafts is accepted and binds the craft counts', async () => {
|
|
224
|
+
const { res, insert } = await postRollup({ crafts: { artist: { pages_approved: 42, credits: 1100 } } });
|
|
225
|
+
assert.equal(res.failure, null, JSON.stringify(res.failure));
|
|
226
|
+
assert.match(insert.text, /reported_as_of, crafts\)/, 'the upsert writes the crafts column');
|
|
227
|
+
assert.match(insert.text, /crafts = EXCLUDED\.crafts/, 'and a later report replaces it');
|
|
228
|
+
assert.deepEqual(JSON.parse(insert.params[9]), { artist: { pages_approved: 42, credits: 1100 } });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await t('the hub keeps only the three crafts and their named integer counts', async () => {
|
|
232
|
+
const { insert } = await postRollup({
|
|
233
|
+
crafts: {
|
|
234
|
+
artist: { pages_approved: 'forty', credits: -3, title: '<script>' },
|
|
235
|
+
ideator: { filed: 1.5, full_ideas: 99_000_000, ratified: 2, credits: 7, idea_title: 'A secret plan' },
|
|
236
|
+
governor: { laws: 4 },
|
|
237
|
+
engineer: 'lots',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const bound = JSON.parse(insert.params[9]);
|
|
241
|
+
assert.deepEqual(bound, {
|
|
242
|
+
artist: { pages_approved: null, credits: null },
|
|
243
|
+
ideator: { filed: null, full_ideas: null, ratified: 2, credits: 7 },
|
|
244
|
+
});
|
|
245
|
+
assert.ok(!insert.params[9].includes('secret') && !insert.params[9].includes('script'), 'no text survives');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await t('a report with no crafts (an older instance) binds the empty object', async () => {
|
|
249
|
+
const { res, insert } = await postRollup({});
|
|
250
|
+
assert.deepEqual(res.body, { ok: true });
|
|
251
|
+
assert.equal(insert.params[9], '{}');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await t('CONTROL: the body is still strict — an unknown field is refused', async () => {
|
|
255
|
+
const { res } = await postRollup({ rolez: {} });
|
|
256
|
+
assert.ok(res.failure, 'a strict body is what makes the sender\'s retry necessary');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---- 3. the aggregate + the page ----------------------------------------------
|
|
260
|
+
|
|
261
|
+
console.log('\n3. the profile sums each craft over every project, and draws it:');
|
|
262
|
+
|
|
263
|
+
await t('getRollupForBuilder sums each craft over EVERY project row, named or not', async () => {
|
|
264
|
+
// pin the doorway back to something with a pool the domain can take.
|
|
265
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
266
|
+
const pool = mockPool((sql) => {
|
|
267
|
+
if (/WITH agg AS/.test(sql)) {
|
|
268
|
+
return { rows: [{ projects: 2, credits: 1300, works_shipped: 44, karma: 3, last_active: null, disciplines: ['artist'] }] };
|
|
269
|
+
}
|
|
270
|
+
if (/recent_ships/.test(sql)) {
|
|
271
|
+
return { rows: [
|
|
272
|
+
{ name: 'Pub', origin: 'https://pub.example', rank_held: 'metic', recent_ships: [], is_public: true,
|
|
273
|
+
crafts: { artist: { pages_approved: 40, credits: 1000 } } },
|
|
274
|
+
{ name: 'Hidden', origin: 'https://priv.example', rank_held: 'xenos', recent_ships: [], is_public: false,
|
|
275
|
+
crafts: { artist: { pages_approved: 2, credits: null }, ideator: { filed: 5, full_ideas: 1, ratified: null, credits: 60 } } },
|
|
276
|
+
{ name: 'Old', origin: 'https://old.example', rank_held: null, recent_ships: [], is_public: true, crafts: {} },
|
|
277
|
+
] };
|
|
278
|
+
}
|
|
279
|
+
return { rows: [] };
|
|
280
|
+
});
|
|
281
|
+
const r = await pi.getRollupForBuilder(42, { pool });
|
|
282
|
+
assert.match(pool.find(/recent_ships/).text, /a\.crafts/, 'the per-project read carries crafts');
|
|
283
|
+
assert.deepEqual(r.crafts, {
|
|
284
|
+
artist: { pages_approved: 42, credits: 1000 },
|
|
285
|
+
ideator: { filed: 5, full_ideas: 1, ratified: null, credits: 60 },
|
|
286
|
+
}, 'a private project is counted, as its totals are; a count nobody reported stays null');
|
|
287
|
+
assert.deepEqual(r.projects.map((p) => p.name), ['Pub', 'Old'], 'CONTROL: the private project is still not NAMED');
|
|
288
|
+
assert.ok(!JSON.stringify(r.projects).includes('pages_approved'), 'per-project numbers never cross, only the sum');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// The page: the real profile.js in the tests/profile_ui_cross_project.mjs stub.
|
|
292
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
293
|
+
const ROLES_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-roles.js'), 'utf8');
|
|
294
|
+
async function renderXp(crossProject) {
|
|
295
|
+
const els = new Map();
|
|
296
|
+
const makeEl = () => ({ hidden: false, textContent: '', _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } });
|
|
297
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
298
|
+
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
299
|
+
const profile = {
|
|
300
|
+
builder: { id: 7, github_login: 'b7', display_name: 'Builder 7', rank: 'thetes', total_credits: 1, karma: 0, created_at: '2026-01-01T00:00:00.000Z', preferred_disciplines: [] },
|
|
301
|
+
shipped_count: 0, recent_ships: [], achievements: [], cross_project: crossProject,
|
|
302
|
+
};
|
|
303
|
+
const api = { async request(_m, url) {
|
|
304
|
+
if (url === '/api/bongos/me') return { status: 200, ok: true, data: { builder: { id: 99 } } };
|
|
305
|
+
if (/\/builders\/[^/]+\/profile$/.test(url)) return { status: 200, ok: true, data: profile };
|
|
306
|
+
return { status: 404, ok: false, data: null };
|
|
307
|
+
} };
|
|
308
|
+
const sandbox = { location: { search: '?id=7' }, URLSearchParams, console, window: {
|
|
309
|
+
__BRANDING__: {}, BongosClient: { createClient: () => api },
|
|
310
|
+
OTB: { roleAxisLabel: () => 'Role', roleLabel: (d) => d.charAt(0).toUpperCase() + d.slice(1), roleOptions: () => [], roleIsUnset: (d) => !d,
|
|
311
|
+
ROLE_UNSET: 'unclassified', $, escapeHtml, fmtNum: (n) => String(Number(n) || 0), fmtDate: (d) => String(d).slice(0, 10), rankLabel: (r) => r, toast() {} },
|
|
312
|
+
} };
|
|
313
|
+
sandbox.globalThis = sandbox;
|
|
314
|
+
vm.createContext(sandbox);
|
|
315
|
+
vm.runInContext(ROLES_SRC, sandbox, { filename: 'profile-roles.js' });
|
|
316
|
+
vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
|
|
317
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|
|
318
|
+
return $('#xp-body').innerHTML;
|
|
319
|
+
}
|
|
320
|
+
const CP = (crafts) => ({ totals: { projects: 1, credits: 10, works_shipped: 0, karma: 0 }, disciplines: [], crafts, last_active: null, projects: [], more_projects: 0 });
|
|
321
|
+
const tileOf = (html, label) => {
|
|
322
|
+
const m = html.match(new RegExp(`${label}</span>\\s*<span class="stat-tile__num">([^<]*)</span>`));
|
|
323
|
+
return m ? m[1] : null;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
await t('an artist\'s and an ideator\'s tiles show their own craft\'s numbers', async () => {
|
|
327
|
+
const html = await renderXp(CP({ artist: { pages_approved: 42, credits: 1100 }, ideator: { filed: 12, full_ideas: 2, ratified: 1, credits: 160 } }));
|
|
328
|
+
assert.equal(tileOf(html, 'Pages approved'), '42');
|
|
329
|
+
assert.equal(tileOf(html, 'Thoughts filed'), '12');
|
|
330
|
+
assert.equal(tileOf(html, 'Full Ideas'), '2');
|
|
331
|
+
assert.match(html, /1 ratified by the board/);
|
|
332
|
+
assert.match(html, /stat-row--craft/);
|
|
333
|
+
assert.equal(tileOf(html, 'Works shipped'), '0', 'CONTROL: the totals band is unchanged');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await t('a count no project could report is a dash, never 0', async () => {
|
|
337
|
+
const html = await renderXp(CP({ artist: { pages_approved: null, credits: 50 } }));
|
|
338
|
+
assert.equal(tileOf(html, 'Pages approved'), '—');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await t('no crafts (an engineer, or an older hub) → no craft band at all', async () => {
|
|
342
|
+
for (const crafts of [undefined, {}, { engineer: { shipped: 3, credits: 9 } }]) {
|
|
343
|
+
const html = await renderXp(CP(crafts));
|
|
344
|
+
assert.ok(!html.includes('stat-row--craft'), `no craft band for ${JSON.stringify(crafts)}`);
|
|
345
|
+
assert.equal((html.match(/stat-tile__num/g) || []).length, 4);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
350
|
+
if (failed) process.exit(1);
|
|
@@ -102,6 +102,8 @@ try {
|
|
|
102
102
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
103
103
|
// upsertActivity also writes reported_as_of (026, task 1004268): 42703 without it.
|
|
104
104
|
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
105
|
+
// ...and crafts (029, task 1004440), which getRollupForBuilder also reads.
|
|
106
|
+
await q(migrationSql('platform_identity_029_activity_crafts.sql'));
|
|
105
107
|
await cleanup();
|
|
106
108
|
|
|
107
109
|
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|
package/tests/profile_route.mjs
CHANGED
|
@@ -108,13 +108,22 @@ function stubPublicProfileExtras() {
|
|
|
108
108
|
return {
|
|
109
109
|
totals: { projects: 2, credits: 150, works_shipped: 7, karma: 4 },
|
|
110
110
|
disciplines: ['engineer', 'ui'],
|
|
111
|
+
crafts: { artist: { pages_approved: 3, credits: 40 } },
|
|
111
112
|
last_active: '2026-08-01T00:00:00.000Z',
|
|
112
113
|
projects: named,
|
|
113
114
|
more_projects: CROSS_PROJECT_ROWS.length - named.length,
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
// The key contract for the cross_project object + its project rows.
|
|
117
|
-
|
|
118
|
+
// crafts (task 1004440) is each craft's summed counts — always present, {} when
|
|
119
|
+
// no project reported any — so it belongs in the exact list too.
|
|
120
|
+
const CP_PUBLIC_KEYS = ['totals', 'disciplines', 'crafts', 'last_active', 'projects', 'more_projects'];
|
|
121
|
+
// The only shape crafts may take: the three crafts, each a block of counts.
|
|
122
|
+
const CP_ROLE_FIELDS = {
|
|
123
|
+
engineer: ['credits', 'shipped'],
|
|
124
|
+
artist: ['credits', 'pages_approved'],
|
|
125
|
+
ideator: ['credits', 'filed', 'full_ideas', 'ratified'],
|
|
126
|
+
};
|
|
118
127
|
// recent_ships rides the project row (task 1002803) and is ALWAYS present —
|
|
119
128
|
// getRollupForBuilder defaults it to [] — so it belongs in the exact key list.
|
|
120
129
|
const CP_PROJECT_ROW_KEYS = ['name', 'origin', 'rank_held', 'recent_ships'];
|
|
@@ -129,6 +138,11 @@ const CP_SENTINELS = [SECRET_CLIENT_ID, SECRET_PROJECT_NAME, SECRET_EMAIL, SECRE
|
|
|
129
138
|
function assertCrossProjectContract(cp, text) {
|
|
130
139
|
assert.deepEqual(Object.keys(cp).sort(), [...CP_PUBLIC_KEYS].sort(), 'cross_project keys are exactly the contract');
|
|
131
140
|
assert.deepEqual(Object.keys(cp.totals).sort(), ['credits', 'karma', 'projects', 'works_shipped']);
|
|
141
|
+
for (const [craft, block] of Object.entries(cp.crafts)) {
|
|
142
|
+
assert.ok(CP_ROLE_FIELDS[craft], `crafts carries only the three crafts — found "${craft}"`);
|
|
143
|
+
assert.deepEqual(Object.keys(block).sort(), CP_ROLE_FIELDS[craft], `${craft} carries only its named counts`);
|
|
144
|
+
for (const v of Object.values(block)) assert.ok(v === null || Number.isInteger(v), 'a craft count is a number or null, never text');
|
|
145
|
+
}
|
|
132
146
|
for (const row of cp.projects) {
|
|
133
147
|
assert.deepEqual(Object.keys(row).sort(), [...CP_PROJECT_ROW_KEYS].sort(), 'project rows carry only the whitelisted keys');
|
|
134
148
|
}
|
|
@@ -619,7 +633,7 @@ await t('R06.5 no PARTIAL numbers survive the hide (a count beside a hidden tota
|
|
|
619
633
|
await pi.setAccountHideStats({ githubId: HUB_GITHUB_ID, githubLogin: 'jaxri', hide: true }, { pool: engine });
|
|
620
634
|
const r = await get(port, '/api/bongos/builders/95/profile');
|
|
621
635
|
assert.equal(r.json.cross_project, null);
|
|
622
|
-
for (const key of ['totals', 'works_shipped', 'more_projects', 'last_active', 'rank_held']) {
|
|
636
|
+
for (const key of ['totals', 'works_shipped', 'more_projects', 'last_active', 'rank_held', 'pages_approved']) {
|
|
623
637
|
assert.ok(!r.text.includes(key), `nothing derived from the rollup may survive — found "${key}"`);
|
|
624
638
|
}
|
|
625
639
|
const digits = [sumOf('credits'), sumOf('tasks_shipped'), sumOf('karma'),
|
|
@@ -474,11 +474,69 @@ test('the two mounts keep their own lines — an invite on one is not drawn on t
|
|
|
474
474
|
// in" and "is this their project" — are answered by the caller, which hands over
|
|
475
475
|
// a slug or nothing. So the pins are on the calls.
|
|
476
476
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
477
|
+
// The wizard's invite step waits for the hall (task 1004503): a second account cannot join a
|
|
478
|
+
// project whose hall is not up, so the box opens only on the done panel's verified Live. The
|
|
479
|
+
// gate is RUN here: demoTeam, paintInviteGate and paintDemoTeam sliced out of the script.
|
|
480
|
+
const GATE_START = ' function demoTeam() {';
|
|
481
|
+
function gateSlice() {
|
|
482
|
+
const a = SCRIPT.indexOf(GATE_START);
|
|
483
|
+
const b = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function paintDemoTeam() {'));
|
|
484
|
+
return a > -1 && b > a ? SCRIPT.slice(a, b + 6) : '';
|
|
485
|
+
}
|
|
486
|
+
function bootGate({ me = { github_login: 'kestrel' }, created = { id: 7, slug: 'moonbase' }, demo = null } = {}) {
|
|
487
|
+
const els = {};
|
|
488
|
+
const $ = (id) => (els[id] = els[id] || { id, hidden: false, textContent: '', innerHTML: '', value: '' });
|
|
489
|
+
const calls = { slug: [], sugg: [] };
|
|
490
|
+
const sandbox = {
|
|
491
|
+
$, me, state: { demo },
|
|
492
|
+
createdRecord: () => created,
|
|
493
|
+
setInviteSlug: (id, slug) => calls.slug.push([id, slug]),
|
|
494
|
+
loadInviteSuggestions: (id, iid) => calls.sugg.push([id, iid]),
|
|
495
|
+
projectPageLink: (label) => '<a href="?view=manage">' + label + '</a>',
|
|
496
|
+
};
|
|
497
|
+
vm.createContext(sandbox);
|
|
498
|
+
vm.runInContext(gateSlice(), sandbox);
|
|
499
|
+
return { run: (c) => vm.runInContext(c, sandbox), $, calls };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test('the wizard opens the invite step only once the hall is up, and only for a signed-in owner of a project that exists (tasks 1002346, 1004503)', () => {
|
|
503
|
+
assert.ok(gateSlice().includes('function paintInviteGate(live)'), 'the gate slice is found');
|
|
504
|
+
assert.match(SCRIPT, /function renderDone\(\) \{[\s\S]*?paintInviteGate\(false\);/, 'the done panel paints the gate shut');
|
|
505
|
+
assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{[\s\S]{0,400}paintInviteGate\(true\);/, 'and opens it only on the verified Live');
|
|
506
|
+
assert.equal((SCRIPT.match(/setInviteSlug\('wizInvite'/g) || []).length, 2, 'the gate is the only caller: shut, or open on Live');
|
|
507
|
+
|
|
508
|
+
const before = bootGate();
|
|
509
|
+
before.run('paintInviteGate(false)');
|
|
510
|
+
assert.deepEqual(before.calls.slug, [['wizInvite', '']], 'before the hall is up: no invite control');
|
|
511
|
+
assert.deepEqual(before.calls.sugg, [], 'and no suggestions asked for');
|
|
512
|
+
assert.equal(before.$('wizInviteLater').textContent, 'You can invite builders once its hall is up.', 'one plain line says when');
|
|
513
|
+
|
|
514
|
+
const live = bootGate();
|
|
515
|
+
live.run('paintInviteGate(true)');
|
|
516
|
+
live.run('paintInviteGate(true)');
|
|
517
|
+
assert.deepEqual(live.calls.slug, [['wizInvite', 'moonbase']], 'on Live: the box opens on the created project, once');
|
|
518
|
+
assert.deepEqual(live.calls.sugg, [['wizInvite', 7]], 'and the suggestions are asked for once, not on every tick');
|
|
519
|
+
assert.match(live.$('wizInviteLater').innerHTML, /any time from <a href="\?view=manage">the project’s own page<\/a>/);
|
|
520
|
+
|
|
521
|
+
for (const [label, opts] of [['signed out', { me: null }], ['no created id', { created: { slug: 'moonbase' } }], ['no slug', { created: { id: 7 } }]]) {
|
|
522
|
+
const g = bootGate(opts);
|
|
523
|
+
g.run('paintInviteGate(true)');
|
|
524
|
+
assert.deepEqual(g.calls.slug, [['wizInvite', '']], label + ': the created record IS the proof — no step');
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a demo\'s teammates are kept until the hall is up, then offered in the box (task 1004503)', () => {
|
|
529
|
+
const before = bootGate({ demo: { invites: ['ava', 'jo'] } });
|
|
530
|
+
before.run('paintInviteGate(false)');
|
|
531
|
+
assert.equal(before.$('wizInviteLater').textContent, 'You can invite @ava, @jo once its hall is up.', 'named while they wait');
|
|
532
|
+
assert.equal(before.$('wizDemoTeam').hidden, true, 'no invite prompt before the hall is up');
|
|
533
|
+
// a reload before the hall is up: the draft is gone, the created record still carries the names
|
|
534
|
+
const reloaded = bootGate({ created: { id: 7, slug: 'moonbase', demo_invites: ['ava'] } });
|
|
535
|
+
reloaded.run('paintInviteGate(true)');
|
|
536
|
+
assert.equal(reloaded.$('wizDemoTeam').hidden, false, 'on Live the teammates line shows');
|
|
537
|
+
assert.equal(reloaded.$('wizDemoTeam').textContent, 'Your demo teammates: @ava. Invite each one here.');
|
|
538
|
+
assert.equal(reloaded.$('wizInviteWho').value, 'ava', 'and the first is ready in the box');
|
|
539
|
+
assert.match(SCRIPT, /demo_invites: state\.demo && state\.demo\.invites && state\.demo\.invites\.length \? state\.demo\.invites\.slice\(\) : undefined/, 'the created record carries them');
|
|
482
540
|
});
|
|
483
541
|
|
|
484
542
|
test('the project page opens the step from its own manage read, which the API already own-scopes', () => {
|
|
@@ -643,8 +701,8 @@ test('a suggestion never renders a github_id, and its name goes through the esca
|
|
|
643
701
|
test('the suggestion surface is mounted on BOTH the wizard and the project page', () => {
|
|
644
702
|
assert.match(HUB, /<ul class="suggList" id="wizInviteSugg"><\/ul>/, 'the wizard mount');
|
|
645
703
|
assert.match(HUB, /<ul class="suggList" id="mInviteSugg"><\/ul>/, 'and the project page mount');
|
|
646
|
-
assert.match(SCRIPT, /
|
|
647
|
-
'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1)');
|
|
704
|
+
assert.match(SCRIPT, /setInviteSlug\('wizInvite', created\.slug\);\n\s*paintDemoTeam\(\);\n\s*loadInviteSuggestions\('wizInvite', created\.id\);/,
|
|
705
|
+
'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1), and only once the gate opens (task 1004503)');
|
|
648
706
|
assert.match(SCRIPT, /loadInviteSuggestions\('mInvite', inst\.slug \? inst\.id : ''\)/,
|
|
649
707
|
'and the manage page asks with the id whose read already proved ownership');
|
|
650
708
|
assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
|
|
@@ -48,9 +48,12 @@ function boot() {
|
|
|
48
48
|
const liveProgress = { innerHTML: '' };
|
|
49
49
|
// the done panel's heading (task 1004504): "Setting up <name>" until the project answers
|
|
50
50
|
const doneH = { textContent: 'Setting up Lantern' };
|
|
51
|
+
// the invite step's gate (task 1004503): renderProgress opens it only on a verified answer
|
|
52
|
+
const gates = [];
|
|
51
53
|
const sandbox = {
|
|
52
54
|
$: (id) => (id === 'liveProgress' ? liveProgress : id === 'done-h' ? doneH : null),
|
|
53
55
|
state: { name: 'Lantern', slug: 'lantern' },
|
|
56
|
+
paintInviteGate: (live) => gates.push(live),
|
|
54
57
|
esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
55
58
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
56
59
|
projectPageLink: (label) => '<a href="?view=manage&id=1">' + label + '</a>',
|
|
@@ -64,7 +67,7 @@ function boot() {
|
|
|
64
67
|
};
|
|
65
68
|
vm.createContext(sandbox);
|
|
66
69
|
vm.runInContext(PROGRESS + '\n' + OVERLAY + '\n' + ADDRESS + '\n' + ORB, sandbox);
|
|
67
|
-
return { sandbox, progress: () => liveProgress.innerHTML, heading: () => doneH.textContent };
|
|
70
|
+
return { sandbox, progress: () => liveProgress.innerHTML, heading: () => doneH.textContent, gates };
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
const NOW = () => new Date().toISOString();
|
|
@@ -93,13 +96,16 @@ test('once the lookup answers, the done panel links the address', () => {
|
|
|
93
96
|
assert.equal(terminal, false, 'resolving is not yet answering — the panel still waits for the probe');
|
|
94
97
|
assert.match(h.progress(), ADDRESS_LINK);
|
|
95
98
|
assert.equal(h.heading(), 'Setting up Lantern', 'and the heading claims nothing yet (task 1004504)');
|
|
99
|
+
assert.deepEqual(h.gates, [], 'and the invite box stays shut: the hall has not answered (task 1004503)');
|
|
96
100
|
const live = boot();
|
|
97
101
|
assert.equal(live.sandbox.renderProgress({ ...base, dns_resolves: true, last_verified_up: true, last_verified_at: NOW() }), true);
|
|
98
102
|
assert.match(live.progress(), ADDRESS_LINK);
|
|
99
103
|
assert.equal(live.heading(), 'Lantern is alive.', 'the alive moment, only on a verified answer');
|
|
104
|
+
assert.deepEqual(live.gates, [true], 'and the invite box opens with it (task 1004503)');
|
|
100
105
|
const down = boot();
|
|
101
106
|
down.sandbox.renderProgress({ ...base, dns_resolves: true, last_verified_up: false, last_verified_at: NOW() });
|
|
102
107
|
assert.equal(down.heading(), 'Setting up Lantern', 'a down probe is never "alive"');
|
|
108
|
+
assert.deepEqual(down.gates, [], 'nor opens the invite box');
|
|
103
109
|
});
|
|
104
110
|
|
|
105
111
|
test('the card and the manage page: no link and no name before the lookup answers, in every pre-live state', () => {
|
package/tests/provision.mjs
CHANGED
|
@@ -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.
|