@bongos/core 1.19.533 → 1.19.535
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 +121 -56
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- 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 +4 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +162 -157
- package/docs/copy-registry.json +252 -196
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -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 +162 -3
- 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/scripts/hall-preview/refresh-fixtures.js +6 -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/hall_kit_charts.mjs +173 -0
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_reading_world.mjs +153 -0
- 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 +6 -1
- 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
|
@@ -2167,7 +2167,12 @@ test('the picker reads ONE public declaration, and it is the one the bundles com
|
|
|
2167
2167
|
// A second source for "what may be chosen" is exactly what would let a
|
|
2168
2168
|
// preselect name a module the panel has no row for (ADR 0237 §5).
|
|
2169
2169
|
assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
|
|
2170
|
-
|
|
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');
|
|
2171
2176
|
// and it is a public read — no other module-catalog call joins it
|
|
2172
2177
|
assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
|
|
2173
2178
|
});
|
|
@@ -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
|
});
|
|
@@ -30,7 +30,8 @@ import http from 'node:http';
|
|
|
30
30
|
// ESM named imports off the CJS module on purpose — knip cannot trace a
|
|
31
31
|
// createRequire() import, so these would read as dead exports (the repo's knip
|
|
32
32
|
// learning; tests/provisioning_starter_bundles.mjs does the same).
|
|
33
|
-
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules }
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules, normalizeModuleSelection }
|
|
34
|
+
from '../modules/provisioning/starter-bundles.js';
|
|
34
35
|
|
|
35
36
|
const require = createRequire(import.meta.url);
|
|
36
37
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
@@ -178,6 +179,10 @@ provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
|
178
179
|
if (patch.detail !== undefined) {
|
|
179
180
|
lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
|
|
180
181
|
}
|
|
182
|
+
// `modules` REPLACES and is normalized on the way in — the real statement's
|
|
183
|
+
// `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
|
|
184
|
+
// A stub that merged would hide the removal half of "add modules later".
|
|
185
|
+
if (patch.modules !== undefined) lastUpdatedRow.modules = normalizeModuleSelection(patch.modules);
|
|
181
186
|
return lastUpdatedRow;
|
|
182
187
|
};
|
|
183
188
|
|
|
@@ -286,8 +291,9 @@ await ta('POST /instances: modules must be an array of strings', async () => {
|
|
|
286
291
|
});
|
|
287
292
|
|
|
288
293
|
await ta('POST /instances: a re-request never changes an existing project\'s modules — and SAYS so', async () => {
|
|
289
|
-
// Same honesty as
|
|
290
|
-
//
|
|
294
|
+
// Same honesty as detail_ignored (task 1002334): since BV1.R23 (task 1002342)
|
|
295
|
+
// there IS a way back, so the receipt names it rather than leaving the owner
|
|
296
|
+
// to hunt for one.
|
|
291
297
|
existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
|
|
292
298
|
const res = await post('/provisioning/instances', {
|
|
293
299
|
slug: 'mercury', hosting_shape: 'standalone', modules: ['discord'],
|
|
@@ -295,6 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
295
301
|
assert.equal(res.status, 200, res.raw);
|
|
296
302
|
assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
|
|
297
303
|
assert.match(res.body.modules_ignored.hint, /never changes/);
|
|
304
|
+
assert.match(res.body.modules_ignored.hint, /\/detail/, 'and names the route that does change it');
|
|
298
305
|
assert.deepEqual(res.body.instance.modules.selected, ['agents'], 'and the stored answer is what reads back');
|
|
299
306
|
existingRow = null;
|
|
300
307
|
});
|
|
@@ -341,6 +348,160 @@ await ta('PATCH /detail: a description-only patch answers with modules too — t
|
|
|
341
348
|
detailRow = null; lastUpdatedRow = null;
|
|
342
349
|
});
|
|
343
350
|
|
|
351
|
+
// ── Adding (and removing) a module AFTER creation ──────────────────────────
|
|
352
|
+
// BV1.R23, task 1002342, ADR 0244. The picker is optional at creation, and an
|
|
353
|
+
// optional step is only a default rather than a dead end if there is a way back
|
|
354
|
+
// — the same argument that made PATCH …/detail exist for the About step beside
|
|
355
|
+
// it. So the way back is that route's `modules` key, not a new endpoint: the
|
|
356
|
+
// same platform row, the same ownership gate, the same receipt.
|
|
357
|
+
|
|
358
|
+
await ta('PATCH /detail: an owner who skipped the picker can add one module later', async () => {
|
|
359
|
+
// Arrange — a game project that skipped: the column is NULL and the read
|
|
360
|
+
// resolves the type's bundle.
|
|
361
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
362
|
+
lastUpdatedRow = null;
|
|
363
|
+
const before = effectiveModules(detailRow);
|
|
364
|
+
assert.equal(before.source, 'bundle', 'the fixture really is an unanswered project');
|
|
365
|
+
|
|
366
|
+
// Act — the owner adds `agents` on top of what the bundle gave them
|
|
367
|
+
const res = await patch('/provisioning/instances/41/detail', {
|
|
368
|
+
modules: before.selected.concat(['agents']),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Assert — stored, and the read-back is the answer rather than the bundle
|
|
372
|
+
assert.equal(res.status, 200, res.raw);
|
|
373
|
+
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
374
|
+
assert.deepEqual(lastUpdatedRow.modules, normalizeModuleSelection(before.selected.concat(['agents'])),
|
|
375
|
+
'in catalog order, exactly as the creation write stores it');
|
|
376
|
+
assert.ok(res.body.modules.selected.includes('agents'), 'the added module is there');
|
|
377
|
+
assert.equal(res.body.modules.source, 'chosen', 'an answered project is no longer reading the bundle');
|
|
378
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow), 'the reply is the POST-patch resolution');
|
|
379
|
+
detailRow = null; lastUpdatedRow = null;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
|
|
383
|
+
// The half a merging write would make impossible, which is why
|
|
384
|
+
// updateInstanceDetail replaces `modules` and merges `detail`.
|
|
385
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['dev-box', 'discord'], detail: {} };
|
|
386
|
+
lastUpdatedRow = null;
|
|
387
|
+
|
|
388
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
|
|
389
|
+
|
|
390
|
+
assert.equal(res.status, 200, res.raw);
|
|
391
|
+
assert.deepEqual(lastUpdatedRow.modules, ['discord']);
|
|
392
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'dev-box is gone, not still merged in');
|
|
393
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
394
|
+
detailRow = null; lastUpdatedRow = null;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await ta('PATCH /detail: switching them all off later is an answer, and it stays off', async () => {
|
|
398
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
399
|
+
lastUpdatedRow = null;
|
|
400
|
+
|
|
401
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: [] });
|
|
402
|
+
|
|
403
|
+
assert.equal(res.status, 200, res.raw);
|
|
404
|
+
assert.deepEqual(lastUpdatedRow.modules, [], 'an empty answer is stored as one, never as NULL');
|
|
405
|
+
assert.deepEqual(res.body.modules.selected, []);
|
|
406
|
+
assert.equal(res.body.modules.source, 'chosen', 'a project that switched the bundle off does not get it back');
|
|
407
|
+
detailRow = null; lastUpdatedRow = null;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await ta('PATCH /detail: the always-on core cannot be removed — it was never on the list', async () => {
|
|
411
|
+
// Two guards, both pinned: the route REFUSES a core key by name, and the read
|
|
412
|
+
// re-adds the whole core regardless of what was stored.
|
|
413
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
414
|
+
lastUpdatedRow = null;
|
|
415
|
+
|
|
416
|
+
const refused = await patch('/provisioning/instances/41/detail', { modules: ['memory'] });
|
|
417
|
+
assert.equal(refused.status, 400, refused.raw);
|
|
418
|
+
assert.equal(refused.body.error.code, 'bad_modules');
|
|
419
|
+
assert.match(refused.body.error.message, /always on/i);
|
|
420
|
+
assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
|
|
421
|
+
|
|
422
|
+
const emptied = await patch('/provisioning/instances/41/detail', { modules: [] });
|
|
423
|
+
assert.deepEqual(emptied.body.modules.core, [...ALWAYS_ON_CORE], 'the core rides every read');
|
|
424
|
+
assert.deepEqual(emptied.body.modules.modules, [...ALWAYS_ON_CORE], 'and every project still has it');
|
|
425
|
+
detailRow = null; lastUpdatedRow = null;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await ta('PATCH /detail: an unknown module is refused and NAMED, exactly as at creation', async () => {
|
|
429
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
430
|
+
lastUpdatedRow = null;
|
|
431
|
+
|
|
432
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['discrod'] });
|
|
433
|
+
|
|
434
|
+
assert.equal(res.status, 400, res.raw);
|
|
435
|
+
assert.equal(res.body.error.code, 'bad_modules');
|
|
436
|
+
assert.match(res.body.error.message, /discrod/, 'the message names what was wrong');
|
|
437
|
+
assert.match(res.body.error.message, /agents/, 'and what the choices are');
|
|
438
|
+
assert.equal(lastUpdatedRow, null);
|
|
439
|
+
detailRow = null; lastUpdatedRow = null;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await ta('PATCH /detail: modules must be an array of strings here too', async () => {
|
|
443
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
444
|
+
const notArray = await patch('/provisioning/instances/41/detail', { modules: 'discord' });
|
|
445
|
+
assert.equal(notArray.status, 400, notArray.raw);
|
|
446
|
+
assert.equal(notArray.body.error.code, 'validation_failed');
|
|
447
|
+
|
|
448
|
+
const notStrings = await patch('/provisioning/instances/41/detail', { modules: [7] });
|
|
449
|
+
assert.equal(notStrings.status, 400, notStrings.raw);
|
|
450
|
+
assert.equal(notStrings.body.error.code, 'validation_failed');
|
|
451
|
+
detailRow = null; lastUpdatedRow = null;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await ta('PATCH /detail: a modules-only body is a real change, not an empty one', async () => {
|
|
455
|
+
// The "nothing to change" guard predates this key; a body carrying only
|
|
456
|
+
// modules must not be turned away by it.
|
|
457
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
458
|
+
lastUpdatedRow = null;
|
|
459
|
+
|
|
460
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
|
|
461
|
+
|
|
462
|
+
assert.equal(res.status, 200, res.raw);
|
|
463
|
+
assert.ok(lastUpdatedRow, 'the update ran off modules alone');
|
|
464
|
+
detailRow = null; lastUpdatedRow = null;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await ta('PATCH /detail: null modules cannot UN-ASK the question — the skip state is not writable', async () => {
|
|
468
|
+
// ADR 0240 §2's three-valued contract: NULL means "nobody answered". A later
|
|
469
|
+
// edit that could write NULL back would make a project that HAD answered
|
|
470
|
+
// indistinguishable from one that never was, permanently.
|
|
471
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
|
|
472
|
+
lastUpdatedRow = null;
|
|
473
|
+
|
|
474
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: null, description: 'Still here.' });
|
|
475
|
+
|
|
476
|
+
assert.equal(res.status, 200, res.raw);
|
|
477
|
+
assert.deepEqual(lastUpdatedRow.modules, ['discord'], 'the stored answer is untouched');
|
|
478
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
479
|
+
detailRow = null; lastUpdatedRow = null;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await ta('PATCH /detail: a body with only a null modules key is still nothing to change', async () => {
|
|
483
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
484
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: null });
|
|
485
|
+
assert.equal(res.status, 400, res.raw);
|
|
486
|
+
assert.equal(res.body.error.code, 'bad_detail');
|
|
487
|
+
assert.match(res.body.error.message, /modules/, 'and the message names the third thing it accepts');
|
|
488
|
+
detailRow = null; lastUpdatedRow = null;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
await ta('PATCH /detail: another builder’s project is a 404, not a module change', async () => {
|
|
492
|
+
// The ownership wall is instanceAccessDenied's, shared with every sibling
|
|
493
|
+
// route — pinned here because this key is new on it.
|
|
494
|
+
detailRow = { id: 41, owner_builder_id: 99, slug: 'not-mine', status: 'active', type: 'game', modules: null, detail: {} };
|
|
495
|
+
lastUpdatedRow = null;
|
|
496
|
+
|
|
497
|
+
const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
|
|
498
|
+
|
|
499
|
+
assert.equal(res.status, 404, res.raw);
|
|
500
|
+
assert.equal(res.body.error.code, 'instance_not_found');
|
|
501
|
+
assert.equal(lastUpdatedRow, null, 'nothing was written');
|
|
502
|
+
detailRow = null; lastUpdatedRow = null;
|
|
503
|
+
});
|
|
504
|
+
|
|
344
505
|
server.close();
|
|
345
506
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
346
507
|
process.exit(failed === 0 ? 0 : 1);
|