@bongos/core 1.19.511 → 1.19.513
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 +85 -50
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +158 -151
- package/docs/copy-registry.json +313 -229
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/provisioning.js +27 -1
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/modules/provisioning/tests/provisioning.mjs +17 -14
- package/modules/public-landing/public/projects.html +125 -0
- package/modules/ui-design/kit/serve.js +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/currency_label.mjs +5 -4
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/project_door_ui.mjs +267 -0
- package/tests/projects_hub.mjs +73 -1
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_settings_apply.mjs +8 -7
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/provisioning_settings.mjs +65 -6
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +10 -8
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// tests/provisioning_recommendations.mjs — the rule-based recommendation engine
|
|
2
|
+
// (BV1.R17, task 1002336; goal 1000046 criteria `proj-module-select`,
|
|
3
|
+
// `proj-governance-reco`, `proj-invite-builders`). Decision record: ADR 0239.
|
|
4
|
+
//
|
|
5
|
+
// Four things are pinned here:
|
|
6
|
+
//
|
|
7
|
+
// 1. Each rule fires on its condition and NOT otherwise — the whole claim of a
|
|
8
|
+
// rule engine, and the thing a table quietly breaks when a fourth row lands.
|
|
9
|
+
// 2. Skipping the interview yields the stated defaults, and an off-vocabulary
|
|
10
|
+
// answer is read as no answer. Criterion `proj-detail-interview` splits on
|
|
11
|
+
// exactly this ("completing it produces tailored recommendations; skipping
|
|
12
|
+
// produces sensible defaults"), and R18 (task 1002337) proves it live.
|
|
13
|
+
// 3. Table integrity — every module a rule names is a real OPT-IN module on
|
|
14
|
+
// disk, every governance rule names a preset that exists, and every team
|
|
15
|
+
// shape in the interview's own vocabulary has a rule. The ADR 0237 idiom: a
|
|
16
|
+
// pure file declares, and this test is the link back to the live catalog.
|
|
17
|
+
// 4. The read surface — GET /provisioning/recommendations is public, needs no
|
|
18
|
+
// database, and hands back the same bundle shape the starter-bundles route
|
|
19
|
+
// serves, so a recommendation and a preselect can never name different
|
|
20
|
+
// modules.
|
|
21
|
+
//
|
|
22
|
+
// DB-free: the route is a pure read, so nothing here touches Postgres or the
|
|
23
|
+
// network beyond a loopback listener on port 0.
|
|
24
|
+
//
|
|
25
|
+
// Run: node --test --test-reporter=tap tests/provisioning_recommendations.mjs
|
|
26
|
+
|
|
27
|
+
import { test, before, after } from 'node:test';
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
// ESM named imports off the CJS module on purpose: knip cannot trace a
|
|
36
|
+
// createRequire() import, so a test-only export would read as dead code and move
|
|
37
|
+
// the knip ratchet (see the repo's knip learning).
|
|
38
|
+
import {
|
|
39
|
+
TEAM_SHAPE_RULES,
|
|
40
|
+
GOVERNANCE_PRESETS,
|
|
41
|
+
GOVERNANCE_RULES,
|
|
42
|
+
GOVERNANCE_DEFAULT,
|
|
43
|
+
INVITE_RULES,
|
|
44
|
+
INVITE_DEFAULT,
|
|
45
|
+
recommendationsForDetails,
|
|
46
|
+
} from '../modules/provisioning/recommendations.js';
|
|
47
|
+
import {
|
|
48
|
+
ALWAYS_ON_CORE,
|
|
49
|
+
HUB_ONLY_MODULES,
|
|
50
|
+
bundleForProjectType,
|
|
51
|
+
} from '../modules/provisioning/starter-bundles.js';
|
|
52
|
+
|
|
53
|
+
const require = createRequire(import.meta.url);
|
|
54
|
+
|
|
55
|
+
// Stub the doorway in require.cache BEFORE the route file loads (the
|
|
56
|
+
// access_requests_invite pattern): the route destructures `pool` at require time,
|
|
57
|
+
// and a real pg pool would hold the event loop open long after the assertions
|
|
58
|
+
// pass. Nothing here needs a database — the route under test is a pure read, and
|
|
59
|
+
// this stub is also the assertion that proves it.
|
|
60
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
61
|
+
const realApi = require(apiPath);
|
|
62
|
+
require.cache[apiPath].exports = {
|
|
63
|
+
...realApi,
|
|
64
|
+
pool: { async query() { throw new Error('the recommendations route must not touch the database'); } },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
68
|
+
|
|
69
|
+
const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
|
|
70
|
+
|
|
71
|
+
// Every module key on disk, with the manifest's `default` — the live catalog as
|
|
72
|
+
// the loader discovers it (src/module-loader/loader.js scans exactly this).
|
|
73
|
+
function manifestsOnDisk() {
|
|
74
|
+
const out = new Map();
|
|
75
|
+
for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
|
|
76
|
+
if (!ent.isDirectory()) continue;
|
|
77
|
+
const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
|
|
78
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
79
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
80
|
+
out.set(manifest.key, manifest);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── the rules fire on their condition, and not otherwise ────────────────────
|
|
86
|
+
|
|
87
|
+
test('an open community adds somewhere to gather to a bundle that lacks one', () => {
|
|
88
|
+
// Arrange — the research bundle is `agents` alone (ADR 0237 §4), so the rule
|
|
89
|
+
// has something to actually change.
|
|
90
|
+
const answers = { type: 'research', team_shape: 'community' };
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const recs = recommendationsForDetails(answers);
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
|
|
97
|
+
assert.equal(recs.bundle.adjustments.length, 1);
|
|
98
|
+
assert.equal(recs.bundle.adjustments[0].module, 'discord');
|
|
99
|
+
assert.equal(recs.bundle.adjustments[0].effect, 'add');
|
|
100
|
+
assert.equal(recs.bundle.adjustments[0].rule, 'a-community-needs-somewhere-to-gather');
|
|
101
|
+
assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a small team adds the shared build environment', () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
const answers = { type: 'research', team_shape: 'small-team' };
|
|
107
|
+
|
|
108
|
+
// Act
|
|
109
|
+
const recs = recommendationsForDetails(answers);
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
|
|
113
|
+
assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a solo builder drops the shared build environment from the bundle', () => {
|
|
117
|
+
// Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
|
|
118
|
+
const answers = { type: 'business', team_shape: 'solo' };
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const recs = recommendationsForDetails(answers);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
assert.deepEqual(recs.bundle.bundle, ['agents']);
|
|
125
|
+
assert.equal(recs.bundle.adjustments.length, 1);
|
|
126
|
+
assert.equal(recs.bundle.adjustments[0].effect, 'remove');
|
|
127
|
+
assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a rule does not fire for a team shape it does not name', () => {
|
|
131
|
+
// Arrange — `solo` removes dev-box; `community` must leave it exactly alone.
|
|
132
|
+
const answers = { type: 'business', team_shape: 'community' };
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
const recs = recommendationsForDetails(answers);
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
138
|
+
assert.ok(recs.bundle.bundle.includes('dev-box'), 'the solo removal did not leak onto another shape');
|
|
139
|
+
assert.deepEqual(recs.bundle.adjustments.map((a) => a.rule), ['a-community-needs-somewhere-to-gather']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a rule whose module the bundle already carries records no adjustment', () => {
|
|
143
|
+
// The game bundle already contains `discord`, so the community rule has nothing
|
|
144
|
+
// to change. An adjustment list is a DELTA — claiming a change that did not
|
|
145
|
+
// happen is a reason the owner cannot check against the picker in front of them.
|
|
146
|
+
// Arrange
|
|
147
|
+
const answers = { type: 'game', team_shape: 'community' };
|
|
148
|
+
|
|
149
|
+
// Act
|
|
150
|
+
const recs = recommendationsForDetails(answers);
|
|
151
|
+
|
|
152
|
+
// Assert
|
|
153
|
+
assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle], 'the bundle is untouched');
|
|
154
|
+
assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a removal rule whose module is absent records no adjustment', () => {
|
|
158
|
+
// Arrange — `non-profit` is `discord` alone, so solo\'s dev-box removal is a no-op.
|
|
159
|
+
const answers = { type: 'non-profit', team_shape: 'solo' };
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
const recs = recommendationsForDetails(answers);
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
assert.deepEqual(recs.bundle.bundle, ['discord']);
|
|
166
|
+
assert.deepEqual(recs.bundle.adjustments, []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('each team shape maps to its own governance preset and invite seek', () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
const expected = {
|
|
172
|
+
solo: { preset: 'owner-only', seeking: null },
|
|
173
|
+
'small-team': { preset: 'trusted-team', seeking: { kind: 'top-builders', limit: 3 } },
|
|
174
|
+
community: { preset: 'open-contribution', seeking: { kind: 'top-builders', limit: 5 } },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Act + Assert
|
|
178
|
+
for (const [shape, want] of Object.entries(expected)) {
|
|
179
|
+
const recs = recommendationsForDetails({ type: 'not-sure', team_shape: shape });
|
|
180
|
+
assert.equal(recs.governance.preset, want.preset, `${shape} → ${want.preset}`);
|
|
181
|
+
assert.deepEqual(recs.invites.seeking, want.seeking, `${shape} → its own invite seek`);
|
|
182
|
+
assert.ok(recs.governance.reason.length > 0, 'the preset always says why it was picked');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── skipping the interview, and answers that are not answers ────────────────
|
|
187
|
+
|
|
188
|
+
test('skipping the interview returns the type bundle unchanged, the narrowest governance and no invites', () => {
|
|
189
|
+
// Arrange — a declared type but no interview answer at all: the supported path
|
|
190
|
+
// (ADR 0182 D4), not a gap.
|
|
191
|
+
const answers = { type: 'game' };
|
|
192
|
+
|
|
193
|
+
// Act
|
|
194
|
+
const recs = recommendationsForDetails(answers);
|
|
195
|
+
|
|
196
|
+
// Assert
|
|
197
|
+
assert.equal(recs.teamShape, null);
|
|
198
|
+
assert.equal(recs.tailored, false, 'nothing here came from an answer the owner gave');
|
|
199
|
+
assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle]);
|
|
200
|
+
assert.deepEqual(recs.bundle.adjustments, []);
|
|
201
|
+
assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
|
|
202
|
+
assert.equal(recs.governance.reason, GOVERNANCE_DEFAULT.reason);
|
|
203
|
+
assert.deepEqual(recs.invites.suggested, []);
|
|
204
|
+
assert.equal(recs.invites.seeking, null);
|
|
205
|
+
assert.equal(recs.invites.reason, INVITE_DEFAULT.reason);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('answering the interview marks the recommendation tailored', () => {
|
|
209
|
+
// Arrange + Act
|
|
210
|
+
const answered = recommendationsForDetails({ type: 'game', team_shape: 'solo' });
|
|
211
|
+
const skipped = recommendationsForDetails({ type: 'game' });
|
|
212
|
+
|
|
213
|
+
// Assert — the machine-readable half of criterion `proj-detail-interview`.
|
|
214
|
+
assert.equal(answered.tailored, true);
|
|
215
|
+
assert.equal(skipped.tailored, false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('an off-vocabulary team shape is read as no answer, not as a new rule', () => {
|
|
219
|
+
// A value that was never in the vocabulary — or has since left it — must land
|
|
220
|
+
// on the defaults, exactly as normalizeProjectDetail drops it on the way in.
|
|
221
|
+
// Inventing an answer nobody gave is the failure this file exists to prevent.
|
|
222
|
+
for (const shape of [null, undefined, '', 'guild', 'SOLO', 'small team', 42, {}, ['solo']]) {
|
|
223
|
+
// Act
|
|
224
|
+
const recs = recommendationsForDetails({ type: 'business', team_shape: shape });
|
|
225
|
+
|
|
226
|
+
// Assert
|
|
227
|
+
assert.equal(recs.teamShape, null, `${JSON.stringify(shape)} → no answer`);
|
|
228
|
+
assert.equal(recs.tailored, false);
|
|
229
|
+
assert.deepEqual(recs.bundle.adjustments, [], 'no rule fires on a value nobody chose');
|
|
230
|
+
assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a call with no answers at all recommends the always-on core and nothing else', () => {
|
|
235
|
+
// Act — the signed-out wizard's very first ask, before any question is answered.
|
|
236
|
+
for (const input of [undefined, null, {}, 'nonsense', []]) {
|
|
237
|
+
const recs = recommendationsForDetails(input);
|
|
238
|
+
|
|
239
|
+
// Assert
|
|
240
|
+
assert.equal(recs.type, null, 'never guesses a type');
|
|
241
|
+
assert.deepEqual(recs.bundle.bundle, [], 'nothing is preselected on a guess');
|
|
242
|
+
assert.deepEqual(recs.bundle.modules, [...ALWAYS_ON_CORE]);
|
|
243
|
+
assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
|
|
244
|
+
assert.equal(recs.invites.seeking, null);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('an unknown project type recommends the core, even when the interview was answered', () => {
|
|
249
|
+
// Arrange — the stale seeded vocabulary (ADR 0237 §2) is the realistic caller.
|
|
250
|
+
const answers = { type: 'saas-tool', team_shape: 'community' };
|
|
251
|
+
|
|
252
|
+
// Act
|
|
253
|
+
const recs = recommendationsForDetails(answers);
|
|
254
|
+
|
|
255
|
+
// Assert — the type is refused, but the ANSWER the owner actually gave still counts.
|
|
256
|
+
assert.equal(recs.type, null);
|
|
257
|
+
assert.equal(recs.teamShape, 'community');
|
|
258
|
+
assert.deepEqual(recs.bundle.bundle, ['discord'], 'the community rule applies to an empty bundle');
|
|
259
|
+
assert.equal(recs.governance.preset, 'open-contribution');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── the recommendation is a preview, and the invites are a seam ─────────────
|
|
263
|
+
|
|
264
|
+
test('governance is a named preview and is never applied', () => {
|
|
265
|
+
// The governance module is planned and unbuilt (goal 1000030), which criterion
|
|
266
|
+
// `proj-governance-reco` states outright. Flip this pin when R25 (task 1002344)
|
|
267
|
+
// fits the presets to the real module.
|
|
268
|
+
for (const shape of [...provisioning.PROJECT_DETAIL_VOCAB.team_shape, null]) {
|
|
269
|
+
// Act
|
|
270
|
+
const recs = recommendationsForDetails({ type: 'game', team_shape: shape });
|
|
271
|
+
|
|
272
|
+
// Assert
|
|
273
|
+
assert.equal(recs.governance.applied, false, 'a recommendation, never a setting');
|
|
274
|
+
assert.ok(recs.governance.label, 'carries owner-facing copy');
|
|
275
|
+
assert.ok(recs.governance.preview, 'and one sentence of what it feels like');
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('invites are a seam: the suggested list is empty and the seek says what to look for', () => {
|
|
280
|
+
// Ranking builders means querying the leaderboard, which lives in the `economy`
|
|
281
|
+
// module this pure file may not import (the one-way rule, ADR 0083). R28 (task
|
|
282
|
+
// 1002347) owns the lookup; the engine only states what it should look for.
|
|
283
|
+
// Arrange
|
|
284
|
+
const answers = { type: 'business', team_shape: 'small-team' };
|
|
285
|
+
|
|
286
|
+
// Act
|
|
287
|
+
const recs = recommendationsForDetails(answers);
|
|
288
|
+
|
|
289
|
+
// Assert
|
|
290
|
+
assert.deepEqual(recs.invites.suggested, []);
|
|
291
|
+
assert.equal(recs.invites.seeking.kind, 'top-builders');
|
|
292
|
+
assert.equal(typeof recs.invites.seeking.limit, 'number');
|
|
293
|
+
assert.ok(recs.invites.reason.length > 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('a caller may mutate the result without corrupting the declaration', () => {
|
|
297
|
+
// Arrange
|
|
298
|
+
const first = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
|
|
299
|
+
|
|
300
|
+
// Act — the picker sorts and splices what it is handed.
|
|
301
|
+
first.bundle.bundle.push('not-a-module');
|
|
302
|
+
first.bundle.modules.length = 0;
|
|
303
|
+
first.invites.seeking.limit = 999;
|
|
304
|
+
|
|
305
|
+
// Assert
|
|
306
|
+
const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
|
|
307
|
+
assert.deepEqual(second.bundle.bundle, ['dev-box', 'discord']);
|
|
308
|
+
assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'dev-box', 'discord']);
|
|
309
|
+
assert.equal(second.invites.seeking.limit, 3);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── table integrity ─────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
test('every module a rule names is a real opt-in module in the catalog', () => {
|
|
315
|
+
// Arrange
|
|
316
|
+
const manifests = manifestsOnDisk();
|
|
317
|
+
|
|
318
|
+
// Act + Assert
|
|
319
|
+
for (const rule of TEAM_SHAPE_RULES) {
|
|
320
|
+
const manifest = manifests.get(rule.module);
|
|
321
|
+
assert.ok(manifest, `rule ${rule.id} names '${rule.module}', which is not a module on disk`);
|
|
322
|
+
assert.notEqual(manifest.default, true, `rule ${rule.id} names always-on '${rule.module}' — a toggle that toggles nothing`);
|
|
323
|
+
assert.ok(!ALWAYS_ON_CORE.includes(rule.module), `rule ${rule.id} names a core module`);
|
|
324
|
+
assert.ok(!HUB_ONLY_MODULES.includes(rule.module), `rule ${rule.id} names hub-only '${rule.module}'`);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('every team shape the interview can return has a governance rule and an invite rule', () => {
|
|
329
|
+
// Arrange — the interview's own vocabulary is the source of truth. A fourth
|
|
330
|
+
// answer added there must not be silently unhandled here.
|
|
331
|
+
const vocab = provisioning.PROJECT_DETAIL_VOCAB.team_shape;
|
|
332
|
+
|
|
333
|
+
// Act + Assert
|
|
334
|
+
for (const shape of vocab) {
|
|
335
|
+
assert.ok(GOVERNANCE_RULES.some((r) => r.teamShape === shape), `no governance rule for '${shape}'`);
|
|
336
|
+
assert.ok(INVITE_RULES.some((r) => r.teamShape === shape), `no invite rule for '${shape}'`);
|
|
337
|
+
}
|
|
338
|
+
for (const rule of [...TEAM_SHAPE_RULES, ...GOVERNANCE_RULES, ...INVITE_RULES]) {
|
|
339
|
+
assert.ok(vocab.includes(rule.teamShape), `a rule names '${rule.teamShape}', which the interview cannot return`);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('every governance rule names a preset that exists, and the default does too', () => {
|
|
344
|
+
// Act + Assert
|
|
345
|
+
for (const rule of [...GOVERNANCE_RULES, GOVERNANCE_DEFAULT]) {
|
|
346
|
+
const preset = GOVERNANCE_PRESETS[rule.preset];
|
|
347
|
+
assert.ok(preset, `no preset '${rule.preset}'`);
|
|
348
|
+
assert.ok(preset.label && preset.preview, `preset '${rule.preset}' is missing owner-facing copy`);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('the rule tables carry exactly one row per shape, so no rule can shadow another', () => {
|
|
353
|
+
// Act + Assert — GOVERNANCE_RULES and INVITE_RULES are resolved by `find`, so a
|
|
354
|
+
// duplicate row would be dead code that reads as live.
|
|
355
|
+
for (const [name, table] of [['governance', GOVERNANCE_RULES], ['invite', INVITE_RULES]]) {
|
|
356
|
+
const shapes = table.map((r) => r.teamShape);
|
|
357
|
+
assert.equal(new Set(shapes).size, shapes.length, `duplicate team shape in the ${name} table`);
|
|
358
|
+
}
|
|
359
|
+
const ruleIds = TEAM_SHAPE_RULES.map((r) => r.id);
|
|
360
|
+
assert.equal(new Set(ruleIds).size, ruleIds.length, 'duplicate bundle rule id');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ── the read surface ────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
// Mount the real router. The route under test is a pure read, so it needs no
|
|
366
|
+
// session gate and no res.fail helper — which is itself the point of the
|
|
367
|
+
// assertions below.
|
|
368
|
+
const express = require('express');
|
|
369
|
+
const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
|
|
370
|
+
const app = express();
|
|
371
|
+
app.use('/api/bongos', buildProvisioningRouter());
|
|
372
|
+
|
|
373
|
+
let server;
|
|
374
|
+
let port;
|
|
375
|
+
before(async () => {
|
|
376
|
+
server = http.createServer(app);
|
|
377
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
378
|
+
port = server.address().port;
|
|
379
|
+
});
|
|
380
|
+
after(async () => { await new Promise((resolve) => server.close(resolve)); });
|
|
381
|
+
|
|
382
|
+
function get(pathname) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
|
|
385
|
+
let body = '';
|
|
386
|
+
res.on('data', (c) => { body += c; });
|
|
387
|
+
res.on('end', () => {
|
|
388
|
+
let parsed = null;
|
|
389
|
+
try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
|
|
390
|
+
resolve({ status: res.statusCode, body: parsed, raw: body });
|
|
391
|
+
});
|
|
392
|
+
}).on('error', reject);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
test('GET /provisioning/recommendations is public and answers with no session', async () => {
|
|
397
|
+
// Act
|
|
398
|
+
const res = await get('/api/bongos/provisioning/recommendations?type=game&team_shape=community');
|
|
399
|
+
|
|
400
|
+
// Assert
|
|
401
|
+
assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
|
|
402
|
+
const recs = res.body.recommendations;
|
|
403
|
+
assert.equal(recs.type, 'game');
|
|
404
|
+
assert.equal(recs.teamShape, 'community');
|
|
405
|
+
assert.equal(recs.tailored, true);
|
|
406
|
+
assert.ok(Array.isArray(recs.bundle.modules));
|
|
407
|
+
assert.ok(recs.governance.preset);
|
|
408
|
+
assert.ok(Array.isArray(recs.invites.suggested));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('the route serves exactly what the engine returns', async () => {
|
|
412
|
+
// Arrange
|
|
413
|
+
const expected = recommendationsForDetails({ type: 'business', team_shape: 'solo' });
|
|
414
|
+
|
|
415
|
+
// Act
|
|
416
|
+
const res = await get('/api/bongos/provisioning/recommendations?type=business&team_shape=solo');
|
|
417
|
+
|
|
418
|
+
// Assert
|
|
419
|
+
assert.deepEqual(res.body.recommendations, expected, 'a script and the wizard read one answer');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('the route reads the same bundle shape the starter-bundles route serves', async () => {
|
|
423
|
+
// ADR 0237 §5's promise: a recommendation and a preselect can never name
|
|
424
|
+
// different modules, because both resolve through bundleForProjectType.
|
|
425
|
+
// Act
|
|
426
|
+
const [bundles, recs] = await Promise.all([
|
|
427
|
+
get('/api/bongos/provisioning/starter-bundles'),
|
|
428
|
+
get('/api/bongos/provisioning/recommendations?type=research'),
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
// Assert — no interview answer, so the recommendation IS the preselect.
|
|
432
|
+
const preset = bundles.body.bundles.find((b) => b.type === 'research');
|
|
433
|
+
const recommended = recs.body.recommendations.bundle;
|
|
434
|
+
assert.deepEqual(recommended.core, preset.core);
|
|
435
|
+
assert.deepEqual(recommended.bundle, preset.bundle);
|
|
436
|
+
assert.deepEqual(recommended.modules, preset.modules);
|
|
437
|
+
assert.equal(recommended.label, preset.label);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('the route answers the defaults for missing or unusable query values', async () => {
|
|
441
|
+
// Act + Assert — an owner who skipped the step must get a 200 with the
|
|
442
|
+
// defaults, never a 400: skipping is a supported answer (ADR 0182 D4).
|
|
443
|
+
for (const query of ['', '?type=', '?team_shape=guild', '?type=saas-tool', '?type[]=game&team_shape[]=solo']) {
|
|
444
|
+
const res = await get(`/api/bongos/provisioning/recommendations${query}`);
|
|
445
|
+
assert.equal(res.status, 200, `${query || '(no query)'} → 200, got ${res.status} ${res.raw}`);
|
|
446
|
+
assert.ok(res.body.recommendations, 'and a recommendation, not an error envelope');
|
|
447
|
+
}
|
|
448
|
+
});
|
|
@@ -164,11 +164,14 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
164
164
|
reset();
|
|
165
165
|
const r = await get('/provisioning/instances/18/settings');
|
|
166
166
|
assert.equal(r.status, 200, r.raw);
|
|
167
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
|
|
167
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
168
168
|
// the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
|
|
169
169
|
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
170
170
|
assert.deepEqual(r.body.options, {
|
|
171
171
|
platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
|
|
172
|
+
// the join door (task 1002324, ADR 0182 D1) — the third policy axis, offered
|
|
173
|
+
// to the form the same way as its two neighbours
|
|
174
|
+
visibility: ['public', 'private', 'stealth'],
|
|
172
175
|
planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
173
176
|
// the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
|
|
174
177
|
// closed list, no default, no push — so it rides the same vocab
|
|
@@ -181,14 +184,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
|
|
|
181
184
|
const r = await get('/provisioning/instances/18/settings');
|
|
182
185
|
assert.equal(r.status, 200, r.raw);
|
|
183
186
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
|
|
184
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
|
|
187
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the planet is not a setting: the policy read is untouched');
|
|
185
188
|
});
|
|
186
189
|
|
|
187
190
|
await ta('stored keys overlay the defaults', async () => {
|
|
188
191
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
|
|
189
192
|
const r = await get('/provisioning/instances/18/settings');
|
|
190
193
|
assert.equal(r.status, 200, r.raw);
|
|
191
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only' });
|
|
194
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' });
|
|
192
195
|
});
|
|
193
196
|
|
|
194
197
|
await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
|
|
@@ -216,7 +219,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
|
|
|
216
219
|
reset();
|
|
217
220
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
218
221
|
assert.equal(r.status, 200, r.raw);
|
|
219
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
222
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
220
223
|
assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
|
|
221
224
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
222
225
|
assert.equal(events.length, 1);
|
|
@@ -229,7 +232,7 @@ await ta('owner changes both keys in one call', async () => {
|
|
|
229
232
|
reset();
|
|
230
233
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
231
234
|
assert.equal(r.status, 200, r.raw);
|
|
232
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
235
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public' });
|
|
233
236
|
});
|
|
234
237
|
|
|
235
238
|
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
@@ -257,6 +260,62 @@ await ta('an off-vocabulary value → 400 validation_failed (nothing written)',
|
|
|
257
260
|
assert.equal(updateCalls.length, 0);
|
|
258
261
|
});
|
|
259
262
|
|
|
263
|
+
// ── the join door (BV1.R05, task 1002324) ────────────────────────────────────
|
|
264
|
+
// ADR 0182 D1's STORED axis — Public / Private / Stealth — reaching the owner on
|
|
265
|
+
// the same own-scoped channel as its two neighbours. What these pin: the server,
|
|
266
|
+
// not the form, decides a value is legal; a fourth state cannot be picked because
|
|
267
|
+
// dark matter is DERIVED (D2); and the door is a POLICY, so it rides the push.
|
|
268
|
+
console.log('\nPATCH /provisioning/instances/:id/settings — the join door (task 1002324):');
|
|
269
|
+
|
|
270
|
+
await ta('the owner sets the door to stealth — stored, echoed effective, audited, and pushed like the policy it is', async () => {
|
|
271
|
+
reset();
|
|
272
|
+
const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
|
|
273
|
+
assert.equal(r.status, 200, r.raw);
|
|
274
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' });
|
|
275
|
+
assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
|
|
276
|
+
assert.equal(events[0].actor, 'api:self');
|
|
277
|
+
assert.match(events[0].detail, /visibility=stealth/);
|
|
278
|
+
// ADR 0182 D6 makes the door the CEILING joinability may only narrow, and D1/D5
|
|
279
|
+
// put that authority on the instance — so it must ride web.env + the restart,
|
|
280
|
+
// not the planet's draw-only channel.
|
|
281
|
+
assert.equal(r.body.push.queued, true, 'the door is a policy the instance obeys — it pushes');
|
|
282
|
+
assert.equal(r.body.push.restart, true, 'and a restart is what applies it');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await ta('each of the three states is accepted', async () => {
|
|
286
|
+
for (const value of ['public', 'private', 'stealth']) {
|
|
287
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { visibility: value === 'public' ? 'stealth' : 'public' } } });
|
|
288
|
+
const r = await patch('/provisioning/instances/18/settings', { visibility: value });
|
|
289
|
+
assert.equal(r.status, 200, `${value}: ${r.raw}`);
|
|
290
|
+
assert.equal(r.body.saved.visibility, value);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await ta('a fourth state is refused server-side — dark matter is DERIVED, never picked (ADR 0182 D2)', async () => {
|
|
295
|
+
for (const bogus of ['unlisted', 'dark-matter', 'gated', 'hidden', '']) {
|
|
296
|
+
reset();
|
|
297
|
+
const r = await patch('/provisioning/instances/18/settings', { visibility: bogus });
|
|
298
|
+
assert.equal(r.status, 400, `${JSON.stringify(bogus)} must be refused: ${r.raw}`);
|
|
299
|
+
assert.equal(r.body.error.code, 'validation_failed');
|
|
300
|
+
assert.equal(updateCalls.length, 0, 'nothing written on a refused value');
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await ta('the OWN-SCOPE WALL holds for the door: another builder\'s PATCH → 404, nothing written', async () => {
|
|
305
|
+
reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
|
|
306
|
+
const r = await patch('/provisioning/instances/18/settings', { visibility: 'private' });
|
|
307
|
+
assert.equal(r.status, 404, r.raw);
|
|
308
|
+
assert.equal(r.body.error.code, 'instance_not_found', 'no existence leak');
|
|
309
|
+
assert.equal(updateCalls.length, 0);
|
|
310
|
+
assert.equal(events.length, 0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await ta('an unknown project → 404 for the door too, archon included', async () => {
|
|
314
|
+
reset({ inst: null, as: { id: 999, github_login: 'archon', rank: 'archon' } });
|
|
315
|
+
const r = await patch('/provisioning/instances/999/settings', { visibility: 'private' });
|
|
316
|
+
assert.equal(r.status, 404, r.raw);
|
|
317
|
+
});
|
|
318
|
+
|
|
260
319
|
await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dropped)', async () => {
|
|
261
320
|
reset();
|
|
262
321
|
const r = await patch('/provisioning/instances/18/settings', { joinabilty: 'open' });
|
|
@@ -272,7 +331,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
|
|
|
272
331
|
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
273
332
|
assert.equal(r.status, 200, r.raw);
|
|
274
333
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
|
|
275
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
|
|
334
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the policy half is untouched');
|
|
276
335
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
|
|
277
336
|
assert.equal(r.body.push.queued, false);
|
|
278
337
|
assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
|
|
@@ -212,7 +212,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
|
|
|
212
212
|
reset();
|
|
213
213
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
214
214
|
assert.equal(r.status, 200, r.raw);
|
|
215
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
215
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
216
216
|
assert.equal(r.body.settings, undefined);
|
|
217
217
|
assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
|
|
218
218
|
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
|
|
@@ -261,7 +261,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
|
|
|
261
261
|
enqueueThrows = new Error('db down');
|
|
262
262
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
263
263
|
assert.equal(r.status, 200, r.raw);
|
|
264
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' });
|
|
264
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' });
|
|
265
265
|
assert.equal(updateCalls.length, 1, 'the write landed');
|
|
266
266
|
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
267
267
|
assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
|
|
@@ -274,7 +274,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
|
|
|
274
274
|
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
|
|
275
275
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
|
|
276
276
|
assert.equal(r.status, 200, r.raw);
|
|
277
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open' });
|
|
277
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public' });
|
|
278
278
|
assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
|
|
279
279
|
assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
|
|
280
280
|
assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
|
|
@@ -55,7 +55,9 @@ t('a row with NO stored settings emits today\'s behavior EXPLICITLY, never nothi
|
|
|
55
55
|
// an instance must never have to infer a policy from an absent variable
|
|
56
56
|
assert.equal(v.CLOUDBONGOS_PLATFORM_VISIBILITY, 'public');
|
|
57
57
|
assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
|
|
58
|
-
|
|
58
|
+
// the join door rides the same transport (task 1002324, ADR 0182 D1)
|
|
59
|
+
assert.equal(v.CLOUDBONGOS_VISIBILITY, 'public');
|
|
60
|
+
assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length, 3, 'every policy key always present');
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
t('an off-vocabulary stored value falls back to the default, never reaches the instance', () => {
|
|
@@ -94,11 +96,11 @@ t('it names the live push by the intent the runner actually dispatches (task 100
|
|
|
94
96
|
});
|
|
95
97
|
|
|
96
98
|
t('settingsEnvVars is the single source both legs derive from — same keys, same values as the lines', () => {
|
|
97
|
-
const row = { settings: { platform_visibility: 'gated', joinability: 'open' } };
|
|
99
|
+
const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth' } };
|
|
98
100
|
const vars = provisioning.settingsEnvVars(row, 'CLOUDBONGOS');
|
|
99
|
-
assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open' });
|
|
101
|
+
assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth' });
|
|
100
102
|
assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
|
|
101
|
-
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply' }, 'defaults, explicitly');
|
|
103
|
+
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public' }, 'defaults, explicitly');
|
|
102
104
|
});
|
|
103
105
|
|
|
104
106
|
console.log('\nwebEnvBody — the settings ride the real per-instance env file:');
|
|
@@ -131,12 +133,12 @@ t('clientBranding\'s project fallbacks are DEFAULT_SETTINGS, key for key — the
|
|
|
131
133
|
|
|
132
134
|
t('the env override resolves onto project.* in a real process', () => {
|
|
133
135
|
const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
|
|
134
|
-
"const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability}))"],
|
|
136
|
+
"const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability,d:r.project.visibility}))"],
|
|
135
137
|
{ cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
|
|
136
|
-
assert.deepEqual(read({}), { v: 'public', j: 'apply' }, 'no env → the pack default');
|
|
138
|
+
assert.deepEqual(read({}), { v: 'public', j: 'apply', d: 'public' }, 'no env → the pack default');
|
|
137
139
|
assert.deepEqual(
|
|
138
|
-
read({ CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'invite_only' }),
|
|
139
|
-
{ v: 'gated', j: 'invite_only' },
|
|
140
|
+
read({ CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'invite_only', CLOUDBONGOS_VISIBILITY: 'stealth' }),
|
|
141
|
+
{ v: 'gated', j: 'invite_only', d: 'stealth' },
|
|
140
142
|
'the web.env the runner writes is what the instance reads');
|
|
141
143
|
});
|
|
142
144
|
|