@bongos/core 1.19.529 → 1.19.531
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 +33 -18
- package/docs/copy-inventory.md +10 -8
- package/docs/copy-registry.json +41 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/routes/callback-page.js +159 -0
- package/modules/provisioning/routes/provisioning.js +4 -136
- package/modules/status-ui/public/explore.html +56 -3
- package/modules/status-ui/public/explore.js +71 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/design_tokens_sync.mjs +1 -1
- package/tests/explore_visible_states.mjs +285 -0
- package/tests/provisioning_callback_page.mjs +74 -0
- package/tests/provisioning_module_selection.mjs +64 -4
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// tests/explore_visible_states.mjs — the Explore page draws the three visible
|
|
2
|
+
// states off the R06 feed (BV1.R07, task 1002326; goal 1000046, criterion
|
|
3
|
+
// `proj-visibility-states`; ADR 0182 D1/D2a).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXECUTES THE PAGE RATHER THAN GREPPING IT. explore.js builds every
|
|
6
|
+
// card with createElement/appendChild — there is no markup in explore.html to pin.
|
|
7
|
+
// A static test could only assert that the words "planet" and "black-hole" appear
|
|
8
|
+
// somewhere in the source, which a page that renders nothing would also pass. So
|
|
9
|
+
// this runs the REAL file in `vm` under a small DOM stub (the tests/idea_detail_page.mjs
|
|
10
|
+
// precedent — there is no jsdom in this repo) and asserts over the tree a visitor
|
|
11
|
+
// would actually get.
|
|
12
|
+
//
|
|
13
|
+
// The three things R07 owes, as assertions:
|
|
14
|
+
// 1. each visible state draws ITS orb — planet / star / black hole — off the
|
|
15
|
+
// feed's own `orb` field, with the plain word (Public / Private / Stealth)
|
|
16
|
+
// as the label, because the space words are the drawn orb's, not the copy's;
|
|
17
|
+
// 2. a stealth row is ANONYMOUS on the page: no name, no address, no picture,
|
|
18
|
+
// no link — and stays anonymous even if a regressed server sends identity,
|
|
19
|
+
// which is the client half of the feed's server-side allow-list (D2a);
|
|
20
|
+
// 3. DARK MATTER NEVER DRAWS. It is excluded in SQL and must never reach here;
|
|
21
|
+
// if it ever does, a row this page cannot classify is dropped, not guessed at.
|
|
22
|
+
//
|
|
23
|
+
// Run: node --test --test-reporter=tap tests/explore_visible_states.mjs
|
|
24
|
+
|
|
25
|
+
import test from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import vm from 'node:vm';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
// The state→orb mapping is the feed's, not this page's (projects-feed.js is the
|
|
33
|
+
// only copy of it in the repo). Importing it here means a rename on the server
|
|
34
|
+
// fails this file rather than silently leaving Explore drawing the old words.
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const { ORB_BY_STATE, feedShapeFor } = require('../modules/platform-identity/projects-feed.js');
|
|
38
|
+
|
|
39
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
const PAGE = path.join(ROOT, 'modules/status-ui/public');
|
|
41
|
+
const SRC = fs.readFileSync(path.join(PAGE, 'explore.js'), 'utf8');
|
|
42
|
+
const HTML = fs.readFileSync(path.join(PAGE, 'explore.html'), 'utf8');
|
|
43
|
+
|
|
44
|
+
// ---- the DOM stub ---------------------------------------------------------
|
|
45
|
+
// Only what explore.js touches: createElement, appendChild, textContent, className,
|
|
46
|
+
// setAttribute and plain property assignment (href/src/alt/loading/target/rel).
|
|
47
|
+
|
|
48
|
+
function makeElement(tag) {
|
|
49
|
+
return {
|
|
50
|
+
tagName: String(tag).toUpperCase(),
|
|
51
|
+
className: '',
|
|
52
|
+
textContent: '',
|
|
53
|
+
hidden: false,
|
|
54
|
+
children: [],
|
|
55
|
+
attrs: {},
|
|
56
|
+
appendChild(child) { this.children.push(child); return child; },
|
|
57
|
+
setAttribute(k, v) { this.attrs[k] = String(v); },
|
|
58
|
+
getAttribute(k) { return Object.prototype.hasOwnProperty.call(this.attrs, k) ? this.attrs[k] : null; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every node in the subtree, root first.
|
|
63
|
+
function nodes(el) {
|
|
64
|
+
return [el, ...el.children.flatMap(nodes)];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Everything a visitor can READ on this subtree.
|
|
68
|
+
function textOf(el) {
|
|
69
|
+
return nodes(el).map((n) => n.textContent || '').join(' ').replace(/\s+/g, ' ').trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Everything a visitor's BROWSER can reach: text plus every attribute and every
|
|
73
|
+
// URL-bearing property. This is what an anonymity assertion has to look at — a
|
|
74
|
+
// name hidden in an href or an <img src> has still left the building.
|
|
75
|
+
function reachable(el) {
|
|
76
|
+
return nodes(el)
|
|
77
|
+
.flatMap((n) => [n.textContent, n.className, n.href, n.src, n.alt, ...Object.values(n.attrs)])
|
|
78
|
+
.filter((v) => typeof v === 'string')
|
|
79
|
+
.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const classed = (el, cls) => nodes(el).filter((n) => String(n.className).split(/\s+/).includes(cls));
|
|
83
|
+
|
|
84
|
+
// Boot explore.js against the stub with `projects` as the feed's answer.
|
|
85
|
+
function render(projects, { ok = true, body = null } = {}) {
|
|
86
|
+
const els = new Map(['grid', 'empty', 'err'].map(
|
|
87
|
+
(id) => [id, Object.assign(makeElement('div'), { id, hidden: true })]));
|
|
88
|
+
|
|
89
|
+
const documentObj = {
|
|
90
|
+
getElementById: (id) => els.get(id) || null,
|
|
91
|
+
createElement: (tag) => makeElement(tag),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const requests = [];
|
|
95
|
+
const sandbox = {
|
|
96
|
+
document: documentObj,
|
|
97
|
+
URL,
|
|
98
|
+
console,
|
|
99
|
+
setTimeout,
|
|
100
|
+
fetch(url, opts) {
|
|
101
|
+
requests.push({ url, opts });
|
|
102
|
+
return Promise.resolve({
|
|
103
|
+
ok,
|
|
104
|
+
status: ok ? 200 : 500,
|
|
105
|
+
json: () => Promise.resolve(body === null ? { projects } : body),
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
sandbox.globalThis = sandbox;
|
|
110
|
+
vm.createContext(sandbox);
|
|
111
|
+
vm.runInContext(SRC, sandbox, { filename: 'explore.js' });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
requests,
|
|
115
|
+
grid: () => els.get('grid'),
|
|
116
|
+
empty: () => els.get('empty'),
|
|
117
|
+
err: () => els.get('err'),
|
|
118
|
+
cards: () => els.get('grid').children,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// fetch → .then(json) → .then(render) is three microtask hops; a macrotask drains them all.
|
|
123
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
124
|
+
|
|
125
|
+
// ---- fixtures: rows exactly as GET /projects/featured shapes them ----------
|
|
126
|
+
// Built through the REAL feedShapeFor(), so the stealth row is redacted by the
|
|
127
|
+
// server's own allow-list rather than by this test's idea of it.
|
|
128
|
+
|
|
129
|
+
const IDENTIFIED = {
|
|
130
|
+
origin: 'https://example-project.test',
|
|
131
|
+
name: 'Example Project',
|
|
132
|
+
tagline: 'A tagline that says what it is.',
|
|
133
|
+
art_url: 'https://example-project.test/art.png',
|
|
134
|
+
type: 'game',
|
|
135
|
+
template: null,
|
|
136
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const publicRow = feedShapeFor({ ...IDENTIFIED, visibility: 'public' });
|
|
140
|
+
const privateRow = feedShapeFor({
|
|
141
|
+
...IDENTIFIED, origin: 'https://closed-project.test', name: 'Closed Project', visibility: 'private',
|
|
142
|
+
});
|
|
143
|
+
const stealthRow = feedShapeFor({
|
|
144
|
+
...IDENTIFIED, origin: 'https://hidden-project.test', name: 'Hidden Project', visibility: 'stealth',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---- 1. each visible state draws its orb ----------------------------------
|
|
148
|
+
|
|
149
|
+
test('the feed mapping this page reads is still planet / star / black-hole', () => {
|
|
150
|
+
assert.deepEqual({ ...ORB_BY_STATE }, { public: 'planet', private: 'star', stealth: 'black-hole' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('each of the three visible states draws its own orb, with the plain word beside it', async () => {
|
|
154
|
+
const r = render([publicRow, privateRow, stealthRow]);
|
|
155
|
+
await settle();
|
|
156
|
+
assert.equal(r.cards().length, 3, 'all three visible states are on the map');
|
|
157
|
+
assert.equal(r.grid().hidden, false, 'the grid is shown');
|
|
158
|
+
assert.equal(r.empty().hidden, true, 'and the empty state is not');
|
|
159
|
+
|
|
160
|
+
const expected = [['orb-planet', 'Public'], ['orb-star', 'Private'], ['orb-black-hole', 'Stealth']];
|
|
161
|
+
r.cards().forEach((card, i) => {
|
|
162
|
+
const [orbClass, label] = expected[i];
|
|
163
|
+
assert.equal(classed(card, orbClass).length, 1, `card ${i} draws exactly one ${orbClass}`);
|
|
164
|
+
assert.ok(classed(card, 'state-label').some((n) => n.textContent === label),
|
|
165
|
+
`card ${i} is labelled "${label}"`);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the drawn orb is decorative — the plain word beside it is its accessible name', async () => {
|
|
170
|
+
const r = render([publicRow]);
|
|
171
|
+
await settle();
|
|
172
|
+
const [orb] = classed(r.cards()[0], 'orb');
|
|
173
|
+
assert.equal(orb.getAttribute('aria-hidden'), 'true',
|
|
174
|
+
'a screen reader hears "Public", never a shape it cannot see');
|
|
175
|
+
assert.equal(orb.textContent, '', 'the orb carries no text of its own');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no card writes the space words as copy — the metaphor is drawn, not said', async () => {
|
|
179
|
+
const r = render([publicRow, privateRow, stealthRow]);
|
|
180
|
+
await settle();
|
|
181
|
+
for (const card of r.cards()) {
|
|
182
|
+
assert.doesNotMatch(textOf(card), /\b(planet|star|black hole|black-hole)\b/i,
|
|
183
|
+
'the goal\'s interview decision: plain labels only — Public, Private, Stealth');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---- 2. a stealth row is anonymous ----------------------------------------
|
|
188
|
+
|
|
189
|
+
test('a stealth card names nothing: no name, no address, no picture, no link', async () => {
|
|
190
|
+
const r = render([stealthRow]);
|
|
191
|
+
await settle();
|
|
192
|
+
const card = r.cards()[0];
|
|
193
|
+
|
|
194
|
+
assert.equal(card.tagName, 'DIV', 'an anonymous card is not a link — there is nowhere to send anyone');
|
|
195
|
+
assert.equal(nodes(card).filter((n) => n.tagName === 'IMG').length, 0, 'and carries no picture');
|
|
196
|
+
assert.equal(nodes(card).filter((n) => n.tagName === 'A').length, 0, 'and no link inside it either');
|
|
197
|
+
assert.equal(classed(card, 'origin').length, 0, 'and shows no address');
|
|
198
|
+
|
|
199
|
+
const seen = reachable(card);
|
|
200
|
+
for (const secret of ['Hidden Project', 'hidden-project.test', 'art.png', 'A tagline']) {
|
|
201
|
+
assert.ok(!seen.includes(secret), `"${secret}" never reaches the page`);
|
|
202
|
+
}
|
|
203
|
+
assert.ok(/something is here/i.test(textOf(card)),
|
|
204
|
+
'it says what a black hole says: something is there, and that is all');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a stealth row stays anonymous even if a regressed server sends identity', async () => {
|
|
208
|
+
// The feed nulls these server-side (ADR 0182 D2a). This is the client half of
|
|
209
|
+
// the same allow-list: the anonymous branch never READS an identifying field,
|
|
210
|
+
// so a server that started sending one again could not put it on the page.
|
|
211
|
+
const leaky = { ...stealthRow, name: 'Hidden Project', origin: 'https://hidden-project.test',
|
|
212
|
+
tagline: 'A tagline that says what it is.', art_url: 'https://hidden-project.test/art.png' };
|
|
213
|
+
const r = render([leaky]);
|
|
214
|
+
await settle();
|
|
215
|
+
const seen = reachable(r.cards()[0]);
|
|
216
|
+
for (const secret of ['Hidden Project', 'hidden-project.test', 'A tagline', 'art.png']) {
|
|
217
|
+
assert.ok(!seen.includes(secret), `"${secret}" is dropped by the page too`);
|
|
218
|
+
}
|
|
219
|
+
assert.equal(classed(r.cards()[0], 'orb-black-hole').length, 1, 'it is still a black hole');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an identified card still shows its name, tagline, picture and address', async () => {
|
|
223
|
+
const r = render([publicRow]);
|
|
224
|
+
await settle();
|
|
225
|
+
const card = r.cards()[0];
|
|
226
|
+
assert.equal(card.tagName, 'A', 'a planet is somewhere you can go');
|
|
227
|
+
assert.equal(card.href, 'https://example-project.test/');
|
|
228
|
+
assert.ok(textOf(card).includes('Example Project'));
|
|
229
|
+
assert.ok(textOf(card).includes('A tagline that says what it is.'));
|
|
230
|
+
assert.equal(nodes(card).filter((n) => n.tagName === 'IMG')[0].src, 'https://example-project.test/art.png');
|
|
231
|
+
assert.ok(classed(card, 'origin')[0].textContent.includes('example-project.test'));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---- 3. dark matter never draws -------------------------------------------
|
|
235
|
+
|
|
236
|
+
test('a row this page cannot classify is not drawn', async () => {
|
|
237
|
+
// Dark matter is excluded in SQL and has no orb to draw; the same drop covers a
|
|
238
|
+
// fourth state added later and not taught to this page. Fail-closed: a card is
|
|
239
|
+
// never guessed at from a row the feed did not classify.
|
|
240
|
+
const unclassifiable = [
|
|
241
|
+
{ ...publicRow, state: 'dark-matter', orb: null },
|
|
242
|
+
{ ...publicRow, orb: 'quasar' },
|
|
243
|
+
{ ...publicRow, orb: undefined },
|
|
244
|
+
];
|
|
245
|
+
const r = render([...unclassifiable, publicRow]);
|
|
246
|
+
await settle();
|
|
247
|
+
assert.equal(r.cards().length, 1, 'only the one row with a known orb is drawn');
|
|
248
|
+
assert.equal(classed(r.cards()[0], 'orb-planet').length, 1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('a feed of nothing drawable shows the empty state, not an empty grid', async () => {
|
|
252
|
+
const r = render([{ ...publicRow, state: 'dark-matter', orb: null }]);
|
|
253
|
+
await settle();
|
|
254
|
+
assert.equal(r.empty().hidden, false, 'the visitor is told the map is empty');
|
|
255
|
+
assert.equal(r.grid().hidden, true, 'rather than shown a grid with nothing in it');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('an empty feed and a failed fetch both land on the empty state', async () => {
|
|
259
|
+
const none = render([]);
|
|
260
|
+
await settle();
|
|
261
|
+
assert.equal(none.empty().hidden, false);
|
|
262
|
+
|
|
263
|
+
const down = render([], { ok: false });
|
|
264
|
+
await settle();
|
|
265
|
+
assert.equal(down.empty().hidden, false, 'a non-hub instance 404s here — not an error to a visitor');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---- the page it draws into ------------------------------------------------
|
|
269
|
+
|
|
270
|
+
test('explore.html paints all three orbs and defines the void colour', () => {
|
|
271
|
+
for (const cls of ['.orb-planet', '.orb-star', '.orb-black-hole']) {
|
|
272
|
+
assert.ok(HTML.includes(`${cls} {`) || HTML.includes(`${cls},`) || HTML.includes(`${cls}:`),
|
|
273
|
+
`${cls} has a rule — a class name with no paint draws nothing`);
|
|
274
|
+
}
|
|
275
|
+
assert.match(HTML, /--orb-void:\s*#[0-9a-fA-F]{6}/,
|
|
276
|
+
'the hole has its own colour, in both schemes — the ink token inverts and would paint it white in dark mode');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the page reads the public feed and asks for JSON', async () => {
|
|
280
|
+
const r = render([]);
|
|
281
|
+
await settle();
|
|
282
|
+
assert.equal(r.requests.length, 1);
|
|
283
|
+
assert.equal(r.requests[0].url, '/api/gds/projects/featured');
|
|
284
|
+
assert.equal(r.requests[0].opts.headers.Accept, 'application/json');
|
|
285
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// tests/provisioning_callback_page.mjs — the pin for the extracted callback-page
|
|
2
|
+
// module (task 1003570).
|
|
3
|
+
//
|
|
4
|
+
// WHY. `esc` and `callbackPage` were carried inside modules/provisioning/routes/
|
|
5
|
+
// provisioning.js and were only ever exercised THROUGH the route
|
|
6
|
+
// (tests/provisioning_github_app_routes.mjs mounts the router and reads the served
|
|
7
|
+
// HTML). Once they became a module of their own they needed a pin of their own, or
|
|
8
|
+
// the extraction would be a surface nothing tests directly: this file requires
|
|
9
|
+
// callback-page.js and asserts the escaping contract and that both page variants
|
|
10
|
+
// carry the ESCAPED values, so a later edit that drops an esc() call — the XSS shape
|
|
11
|
+
// this helper exists to prevent — fails here rather than in a served page.
|
|
12
|
+
//
|
|
13
|
+
// DB-free and network-free: callback-page.js reaches core only for branding().
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test --test-reporter=tap tests/provisioning_callback_page.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
|
|
20
|
+
// ESM named imports, deliberately — knip credits a named CJS export to a
|
|
21
|
+
// destructured/named binding but not to a property read off a createRequire
|
|
22
|
+
// namespace, and these two exports have no other reader outside the route file.
|
|
23
|
+
import { esc, callbackPage } from '../modules/provisioning/routes/callback-page.js';
|
|
24
|
+
|
|
25
|
+
test('esc replaces every HTML-significant character, and nothing else', () => {
|
|
26
|
+
assert.equal(esc('&<>"\''), '&<>"'');
|
|
27
|
+
assert.equal(esc('<script>alert(1)</script>'), '<script>alert(1)</script>');
|
|
28
|
+
// repeats are all replaced, not just the first
|
|
29
|
+
assert.equal(esc('<<>>'), '<<>>');
|
|
30
|
+
// ordinary text passes through untouched
|
|
31
|
+
assert.equal(esc('mercury.example.com'), 'mercury.example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('esc renders an absent value as the empty string, never "null"/"undefined"', () => {
|
|
35
|
+
assert.equal(esc(null), '');
|
|
36
|
+
assert.equal(esc(undefined), '');
|
|
37
|
+
assert.equal(esc(0), '0');
|
|
38
|
+
assert.equal(esc(false), 'false');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the progress variant escapes the title + domain it interpolates', () => {
|
|
42
|
+
const html = callbackPage({
|
|
43
|
+
ok: true,
|
|
44
|
+
title: 'Setting up <your> instance',
|
|
45
|
+
state: 'GOODSTATE',
|
|
46
|
+
domain: 'evil".example.com',
|
|
47
|
+
});
|
|
48
|
+
assert.match(html, /<title>Setting up <your> instance<\/title>/);
|
|
49
|
+
assert.match(html, /evil"\.example\.com/, 'the domain rides through esc');
|
|
50
|
+
assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
|
|
51
|
+
assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
|
|
52
|
+
// it IS the progress variant: the stepper + the config block the client script reads
|
|
53
|
+
assert.match(html, /id="steps"/);
|
|
54
|
+
assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com"\}/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the message variant escapes the title but passes server-authored body HTML through', () => {
|
|
58
|
+
const html = callbackPage({
|
|
59
|
+
ok: false,
|
|
60
|
+
title: 'This setup link has <expired>',
|
|
61
|
+
body: '<p>Ask the owner for a fresh link.</p>',
|
|
62
|
+
});
|
|
63
|
+
assert.match(html, /<h1>This setup link has <expired><\/h1>/);
|
|
64
|
+
assert.match(html, /<div class="sub"><p>Ask the owner for a fresh link\.<\/p><\/div>/,
|
|
65
|
+
'body is trusted server-authored HTML — the route composes it, a request never does');
|
|
66
|
+
assert.doesNotMatch(html, /id="steps"/, 'no stepper without ok + state + domain');
|
|
67
|
+
assert.match(html, /badge-warn">⚠️</, 'a failure wears the warning badge');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ok without state or domain falls back to the message variant', () => {
|
|
71
|
+
const html = callbackPage({ ok: true, title: 'GitHub app created', body: '<p>Done.</p>' });
|
|
72
|
+
assert.doesNotMatch(html, /__CB_SETUP__/, 'nothing to poll for, so no client config block');
|
|
73
|
+
assert.match(html, /badge-warn">✓</, 'but it is still a success');
|
|
74
|
+
});
|
|
@@ -30,7 +30,7 @@ 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 } from '../modules/provisioning/starter-bundles.js';
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules } from '../modules/provisioning/starter-bundles.js';
|
|
34
34
|
|
|
35
35
|
const require = createRequire(import.meta.url);
|
|
36
36
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
@@ -152,9 +152,13 @@ t('publicInstance: a row written before provisioning_014 reads as the bundle, no
|
|
|
152
152
|
|
|
153
153
|
let lastCreateArgs = null;
|
|
154
154
|
let existingRow = null;
|
|
155
|
+
// The row PATCH …/detail reads, and the row its UPDATE returned. Both null by
|
|
156
|
+
// default, so the POST /instances tests below see the same "no such id" world.
|
|
157
|
+
let detailRow = null;
|
|
158
|
+
let lastUpdatedRow = null;
|
|
155
159
|
provisioning.getInstanceBySlug = async () => existingRow;
|
|
156
160
|
provisioning.getInstanceByDomain = async () => null;
|
|
157
|
-
provisioning.getInstanceById = async () =>
|
|
161
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
158
162
|
provisioning.createInstance = async (_db, args) => {
|
|
159
163
|
lastCreateArgs = args;
|
|
160
164
|
return existingRow
|
|
@@ -164,6 +168,18 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
164
168
|
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
165
169
|
provisioning.getOpenIntent = async () => null;
|
|
166
170
|
provisioning.recordEvent = async () => {};
|
|
171
|
+
// Mirrors the real statement: `detail = detail || $n::jsonb` MERGES the
|
|
172
|
+
// normalized answers onto what is already stored. The row the route then hands
|
|
173
|
+
// `effectiveModules` is that merged one, so a stub that returned the pre-patch
|
|
174
|
+
// row would hide exactly the staleness this test exists to catch.
|
|
175
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
176
|
+
lastUpdatedRow = { ...detailRow };
|
|
177
|
+
if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
|
|
178
|
+
if (patch.detail !== undefined) {
|
|
179
|
+
lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
|
|
180
|
+
}
|
|
181
|
+
return lastUpdatedRow;
|
|
182
|
+
};
|
|
167
183
|
|
|
168
184
|
const api = require('../src/module-api.js');
|
|
169
185
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
@@ -185,11 +201,11 @@ const server = app.listen(0);
|
|
|
185
201
|
await new Promise((r) => server.once('listening', r));
|
|
186
202
|
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
187
203
|
|
|
188
|
-
function
|
|
204
|
+
function send(method, path, body) {
|
|
189
205
|
return new Promise((resolve, reject) => {
|
|
190
206
|
const payload = JSON.stringify(body);
|
|
191
207
|
const req = http.request(`${base}${path}`, {
|
|
192
|
-
method
|
|
208
|
+
method,
|
|
193
209
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
194
210
|
}, (res) => {
|
|
195
211
|
let d = '';
|
|
@@ -200,6 +216,8 @@ function post(path, body) {
|
|
|
200
216
|
req.end(payload);
|
|
201
217
|
});
|
|
202
218
|
}
|
|
219
|
+
const post = (path, body) => send('POST', path, body);
|
|
220
|
+
const patch = (path, body) => send('PATCH', path, body);
|
|
203
221
|
|
|
204
222
|
await ta('POST /instances: the picker\'s ids ride the create request through to storage', async () => {
|
|
205
223
|
// Arrange
|
|
@@ -281,6 +299,48 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
281
299
|
existingRow = null;
|
|
282
300
|
});
|
|
283
301
|
|
|
302
|
+
// ── PATCH /instances/:id/detail, over the same mounted router ──────────────
|
|
303
|
+
// The interview's answer MOVES a bundle-sourced project's modules (BV1.R22, ADR
|
|
304
|
+
// 0243), so the reply that takes the answer says what the project now has rather
|
|
305
|
+
// than making the caller re-read. openapi.json names `modules` in this response's
|
|
306
|
+
// `required` list; nothing kept that promise until here — only the pure
|
|
307
|
+
// `effectiveModules` was pinned (tests/provisioning_starter_bundles.mjs).
|
|
308
|
+
|
|
309
|
+
await ta('PATCH /detail: the reply carries modules, resolved from the row the UPDATE returned', async () => {
|
|
310
|
+
// Arrange — a game project that skipped the picker, so its modules still
|
|
311
|
+
// resolve from the bundle and an interview answer can still move them.
|
|
312
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
313
|
+
lastUpdatedRow = null;
|
|
314
|
+
|
|
315
|
+
// Act — the owner says they are building it alone
|
|
316
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
|
|
317
|
+
|
|
318
|
+
// Assert — the field is there, and it is the POST-patch resolution: a route
|
|
319
|
+
// that answered off `inst` instead of `updated` would still say 'dev-box'.
|
|
320
|
+
assert.equal(res.status, 200, res.raw);
|
|
321
|
+
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
322
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
323
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'solo drops the shared dev box out of the game bundle');
|
|
324
|
+
assert.equal(res.body.modules.source, 'bundle');
|
|
325
|
+
assert.ok(res.body.modules.adjustments.some((a) => a.module === 'dev-box'), 'and the change carries its reason');
|
|
326
|
+
detailRow = null; lastUpdatedRow = null;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await ta('PATCH /detail: a description-only patch answers with modules too — the field is not conditional', async () => {
|
|
330
|
+
// openapi lists `modules` as REQUIRED, so every 200 off this route carries it,
|
|
331
|
+
// including the ones that moved nothing a bundle reads.
|
|
332
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: ['agents', 'discord'], detail: {} };
|
|
333
|
+
lastUpdatedRow = null;
|
|
334
|
+
|
|
335
|
+
const res = await patch('/provisioning/instances/41/detail', { description: 'A study of small things.' });
|
|
336
|
+
|
|
337
|
+
assert.equal(res.status, 200, res.raw);
|
|
338
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
339
|
+
assert.deepEqual(res.body.modules.selected, ['agents', 'discord'], 'a chosen set is not re-derived from the type');
|
|
340
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
341
|
+
detailRow = null; lastUpdatedRow = null;
|
|
342
|
+
});
|
|
343
|
+
|
|
284
344
|
server.close();
|
|
285
345
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
286
346
|
process.exit(failed === 0 ? 0 : 1);
|