@bongos/core 1.19.532 → 1.19.534
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 +113 -48
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +116 -0
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +5 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +129 -124
- package/docs/copy-registry.json +205 -159
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +221 -2
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +28 -1
- package/tests/projects_hub_module_picker.mjs +207 -0
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +7 -4
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// tests/project_modules_ui.mjs — adding and removing a project's extras AFTER
|
|
2
|
+
// creation, EXECUTED (BV1.R23, task 1002342, ADR 0245).
|
|
3
|
+
//
|
|
4
|
+
// tests/provisioning_module_selection.mjs pins the ROUTE half of "add modules
|
|
5
|
+
// later"; this file pins the surface an owner actually uses. It lifts the manage
|
|
6
|
+
// page's extras region out of modules/public-landing/public/projects.html —
|
|
7
|
+
// ensureModsCatalog, modsSelected, renderMods, doModToggle and the delegated
|
|
8
|
+
// click — runs it in a vm under a minimal DOM stub, PARSES the markup it writes
|
|
9
|
+
// into innerHTML, and drives a real click on a toggle.
|
|
10
|
+
//
|
|
11
|
+
// The precedent is tests/project_door_ui.mjs beside it (there is no jsdom in this
|
|
12
|
+
// repo), including its rule that the slice boundaries are themselves asserted, so
|
|
13
|
+
// a rename can never leave this file quietly executing an empty string.
|
|
14
|
+
//
|
|
15
|
+
// What it proves, and why each matters:
|
|
16
|
+
// • the optional CATALOG renders — one row per module the server offers, with
|
|
17
|
+
// the owner-facing line, and nothing the server did not offer
|
|
18
|
+
// • the project's CURRENT set is what is switched on, resolved by the SERVER
|
|
19
|
+
// (a skipped picker reads as its bundle, and this page never re-derives that
|
|
20
|
+
// rule — the one way two surfaces name different modules for one project)
|
|
21
|
+
// • a toggle issues the write, as the WHOLE list in catalog order, on the
|
|
22
|
+
// own-scoped detail channel
|
|
23
|
+
// • the band repaints from the platform's answer, never from what was sent —
|
|
24
|
+
// and a refusal puts it back to what the row still says
|
|
25
|
+
// • the always-on core is stated and is not on the band at all
|
|
26
|
+
// • the done panel carries one pointer at this surface for the project just
|
|
27
|
+
// created, so a skipped picker is never a dead end
|
|
28
|
+
//
|
|
29
|
+
// Run: node --test --test-reporter=tap tests/project_modules_ui.mjs
|
|
30
|
+
|
|
31
|
+
import test from 'node:test';
|
|
32
|
+
import assert from 'node:assert/strict';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import vm from 'node:vm';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8')
|
|
40
|
+
.replace(/\r\n/g, '\n');
|
|
41
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
42
|
+
|
|
43
|
+
const START = SCRIPT.indexOf('var modsCatalog = null;');
|
|
44
|
+
const END = SCRIPT.indexOf('/* ── on the map (task 1002324)');
|
|
45
|
+
assert.ok(START > 0, 'the extras region must start at modsCatalog');
|
|
46
|
+
assert.ok(END > START, 'the extras region must end before the join-door section');
|
|
47
|
+
const REGION = SCRIPT.slice(START, END);
|
|
48
|
+
for (const fn of ['function ensureModsCatalog(', 'function renderMods(', 'function doModToggle(']) {
|
|
49
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── the fixture catalog ──────────────────────────────────────────────────────
|
|
53
|
+
// A FIXTURE, not a live require: this file asserts the band reads the shape, and
|
|
54
|
+
// tests/provisioning_starter_bundles.mjs asserts the route emits it. Two files,
|
|
55
|
+
// one shape, neither taking the other's word for it (the module-picker rule).
|
|
56
|
+
const CATALOG = {
|
|
57
|
+
core: ['memory', 'lifecycle', 'hall-ui'],
|
|
58
|
+
optional: [
|
|
59
|
+
{ key: 'dev-box', label: 'Dev box', summary: 'A shared machine your builders work on.' },
|
|
60
|
+
{ key: 'discord', label: 'Discord', summary: 'Mirrors what ships into a Discord channel.' },
|
|
61
|
+
{ key: 'agents', label: 'Agents', summary: 'Lets AI builders claim and ship work on their own.' },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
66
|
+
// Narrow on purpose (the task_detail_ui.mjs rule): it supports exactly what the
|
|
67
|
+
// region uses, so it stays honest about what it can prove.
|
|
68
|
+
|
|
69
|
+
const ROW = /<button type="button" class="modOpt" data-mod="([^"]*)" aria-pressed="(true|false)"( disabled)?><b>([^<]*)<\/b><small>([^<]*)<\/small><\/button>/g;
|
|
70
|
+
|
|
71
|
+
function parseRows(html) {
|
|
72
|
+
return [...String(html).matchAll(ROW)].map((m) => ({
|
|
73
|
+
key: m[1], on: m[2] === 'true', disabled: !!m[3], label: m[4], summary: m[5],
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Boot the region against a scripted catalog + detail channel and hand back the
|
|
78
|
+
// handles a test drives it with.
|
|
79
|
+
function boot({ selected = ['dev-box'], source = 'chosen', catalog = CATALOG, catalogOk = true, patchAnswer } = {}) {
|
|
80
|
+
const requests = [];
|
|
81
|
+
const els = new Map();
|
|
82
|
+
const listeners = new Map();
|
|
83
|
+
|
|
84
|
+
const makeEl = (id) => ({
|
|
85
|
+
_id: id, _html: '', textContent: '', className: '', disabled: false,
|
|
86
|
+
set innerHTML(v) { this._html = String(v); },
|
|
87
|
+
get innerHTML() { return this._html; },
|
|
88
|
+
addEventListener(type, fn) {
|
|
89
|
+
if (type !== 'click') return;
|
|
90
|
+
if (!listeners.has(id)) listeners.set(id, []);
|
|
91
|
+
listeners.get(id).push(fn);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const $ = (id) => {
|
|
95
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
96
|
+
return els.get(id);
|
|
97
|
+
};
|
|
98
|
+
const slotHtml = () => ($('mMods')._html || '');
|
|
99
|
+
|
|
100
|
+
// `_json` is what jsonOrEmpty hands back (the PATCH path); `json()` is what
|
|
101
|
+
// ensureModsCatalog calls directly (the GET path) — the region uses both.
|
|
102
|
+
const answer = (body, { ok = true, status = 200 } = {}) =>
|
|
103
|
+
Promise.resolve({ ok, status, _json: body, json: () => Promise.resolve(body) });
|
|
104
|
+
const fetch = (url, init = {}) => {
|
|
105
|
+
const method = init.method || 'GET';
|
|
106
|
+
requests.push({ url, method, body: init.body, credentials: init.credentials, headers: init.headers });
|
|
107
|
+
if (method === 'GET') {
|
|
108
|
+
return catalogOk ? answer(catalog) : answer({}, { ok: false, status: 500 });
|
|
109
|
+
}
|
|
110
|
+
if (patchAnswer) return patchAnswer;
|
|
111
|
+
// the platform's own answer: what the row now resolves to (effectiveModules)
|
|
112
|
+
const sent = JSON.parse(init.body).modules;
|
|
113
|
+
return answer({ ok: true, modules: { core: catalog.core, selected: sent, modules: catalog.core.concat(sent), source: 'chosen', adjustments: [] } });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const sandbox = {
|
|
117
|
+
$, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
|
|
118
|
+
encodeURIComponent, setTimeout, clearTimeout,
|
|
119
|
+
API: '/api/bongos',
|
|
120
|
+
manageId: '18',
|
|
121
|
+
manageInst: { id: 18, status: 'active', modules: { core: catalog.core, selected: selected.slice(), modules: catalog.core.concat(selected), source, adjustments: [] } },
|
|
122
|
+
esc: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
123
|
+
jsonOrEmpty: (res) => Promise.resolve(res._json),
|
|
124
|
+
apiError: (data, status) => ({ code: (data && data.error && data.error.code) || 'error', message: (data && data.error && data.error.message) || `HTTP ${status}` }),
|
|
125
|
+
signInAgain: () => '<a href="/signin">Sign in again</a>',
|
|
126
|
+
};
|
|
127
|
+
sandbox.globalThis = sandbox;
|
|
128
|
+
vm.createContext(sandbox);
|
|
129
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-extras' });
|
|
130
|
+
|
|
131
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
132
|
+
return {
|
|
133
|
+
requests, sandbox, settle,
|
|
134
|
+
rows: () => parseRows(slotHtml()),
|
|
135
|
+
slot: () => slotHtml(),
|
|
136
|
+
msg: () => $('mModMsg'),
|
|
137
|
+
async render() { sandbox.renderMods(sandbox.manageInst); await settle(); },
|
|
138
|
+
async click(key) {
|
|
139
|
+
const handlers = listeners.get('mMods') || [];
|
|
140
|
+
assert.equal(handlers.length, 1, 'the section wires exactly one delegated click handler');
|
|
141
|
+
const row = parseRows(slotHtml()).find((r) => r.key === key);
|
|
142
|
+
assert.ok(row, `the band carries a row for ${key}`);
|
|
143
|
+
handlers[0]({
|
|
144
|
+
target: {
|
|
145
|
+
closest: (sel) => (sel === '.modOpt[data-mod]'
|
|
146
|
+
? { disabled: row.disabled, getAttribute: (a) => (a === 'data-mod' ? key : null) }
|
|
147
|
+
: null),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
await settle();
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── the tests ────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
test('the band renders the optional catalog the server offered — a row and a sentence each', async () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
const ui = boot({ selected: ['dev-box'] });
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
await ui.render();
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
assert.deepEqual(ui.rows().map((r) => r.key), ['dev-box', 'discord', 'agents']);
|
|
166
|
+
assert.deepEqual(ui.rows().map((r) => r.label), ['Dev box', 'Discord', 'Agents']);
|
|
167
|
+
for (const r of ui.rows()) assert.ok(r.summary.length > 20, `${r.label} says what it is for`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('what the project runs today is what is switched on — resolved by the server, not re-derived here', async () => {
|
|
171
|
+
// A project that SKIPPED the picker reads as its own starter bundle, and the
|
|
172
|
+
// band must show that set rather than an empty one: the whole point of
|
|
173
|
+
// effectiveModules resolving `source: 'bundle'` on the way out.
|
|
174
|
+
const ui = boot({ selected: ['dev-box', 'discord'], source: 'bundle' });
|
|
175
|
+
|
|
176
|
+
await ui.render();
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['dev-box', 'discord']);
|
|
179
|
+
assert.deepEqual(ui.rows().filter((r) => !r.on).map((r) => r.key), ['agents']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the always-on core is COUNTED and stated, and is not a row anyone can switch off', async () => {
|
|
183
|
+
const ui = boot({ selected: [] });
|
|
184
|
+
|
|
185
|
+
await ui.render();
|
|
186
|
+
|
|
187
|
+
assert.match(ui.slot(), /the same 3 core parts/, 'the clause names how many, from the server\'s own list');
|
|
188
|
+
assert.deepEqual(ui.rows().map((r) => r.key), ['dev-box', 'discord', 'agents'], 'and no core key is on the band');
|
|
189
|
+
for (const key of CATALOG.core) {
|
|
190
|
+
assert.equal(ui.rows().some((r) => r.key === key), false, `${key} is not a choice`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a toggle issues the write — the WHOLE list, in catalog order, on the own-scoped detail channel', async () => {
|
|
195
|
+
// Arrange
|
|
196
|
+
const ui = boot({ selected: ['discord'] });
|
|
197
|
+
await ui.render();
|
|
198
|
+
|
|
199
|
+
// Act — switch the dev box ON
|
|
200
|
+
await ui.click('dev-box');
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
203
|
+
const patch = ui.requests.find((r) => r.method === 'PATCH');
|
|
204
|
+
assert.ok(patch, 'a PATCH was sent');
|
|
205
|
+
assert.equal(patch.url, '/api/bongos/provisioning/instances/18/detail');
|
|
206
|
+
assert.deepEqual(JSON.parse(patch.body), { modules: ['dev-box', 'discord'] },
|
|
207
|
+
'catalog order, exactly as the column stores it — never append order');
|
|
208
|
+
assert.equal(patch.credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a toggle can REMOVE one too — the same list, one key shorter', async () => {
|
|
212
|
+
const ui = boot({ selected: ['dev-box', 'agents'] });
|
|
213
|
+
await ui.render();
|
|
214
|
+
|
|
215
|
+
await ui.click('agents');
|
|
216
|
+
|
|
217
|
+
const patch = ui.requests.find((r) => r.method === 'PATCH');
|
|
218
|
+
assert.deepEqual(JSON.parse(patch.body), { modules: ['dev-box'] });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('switching the last one off sends an empty list — a real answer, not a skipped write', async () => {
|
|
222
|
+
const ui = boot({ selected: ['agents'] });
|
|
223
|
+
await ui.render();
|
|
224
|
+
|
|
225
|
+
await ui.click('agents');
|
|
226
|
+
|
|
227
|
+
const patch = ui.requests.find((r) => r.method === 'PATCH');
|
|
228
|
+
assert.ok(patch, 'the write still happens');
|
|
229
|
+
assert.deepEqual(JSON.parse(patch.body), { modules: [] });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('the band repaints from the PLATFORM’S answer, and says the save changed nothing running', async () => {
|
|
233
|
+
const ui = boot({ selected: [] });
|
|
234
|
+
await ui.render();
|
|
235
|
+
|
|
236
|
+
await ui.click('discord');
|
|
237
|
+
|
|
238
|
+
assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['discord']);
|
|
239
|
+
assert.equal(ui.sandbox.manageInst.modules.source, 'chosen', 'the row is answered now, not still reading its bundle');
|
|
240
|
+
assert.equal(ui.msg().className, 'projMsg ok');
|
|
241
|
+
assert.match(ui.msg().textContent, /nothing to restart/i, 'this channel pushes nothing at the instance');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a refusal is SAID in the server’s own words, and the band goes back to what the row still holds', async () => {
|
|
245
|
+
const ui = boot({
|
|
246
|
+
selected: ['dev-box'],
|
|
247
|
+
patchAnswer: Promise.resolve({ ok: false, status: 400, _json: { error: { code: 'bad_modules', message: 'Unknown module "discrod" — the choices are: dev-box, discord, agents.' } } }),
|
|
248
|
+
});
|
|
249
|
+
await ui.render();
|
|
250
|
+
|
|
251
|
+
await ui.click('discord');
|
|
252
|
+
|
|
253
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
254
|
+
assert.match(ui.msg().textContent, /Unknown module "discrod"/);
|
|
255
|
+
assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['dev-box'],
|
|
256
|
+
'a refused write leaves the band showing the project’s real set');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('a signed-out save offers a way back in rather than a dead end', async () => {
|
|
260
|
+
const ui = boot({
|
|
261
|
+
selected: [],
|
|
262
|
+
patchAnswer: Promise.resolve({ ok: false, status: 401, _json: {} }),
|
|
263
|
+
});
|
|
264
|
+
await ui.render();
|
|
265
|
+
|
|
266
|
+
await ui.click('agents');
|
|
267
|
+
|
|
268
|
+
assert.match(ui.msg().innerHTML, /Sign in again/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('a catalog that ANSWERED badly says so; nothing about the project is claimed to have changed', async () => {
|
|
272
|
+
const ui = boot({ selected: ['dev-box'], catalogOk: false });
|
|
273
|
+
|
|
274
|
+
await ui.render();
|
|
275
|
+
await ui.settle();
|
|
276
|
+
await ui.render();
|
|
277
|
+
|
|
278
|
+
assert.match(ui.slot(), /couldn’t load the list of extras/i);
|
|
279
|
+
assert.match(ui.slot(), /Nothing about your project has changed/);
|
|
280
|
+
assert.equal(ui.rows().length, 0, 'and no band is painted from a catalog we do not have');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the catalog is read ONCE however often the section repaints', async () => {
|
|
284
|
+
const ui = boot({ selected: [] });
|
|
285
|
+
|
|
286
|
+
await ui.render();
|
|
287
|
+
await ui.render();
|
|
288
|
+
await ui.click('agents');
|
|
289
|
+
|
|
290
|
+
const gets = ui.requests.filter((r) => r.method === 'GET');
|
|
291
|
+
assert.equal(gets.length, 1, 'memoised like the wizard picker’s own read');
|
|
292
|
+
assert.equal(gets[0].url, '/api/bongos/provisioning/starter-bundles');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── the done panel's one pointer (criterion B) ───────────────────────────────
|
|
296
|
+
// A source pin, the tests/projects_hub.mjs idiom: the line is one sentence in a
|
|
297
|
+
// panel this file does not execute. What matters is that it exists, that it
|
|
298
|
+
// names THIS project rather than the list, and that it stays one line.
|
|
299
|
+
|
|
300
|
+
test('the wizard’s done panel points at the project’s settings for its extras', () => {
|
|
301
|
+
assert.match(HUB, /<p class="samePath" id="doneModules"><\/p>/, 'the panel carries the slot');
|
|
302
|
+
assert.match(SCRIPT, /\$\('doneModules'\)\.innerHTML = 'Add or remove modules later in ' \+ projectPageLink\('the project’s settings'\) \+ '\.';/,
|
|
303
|
+
'and renderDone fills it with one sentence pointing at the manage page');
|
|
304
|
+
assert.equal((SCRIPT.match(/doneModules/g) || []).length, 1, 'one line, written in one place');
|
|
305
|
+
assert.match(SCRIPT, /function projectPageLink\(label\) \{\s*return '<a href="' \+ \(instanceId \? '\?view=manage&id=' \+ encodeURIComponent\(instanceId\)/,
|
|
306
|
+
'and the link names the instance just created, not the list');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('a manage link inside the wizard carries its project id through the in-page router', () => {
|
|
310
|
+
// setView reads `manageId`, never the href — so a done-panel link that only
|
|
311
|
+
// matched `view=(\w+)` would land every owner on a manage page with no
|
|
312
|
+
// project, which is what this pointer would have done.
|
|
313
|
+
const handler = SCRIPT.slice(SCRIPT.indexOf("var a = ev.target.closest && ev.target.closest('a[href^=\"?view=\"]');"));
|
|
314
|
+
assert.match(handler.slice(0, 900), /var mid = \(href\.match\(\/\[\?&\]id=\(\[\^&\]\*\)\/\) \|\| \[\]\)\[1\];/,
|
|
315
|
+
'the id is lifted out of the href');
|
|
316
|
+
assert.match(handler.slice(0, 900), /if \(to === 'manage' && mid\) return goToManage\(decodeURIComponent\(mid\)\);/,
|
|
317
|
+
'and handed to goToManage, which is what sets manageId');
|
|
318
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -2141,11 +2141,38 @@ test('the Modules step ships as real markup and is skippable like About (task 10
|
|
|
2141
2141
|
'the optional step has no validation clause — Next past an untouched picker is the point');
|
|
2142
2142
|
});
|
|
2143
2143
|
|
|
2144
|
+
test('the picker\'s search ships as real markup, in the wizard\'s own field grammar (task 1002340)', () => {
|
|
2145
|
+
const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
|
|
2146
|
+
assert.match(panel, /<input type="text" id="modQ"[^>]*>/, 'the search is a real field, not one built by script');
|
|
2147
|
+
assert.match(panel, /id="modQ"[^>]*aria-label="Search the extras by name"/, 'and it is named for a screen reader');
|
|
2148
|
+
assert.match(panel, /id="modFilterNote"[^>]*aria-live="polite"/, 'the count under it is announced when it changes');
|
|
2149
|
+
// it wears the wizard's own control skin (#view-new input[type=text]) rather
|
|
2150
|
+
// than a class of its own — the C1 rule the type and team bands follow
|
|
2151
|
+
assert.doesNotMatch(panel, /id="modQ"[^>]*class=/, 'no bespoke class for one field');
|
|
2152
|
+
// the band, the field and the count are one wizField — a search that sat
|
|
2153
|
+
// outside the field it filters would read as filtering the whole panel
|
|
2154
|
+
const field = (panel.match(/<div class="wizField"><span id="modLbl"[\s\S]*?<\/div>\s*<\/div>/) || [''])[0];
|
|
2155
|
+
assert.match(field, /id="modQ"/, 'the field holds the search');
|
|
2156
|
+
assert.match(field, /id="modCards"/, 'and the band it filters');
|
|
2157
|
+
// display only: the filter reaches the render, never state.modules (ADR 0244)
|
|
2158
|
+
assert.match(SCRIPT, /function moduleMatches\(mod, terms\)/, 'the matcher is a named function the harness can run');
|
|
2159
|
+
assert.match(SCRIPT, /function visibleModules\(\)/, 'and the rows it leaves are derived, not stored');
|
|
2160
|
+
const onInput = SCRIPT.slice(SCRIPT.indexOf("$('modQ').addEventListener('input'"));
|
|
2161
|
+
const body = onInput.slice(0, onInput.indexOf('});'));
|
|
2162
|
+
assert.doesNotMatch(body, /state\.modules/, 'typing never writes an answer');
|
|
2163
|
+
assert.doesNotMatch(body, /saveDraft/, 'and a search is not part of the draft');
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2144
2166
|
test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
|
|
2145
2167
|
// A second source for "what may be chosen" is exactly what would let a
|
|
2146
2168
|
// preselect name a module the panel has no row for (ADR 0237 §5).
|
|
2147
2169
|
assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
|
|
2148
|
-
|
|
2170
|
+
// Two READERS since task 1002342 — the wizard's panel and the manage page's
|
|
2171
|
+
// extras band — but still ONE declaration: the count is what guards against a
|
|
2172
|
+
// second SOURCE, so it moves only when a reader is added, and both of these
|
|
2173
|
+
// name the same route.
|
|
2174
|
+
assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 2,
|
|
2175
|
+
'the create picker and the manage page, and nothing else');
|
|
2149
2176
|
// and it is a public read — no other module-catalog call joins it
|
|
2150
2177
|
assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
|
|
2151
2178
|
});
|
|
@@ -205,6 +205,13 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
|
|
|
205
205
|
skip: () => $('skipModules')._handlers.click(),
|
|
206
206
|
forCreate: () => sandbox.modulesForCreate(),
|
|
207
207
|
picked: () => sandbox.pickedModules(),
|
|
208
|
+
// BV1.R21 (task 1002340) — the search box. type() is the real `input` event
|
|
209
|
+
// the browser fires, so a test drives the same path a keystroke does.
|
|
210
|
+
type: (q) => { $('modQ')._handlers.input({ target: { value: q } }); },
|
|
211
|
+
filterNote: () => $('modFilterNote').textContent,
|
|
212
|
+
searchHidden: () => $('modQ').hidden === true,
|
|
213
|
+
// the pure matcher on its own, one row against one query
|
|
214
|
+
matches: (mod, q) => sandbox.moduleMatches(mod, sandbox.searchTerms(q)),
|
|
208
215
|
};
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -580,3 +587,203 @@ test('the picker and the server resolve the same set for every answer an owner c
|
|
|
580
587
|
}
|
|
581
588
|
}
|
|
582
589
|
});
|
|
590
|
+
|
|
591
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
592
|
+
// BV1.R21 (task 1002340) — keyword search over the catalog. ADR 0244: the search
|
|
593
|
+
// is a VIEW over the rows and never an edit to the answer, so everything below
|
|
594
|
+
// asserts both halves — what the band shows, and that what is switched on did
|
|
595
|
+
// not move.
|
|
596
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
const DEV_BOX = PAYLOAD.optional[1];
|
|
599
|
+
|
|
600
|
+
test('the matcher reads a row\'s own words — its name, its summary and its key', () => {
|
|
601
|
+
// Lexical, not semantic (idea 1000552 holds the by-meaning half): the only
|
|
602
|
+
// words it knows are the ones the owner can see, plus the key they cannot.
|
|
603
|
+
const h = boot({ state: { projType: 'game' } });
|
|
604
|
+
|
|
605
|
+
assert.equal(h.matches(DEV_BOX, 'boxes'), true, 'by a word in the name');
|
|
606
|
+
assert.equal(h.matches(DEV_BOX, 'workspace'), true, 'by a word in the summary');
|
|
607
|
+
assert.equal(h.matches(DEV_BOX, 'dev-box'), true, 'by the key');
|
|
608
|
+
assert.equal(h.matches(DEV_BOX, 'discord'), false, 'and never by a word that is in none of them');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('a term matches as a SUBSTRING, so a half-typed word still finds its module', () => {
|
|
612
|
+
const h = boot({ state: { projType: 'game' } });
|
|
613
|
+
|
|
614
|
+
assert.equal(h.matches(DEV_BOX, 'work'), true, 'the owner is still typing');
|
|
615
|
+
assert.equal(h.matches(DEV_BOX, 'ace'), true, 'even mid-word — a dumb filter beats a clever one that finds nothing');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('matching is case-insensitive in both directions', () => {
|
|
619
|
+
const h = boot({ state: { projType: 'game' } });
|
|
620
|
+
|
|
621
|
+
assert.equal(h.matches(DEV_BOX, 'DEV BOXES'), true, 'a shouted query finds a title-cased row');
|
|
622
|
+
assert.equal(h.matches(PAYLOAD.optional[0], 'ai'), true, 'and a quiet one finds a shouted row');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('every term must match — typing more words narrows, it never widens', () => {
|
|
626
|
+
// AND, not OR. An owner adding a word is telling us the list is still too long.
|
|
627
|
+
const h = boot({ state: { projType: 'game' } });
|
|
628
|
+
|
|
629
|
+
assert.equal(h.matches(DEV_BOX, 'cloud change'), true, 'both words are in the row');
|
|
630
|
+
assert.equal(h.matches(DEV_BOX, 'cloud discord'), false, 'one of them is not, so the row is out');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('an empty search is not a search — every row stays', () => {
|
|
634
|
+
const h = boot({ state: { projType: 'game' } });
|
|
635
|
+
|
|
636
|
+
assert.equal(h.matches(DEV_BOX, ''), true);
|
|
637
|
+
assert.equal(h.matches(DEV_BOX, ' '), true, 'and a box holding only spaces is the same as an empty one');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('typing filters the band to the rows that answer, in catalog order', async () => {
|
|
641
|
+
// Arrange
|
|
642
|
+
const h = boot({ state: { projType: 'game' } });
|
|
643
|
+
await h.open();
|
|
644
|
+
assert.equal(h.rows().length, 3, 'the whole catalog before anything is typed');
|
|
645
|
+
|
|
646
|
+
// Act — a word from one row's NAME
|
|
647
|
+
h.type('boxes');
|
|
648
|
+
|
|
649
|
+
// Assert
|
|
650
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['dev-box']);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('a summary word and a key find their row just as a name word does', async () => {
|
|
654
|
+
const h = boot({ state: { projType: 'game' } });
|
|
655
|
+
await h.open();
|
|
656
|
+
|
|
657
|
+
h.type('workspace');
|
|
658
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'by summary');
|
|
659
|
+
|
|
660
|
+
h.type('dev-box');
|
|
661
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'by key');
|
|
662
|
+
|
|
663
|
+
h.type('DISCORD');
|
|
664
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'and case is not part of the question');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('two terms narrow to the rows that carry both', async () => {
|
|
668
|
+
const h = boot({ state: { projType: 'game' } });
|
|
669
|
+
await h.open();
|
|
670
|
+
|
|
671
|
+
h.type('cloud change');
|
|
672
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['dev-box']);
|
|
673
|
+
|
|
674
|
+
h.type('cloud discord');
|
|
675
|
+
assert.deepEqual(h.rows(), [], 'and no row carries both of these');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('a search that matches nothing says so, and invents no rows', async () => {
|
|
679
|
+
// The band would otherwise just be empty, which reads as "the catalog failed"
|
|
680
|
+
// — the one thing the couldn't-load clause exists to distinguish.
|
|
681
|
+
const h = boot({ state: { projType: 'game' } });
|
|
682
|
+
await h.open();
|
|
683
|
+
|
|
684
|
+
h.type('kubernetes');
|
|
685
|
+
|
|
686
|
+
assert.deepEqual(h.rows(), []);
|
|
687
|
+
assert.match(h.filterNote(), /No extras match/, 'the panel says why the band is empty');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('a switched-on module the search is HIDING is still switched on', async () => {
|
|
691
|
+
// The property ADR 0244 is about. A filter is a view; the answer is the set of
|
|
692
|
+
// toggles, and a view must not be able to edit it.
|
|
693
|
+
const h = boot({ state: { projType: 'game' } });
|
|
694
|
+
await h.open();
|
|
695
|
+
assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'the game bundle is on');
|
|
696
|
+
|
|
697
|
+
// Act — search for the one module that is OFF, hiding both that are on
|
|
698
|
+
h.type('agents');
|
|
699
|
+
|
|
700
|
+
// Assert
|
|
701
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['agents'], 'only the match is on screen');
|
|
702
|
+
assert.equal(h.rows()[0].pressed, false, 'and it is off, as it was');
|
|
703
|
+
assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'the two hidden ones are still switched on');
|
|
704
|
+
assert.equal(h.state.modules, null, 'and typing answered nothing on the owner\'s behalf');
|
|
705
|
+
assert.match(h.filterNote(), /2 extras are switched on/, 'the count under the field says so out loud');
|
|
706
|
+
assert.match(h.filterNote(), /including any this search is hiding/, 'and that some of them are hidden');
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('the count under the field states what is shown and what is on, singular included', async () => {
|
|
710
|
+
const h = boot({ state: { projType: 'research' } });
|
|
711
|
+
await h.open();
|
|
712
|
+
assert.equal(h.filterNote(), '', 'with nothing typed there is nothing to explain');
|
|
713
|
+
|
|
714
|
+
h.type('agents');
|
|
715
|
+
|
|
716
|
+
assert.equal(h.filterNote(),
|
|
717
|
+
'Showing 1 of 3. 1 extra is switched on, including any this search is hiding.');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test('with nothing switched on the count says only what is shown', async () => {
|
|
721
|
+
const h = boot({ state: { projType: 'not-sure' } });
|
|
722
|
+
await h.open();
|
|
723
|
+
|
|
724
|
+
h.type('a');
|
|
725
|
+
|
|
726
|
+
assert.equal(h.filterNote(), 'Showing 3 of 3.', 'no "0 extras are switched on" noise');
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('the review names the hidden picks too — a filter never reaches the create request', async () => {
|
|
730
|
+
const h = boot({ state: { projType: 'game', step: 7 } });
|
|
731
|
+
await h.open();
|
|
732
|
+
h.click('agents');
|
|
733
|
+
assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord']);
|
|
734
|
+
|
|
735
|
+
h.type('discord');
|
|
736
|
+
|
|
737
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'one row on screen');
|
|
738
|
+
assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord'], 'three modules in the answer');
|
|
739
|
+
assert.equal(h.extrasRow(), 'Extra agents, Dev boxes, Discord', 'and the review names all three');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('clearing the search brings every row back, untouched', async () => {
|
|
743
|
+
const h = boot({ state: { projType: 'game' } });
|
|
744
|
+
await h.open();
|
|
745
|
+
h.type('agents');
|
|
746
|
+
assert.equal(h.rows().length, 1);
|
|
747
|
+
|
|
748
|
+
h.type('');
|
|
749
|
+
|
|
750
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['agents', 'dev-box', 'discord']);
|
|
751
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord'],
|
|
752
|
+
'with the preselect exactly as it was before the search');
|
|
753
|
+
assert.equal(h.filterNote(), '', 'and the count goes quiet again');
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test('a toggle made while filtered lands in the answer in catalog order, and the filter stands', async () => {
|
|
757
|
+
// The path BV1.R24 walks: preselect -> search -> add -> enabled.
|
|
758
|
+
const h = boot({ state: { projType: 'non-profit' } });
|
|
759
|
+
await h.open();
|
|
760
|
+
|
|
761
|
+
h.type('workspace');
|
|
762
|
+
h.click('dev-box');
|
|
763
|
+
|
|
764
|
+
assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'catalog order, not click order');
|
|
765
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'and the search is still the search');
|
|
766
|
+
assert.equal(h.rows()[0].pressed, true, 'the row they just switched on shows it');
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('a search is not an answer: it saves no draft and costs no round trip', async () => {
|
|
770
|
+
const h = boot({ state: { projType: 'game' } });
|
|
771
|
+
await h.open();
|
|
772
|
+
const savesBefore = h.saves();
|
|
773
|
+
|
|
774
|
+
h.type('dev');
|
|
775
|
+
h.type('');
|
|
776
|
+
|
|
777
|
+
assert.equal(h.saves(), savesBefore, 'nothing is written to the draft');
|
|
778
|
+
assert.equal(h.fetches(), 1, 'and the catalog is already here — typing re-reads nothing');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test('with no catalog there is nothing to search, so the box is not offered', async () => {
|
|
782
|
+
// A search field over a list that failed to load is an affordance that can
|
|
783
|
+
// only disappoint.
|
|
784
|
+
const h = boot({ state: { projType: 'game' }, fetchOk: false });
|
|
785
|
+
await h.open();
|
|
786
|
+
|
|
787
|
+
assert.equal(h.searchHidden(), true);
|
|
788
|
+
assert.equal(h.filterNote(), '', 'and no count for a band with no rows');
|
|
789
|
+
});
|
|
@@ -496,8 +496,8 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
|
|
|
496
496
|
'and it is named by id whenever the flow has one');
|
|
497
497
|
// every done-panel pointer that names a CONTROL goes the same way; the ones
|
|
498
498
|
// that only say "watch it there" still name the list, which does show it
|
|
499
|
-
assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length,
|
|
500
|
-
'the helper and its
|
|
499
|
+
assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length, 7,
|
|
500
|
+
'the helper and its six callers: the locked step, the domain_required snag, the domainless standup, the snagged retry, the torn-down revive, and the extras pointer (task 1002342)');
|
|
501
501
|
for (const dead of ['Retry it \\(and see the technical details\\) from <a href="\\?view=mine">',
|
|
502
502
|
'Bring it back any time from <a href="\\?view=mine">']) {
|
|
503
503
|
assert.doesNotMatch(SCRIPT, new RegExp(dead), 'a control the list no longer has must not be pointed at there');
|
|
@@ -60,6 +60,7 @@ PGDATABASE=spike
|
|
|
60
60
|
${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=public
|
|
61
61
|
${NEUTRAL_PREFIX}_JOINABILITY=apply
|
|
62
62
|
${NEUTRAL_PREFIX}_VISIBILITY=public
|
|
63
|
+
${NEUTRAL_PREFIX}_JOIN_GRANT=full
|
|
63
64
|
`;
|
|
64
65
|
|
|
65
66
|
t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
|
|
@@ -112,7 +113,7 @@ t('is PURE: same input, same output; empty body yields just the lines', () => {
|
|
|
112
113
|
t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at standup, under the pack prefix', () => {
|
|
113
114
|
const inst = { slug: 'spike', port: 3010, db_name: 'spike', hosting_shape: 'standalone', target_ref: 'jaxri/spike', settings: { platform_visibility: 'gated' } };
|
|
114
115
|
const vars = P.settingsEnvVarsFor(inst);
|
|
115
|
-
assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public' });
|
|
116
|
+
assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full' });
|
|
116
117
|
const body = P.webEnvBody(inst, {});
|
|
117
118
|
for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
|
|
118
119
|
});
|
|
@@ -123,10 +124,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
|
|
|
123
124
|
const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
|
|
124
125
|
|
|
125
126
|
t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
|
|
126
|
-
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' }), GATED), true);
|
|
127
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), true);
|
|
127
128
|
});
|
|
128
129
|
t('false when the instance still reports a different value', () => {
|
|
129
|
-
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public' }), GATED), false);
|
|
130
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), false);
|
|
130
131
|
assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
|
|
131
132
|
});
|
|
132
133
|
t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
|
|
@@ -144,7 +145,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
|
|
|
144
145
|
function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
|
|
145
146
|
manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
|
|
146
147
|
const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
|
|
147
|
-
const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' })
|
|
148
|
+
const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' })
|
|
148
149
|
: manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
|
|
149
150
|
: manifest === 'none' ? manifestWith(undefined) : 'nope';
|
|
150
151
|
const exec = (cmd, opts = {}) => {
|
|
@@ -213,7 +214,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
|
|
|
213
214
|
assert.equal(r.ok, true);
|
|
214
215
|
assert.equal(r.healthy, null, 'nothing ran, nothing observed');
|
|
215
216
|
assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
|
|
216
|
-
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
217
|
+
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
217
218
|
assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
|
|
218
219
|
assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
|
|
219
220
|
});
|