@bongos/core 1.19.851 → 1.19.853
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 +41 -31
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +3 -1
- package/docs/adr/0305-a-recipe-lives-with-what-it-documents.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -16
- package/docs/copy-registry.json +26 -17
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/core-release-pipeline.md +11 -2
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/hall-ui/public/builders.js +65 -6
- package/modules/hall-ui/public/hall-kit.js +72 -6
- package/modules/hall-ui/public/hall-render.js +103 -11
- package/modules/hall-ui/public/style.css +20 -0
- package/modules/lifecycle/db-analytics.js +79 -20
- package/modules/lifecycle/routes/analytics.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo-auth.js +51 -8
- package/scripts/gds/update-channel.js +27 -7
- package/scripts/gds/upgrade.js +20 -7
- package/src/module-api.js +1 -1
- package/tests/curve_kind_overlay.mjs +298 -0
- package/tests/provision.mjs +46 -3
- package/tests/rank_gate.mjs +35 -7
- package/tests/update_channel.mjs +31 -0
- package/tests/upgrade.mjs +142 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// tests/curve_kind_overlay.mjs — the dev curve's kind filter + ecosystem overlay
|
|
2
|
+
// (task 1004005, split from task 1004004 part D).
|
|
3
|
+
//
|
|
4
|
+
// Three things are pinned here, and each one is a claim that can be made FALSELY
|
|
5
|
+
// while every chart still renders:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE OVERLAY SHARES A SCALE. Two lines each fitted to their own min/max
|
|
8
|
+
// are not a comparison — they are two unrelated shapes that look like one.
|
|
9
|
+
// A chart that does this is worse than no benchmark, because the reader
|
|
10
|
+
// believes they are reading one.
|
|
11
|
+
// 2. THE SINGLE-SERIES PATH IS UNTOUCHED. modules/hall-ui/CLAUDE.md records
|
|
12
|
+
// one-series-per-chart as a deliberate decision, and every other chart in
|
|
13
|
+
// the hall shares this kit. The overlay is an addition, so a caller that
|
|
14
|
+
// passes no overlay must get back exactly the bytes it got before.
|
|
15
|
+
// 3. BOTH SIDES OF A COMPARISON ARE NARROWED THE SAME WAY. A kind-filtered
|
|
16
|
+
// line against an unfiltered benchmark is the same lie as (1), one layer
|
|
17
|
+
// up — so the ecosystem query takes the same `kind`, and cost, which
|
|
18
|
+
// CANNOT be narrowed by kind, is withheld rather than faked.
|
|
19
|
+
//
|
|
20
|
+
// The kit is a browser IIFE on `window`, so it is evaluated in a vm with a
|
|
21
|
+
// window stub — the hall_kit / hall_landing_boot pattern.
|
|
22
|
+
|
|
23
|
+
import test from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
|
|
32
|
+
function loadKit() {
|
|
33
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-kit.js'), 'utf8');
|
|
34
|
+
const win = {};
|
|
35
|
+
const ctx = vm.createContext({ window: win, document: { createElement: () => ({}) }, console });
|
|
36
|
+
vm.runInContext(src, ctx);
|
|
37
|
+
assert.ok(win.OTBKit, 'hall-kit.js did not publish window.OTBKit');
|
|
38
|
+
return win.OTBKit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const KIT = loadKit();
|
|
42
|
+
|
|
43
|
+
// Pull the y of each point out of a `points="x,y x,y"` polyline.
|
|
44
|
+
function ysOf(svg, cls) {
|
|
45
|
+
const m = new RegExp(`class="${cls}" points="([^"]+)"`).exec(svg);
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
return m[1].trim().split(/\s+/).map((p) => Number(p.split(',')[1]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 1. The shared scale ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
test('an overlaid chart fits BOTH series to one scale', () => {
|
|
53
|
+
// Own series 0..10, benchmark 0..100. On a shared scale the own line must sit
|
|
54
|
+
// in the BOTTOM tenth of the box; on two independent scales it would fill the
|
|
55
|
+
// box exactly as if the benchmark were not there, and the reader would see
|
|
56
|
+
// "level with everyone" where the truth is "a tenth of everyone".
|
|
57
|
+
const own = [0, 5, 10];
|
|
58
|
+
const eco = [0, 50, 100];
|
|
59
|
+
const solo = KIT.sparklineHtml(own, { color: 'var(--accent)' });
|
|
60
|
+
const both = KIT.sparklineHtml(own, { color: 'var(--accent)', overlay: eco });
|
|
61
|
+
|
|
62
|
+
const soloY = ysOf(solo, 'kit-spark__line');
|
|
63
|
+
const bothY = ysOf(both, 'kit-spark__line');
|
|
64
|
+
const ecoY = ysOf(both, 'kit-spark__overlay');
|
|
65
|
+
|
|
66
|
+
assert.ok(soloY && bothY && ecoY, 'expected an own line and an overlay line');
|
|
67
|
+
assert.notDeepEqual(bothY, soloY,
|
|
68
|
+
'the own series was drawn identically with and without the benchmark — the two series are NOT sharing a scale');
|
|
69
|
+
|
|
70
|
+
// SVG y grows downward, so a bigger y is a lower value. The own series' top
|
|
71
|
+
// must sit BELOW the benchmark's top by most of the box.
|
|
72
|
+
const ownTop = Math.min(...bothY);
|
|
73
|
+
const ecoTop = Math.min(...ecoY);
|
|
74
|
+
const ownBottom = Math.max(...bothY);
|
|
75
|
+
assert.ok(ownTop > ecoTop, 'the smaller series must not reach as high as the larger one');
|
|
76
|
+
// Own spans 10 of a 0..100 range, so its drawn height is ~1/10 of the overlay's.
|
|
77
|
+
const ownHeight = ownBottom - ownTop;
|
|
78
|
+
const ecoHeight = Math.max(...ecoY) - ecoTop;
|
|
79
|
+
assert.ok(ownHeight < ecoHeight * 0.25,
|
|
80
|
+
`own series drew ${ownHeight.toFixed(1)}px against the benchmark's ${ecoHeight.toFixed(1)}px — expected roughly a tenth`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the scale is shared SYMMETRICALLY — an own series larger than the benchmark still fits', () => {
|
|
84
|
+
// The mirror of the case above. If only the overlay were folded into the
|
|
85
|
+
// scale, a builder shipping more than the average would run off the top of
|
|
86
|
+
// the box and be silently clipped at exactly the moment the chart has good
|
|
87
|
+
// news to deliver.
|
|
88
|
+
const svg = KIT.sparklineHtml([0, 50, 100], { overlay: [0, 5, 10] });
|
|
89
|
+
const ys = ysOf(svg, 'kit-spark__line');
|
|
90
|
+
const H = 46; // CHART_H
|
|
91
|
+
assert.ok(Math.min(...ys) >= 0 && Math.max(...ys) <= H,
|
|
92
|
+
`own series left the viewBox: ${JSON.stringify(ys)}`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('two series of DIFFERENT length each span the full width', () => {
|
|
96
|
+
// The builder's window and the ecosystem's need not have the same number of
|
|
97
|
+
// days with data. Each series gets its own x spread over the same box, so a
|
|
98
|
+
// sparser one is stretched rather than crammed into a corner.
|
|
99
|
+
const svg = KIT.sparklineHtml([1, 2, 3, 4], { overlay: [1, 4] });
|
|
100
|
+
const over = /class="kit-spark__overlay" points="([^"]+)"/.exec(svg)[1].trim().split(/\s+/);
|
|
101
|
+
assert.equal(over.length, 2);
|
|
102
|
+
const xs = over.map((p) => Number(p.split(',')[0]));
|
|
103
|
+
assert.ok(xs[0] < 10 && xs[1] > 250, `overlay did not span the box: ${JSON.stringify(xs)}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── 2. The single-series path is untouched ───────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test('no overlay means byte-identical output — the one-series rule still holds', () => {
|
|
109
|
+
for (const values of [[1, 2, 3], [7], [], [0, 0, 0], [5, -3, 9]]) {
|
|
110
|
+
const a = KIT.sparklineHtml(values, { color: 'var(--sea)' });
|
|
111
|
+
const b = KIT.sparklineHtml(values, { color: 'var(--sea)', overlay: undefined });
|
|
112
|
+
assert.equal(a, b, `overlay:undefined changed the output for ${JSON.stringify(values)}`);
|
|
113
|
+
assert.ok(!a.includes('kit-spark__overlay'), 'a single-series chart drew an overlay element');
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('an EMPTY overlay is not an overlay', () => {
|
|
118
|
+
// A builder whose ecosystem feed failed, or a kind nobody else has shipped,
|
|
119
|
+
// must get the ordinary one-line chart — not a chart claiming a benchmark it
|
|
120
|
+
// has no data for.
|
|
121
|
+
const plain = KIT.sparklineHtml([1, 2, 3]);
|
|
122
|
+
assert.equal(KIT.sparklineHtml([1, 2, 3], { overlay: [] }), plain);
|
|
123
|
+
assert.equal(KIT.sparklineHtml([1, 2, 3], { overlay: null }), plain);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('linePoints without scaleWith is unchanged', () => {
|
|
127
|
+
const a = KIT.linePoints([3, 1, 4, 1, 5]);
|
|
128
|
+
const b = KIT.linePoints([3, 1, 4, 1, 5], { scaleWith: undefined });
|
|
129
|
+
assert.equal(a.points, b.points);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('the benchmark is drawn UNDER the subject, and carries no area fill', () => {
|
|
133
|
+
// Paint order is the encoding: the subject's own line must win where they
|
|
134
|
+
// cross. And a second filled area would muddy both until neither reads as the
|
|
135
|
+
// figure.
|
|
136
|
+
const svg = KIT.sparklineHtml([1, 5, 2], { overlay: [2, 3, 4] });
|
|
137
|
+
const iOverlay = svg.indexOf('kit-spark__overlay');
|
|
138
|
+
const iArea = svg.indexOf('kit-spark__area');
|
|
139
|
+
const iLine = svg.indexOf('kit-spark__line');
|
|
140
|
+
assert.ok(iOverlay !== -1 && iOverlay < iArea && iOverlay < iLine,
|
|
141
|
+
'the benchmark must be emitted before the subject so it renders beneath it');
|
|
142
|
+
assert.equal((svg.match(/kit-spark__area/g) || []).length, 1, 'only the subject may have an area fill');
|
|
143
|
+
assert.match(svg, /class="kit-spark__overlay"[^>]*fill="none"/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an overlaid chart says so in its accessible label', () => {
|
|
147
|
+
// A screen-reader user gets the aria-label and nothing else; without the
|
|
148
|
+
// benchmark named there, the second line simply does not exist for them.
|
|
149
|
+
const svg = KIT.sparklineHtml([1, 2, 3], { overlay: [4, 5, 6], overlayLabel: 'all builders' });
|
|
150
|
+
const label = /aria-label="([^"]+)"/.exec(svg)[1];
|
|
151
|
+
assert.match(label, /all builders/);
|
|
152
|
+
assert.match(label, /latest 3/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── The legend ───────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
test('a legend is drawn for two series and withheld for fewer', () => {
|
|
158
|
+
assert.equal(KIT.chartLegendHtml([{ label: 'You', color: 'var(--accent)' }]), '',
|
|
159
|
+
'one series needs no key — that is why the hall had no legend before the overlay');
|
|
160
|
+
assert.equal(KIT.chartLegendHtml([]), '');
|
|
161
|
+
const two = KIT.chartLegendHtml([
|
|
162
|
+
{ label: 'You', color: 'var(--accent)' },
|
|
163
|
+
{ label: 'All builders', color: 'var(--ink-soft)' },
|
|
164
|
+
]);
|
|
165
|
+
assert.match(two, /You/);
|
|
166
|
+
assert.match(two, /All builders/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the legend swatch survives a var() colour', () => {
|
|
170
|
+
// The regression this shape exists to prevent: CSS_LEN in the kit REFUSES
|
|
171
|
+
// `var(`, so routing a chart colour through a style attribute would blank
|
|
172
|
+
// every swatch and leave a legend of unlabelled colours. An SVG fill attribute
|
|
173
|
+
// is the path the kit already uses for exactly these values.
|
|
174
|
+
const html = KIT.chartLegendHtml([
|
|
175
|
+
{ label: 'You', color: 'var(--accent-deep)' },
|
|
176
|
+
{ label: 'All builders', color: 'var(--ink-soft)' },
|
|
177
|
+
]);
|
|
178
|
+
assert.match(html, /fill="var\(--accent-deep\)"/, 'the swatch colour was dropped');
|
|
179
|
+
assert.match(html, /fill="var\(--ink-soft\)"/);
|
|
180
|
+
assert.ok(!html.includes('style="background:"'), 'an emptied style attribute means the colour was refused');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a legend label is escaped, not interpolated raw', () => {
|
|
184
|
+
const html = KIT.chartLegendHtml([
|
|
185
|
+
{ label: '<img src=x onerror=alert(1)>', color: 'var(--accent)' },
|
|
186
|
+
{ label: 'All builders', color: 'var(--ink-soft)' },
|
|
187
|
+
]);
|
|
188
|
+
assert.ok(!html.includes('<img'), 'a legend label reached the DOM as markup');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── 3. Both sides narrowed the same way (the server contract) ────────────────
|
|
192
|
+
|
|
193
|
+
const ANALYTICS = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-analytics.js'), 'utf8');
|
|
194
|
+
const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/analytics.js'), 'utf8');
|
|
195
|
+
|
|
196
|
+
function fnBody(src, name) {
|
|
197
|
+
const start = src.indexOf(`async function ${name}(`);
|
|
198
|
+
assert.notEqual(start, -1, `${name} not found`);
|
|
199
|
+
const end = src.indexOf('\n}', src.indexOf(' return {', start));
|
|
200
|
+
return src.slice(start, end);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
test('BOTH analytics functions take a kind — a one-sided filter is the lie this prevents', () => {
|
|
204
|
+
assert.match(ANALYTICS, /async function builderAnalytics\(builderId, days = 90, kind = null\)/);
|
|
205
|
+
assert.match(ANALYTICS, /async function ecosystemAnalytics\(days = 90, kind = null\)/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('both routes pass the caller\'s kind through', () => {
|
|
209
|
+
assert.match(ROUTES, /db\.builderAnalytics\(targetId, days, parseKind\(req\.query\.kind\)\)/);
|
|
210
|
+
assert.match(ROUTES, /db\.ecosystemAnalytics\(days, parseKind\(req\.query\.kind\)\)/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('the kind is a BOUND parameter everywhere, never interpolated into SQL', () => {
|
|
214
|
+
// The filter reaches a GROUP BY on two hot tables; a template-literal kind
|
|
215
|
+
// would be an injection point on a route that is, on the ecosystem side,
|
|
216
|
+
// unauthenticated.
|
|
217
|
+
for (const [name, body] of [['builderAnalytics', fnBody(ANALYTICS, 'builderAnalytics')],
|
|
218
|
+
['ecosystemAnalytics', fnBody(ANALYTICS, 'ecosystemAnalytics')]]) {
|
|
219
|
+
assert.ok(!/COALESCE\(kind, 'unspecified'\) = \$\{/.test(body),
|
|
220
|
+
`${name} interpolates the kind into SQL`);
|
|
221
|
+
assert.match(body, /\$\d::text IS NULL OR COALESCE\(kind, 'unspecified'\) = \$\d/,
|
|
222
|
+
`${name} has no bound kind predicate`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('top_kinds is NEVER narrowed — it is the picker\'s own source', () => {
|
|
227
|
+
// Narrowing it would leave the reader one bar and no way back to the others:
|
|
228
|
+
// the control that caused the filter would lose the options to undo it.
|
|
229
|
+
for (const name of ['builderAnalytics', 'ecosystemAnalytics']) {
|
|
230
|
+
const body = fnBody(ANALYTICS, name);
|
|
231
|
+
const q = body.indexOf("COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n");
|
|
232
|
+
assert.notEqual(q, -1, `${name} has no top_kinds query`);
|
|
233
|
+
// The params array immediately after that query must not carry the kind.
|
|
234
|
+
const tail = body.slice(q, q + 700);
|
|
235
|
+
const params = /\n\s*\[([^\]]*)\]/.exec(tail);
|
|
236
|
+
assert.ok(params, `could not read top_kinds params in ${name}`);
|
|
237
|
+
assert.ok(!/\bk\b/.test(params[1]),
|
|
238
|
+
`${name}'s top_kinds is filtered by kind (params: [${params[1]}]) — the picker would lose its options`);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('cost is WITHHELD under a kind filter, not faked', () => {
|
|
243
|
+
// cost_log has no kind and reaches tasks only through a nullable task_id that
|
|
244
|
+
// is NULL for most rows, so a kind-filtered cost line would drop nearly every
|
|
245
|
+
// cost row and plot a near-zero trend that reads like genuinely cheap work.
|
|
246
|
+
const body = fnBody(ANALYTICS, 'builderAnalytics');
|
|
247
|
+
assert.match(body, /k !== null \? \{ rows: \[\] \} : pool\.query\(/,
|
|
248
|
+
'the cost query must be skipped under a kind filter, not run and discarded');
|
|
249
|
+
assert.match(body, /unavailable: k === null \? \[\] : \['cost_trend', 'cost_per_task', 'total_cost_usd'\]/,
|
|
250
|
+
'the payload must NAME what it cannot answer');
|
|
251
|
+
assert.match(body, /total_cost_usd: k === null \?[^\n]*: null/,
|
|
252
|
+
'cost must be null under a filter — 0 is a measurement, this is the absence of one');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('the client hides the cost chart and stat rather than printing $0.00', () => {
|
|
256
|
+
const render = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
|
|
257
|
+
assert.match(render, /costUnavailable = Array\.isArray\(data\.unavailable\) && data\.unavailable\.includes\('cost_trend'\)/);
|
|
258
|
+
assert.match(render, /costUnavailable\s*\?\s*statCard\('Cost \/ work', '—'/,
|
|
259
|
+
'a withheld cost must render as —, never as a price');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('the controls are written BEFORE every early return', () => {
|
|
263
|
+
// A kind with nothing shipped in the window takes the totalShipped === 0
|
|
264
|
+
// branch, which blanks the body. If the filter were drawn after that, the
|
|
265
|
+
// reader would be stranded at "no data" with no control to undo it.
|
|
266
|
+
const render = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
|
|
267
|
+
const fn = render.slice(render.indexOf('function renderCurveBody()'));
|
|
268
|
+
const controls = fn.indexOf('kindFilterHtml()');
|
|
269
|
+
const firstReturn = fn.indexOf('if (!data) {');
|
|
270
|
+
const emptyReturn = fn.indexOf('if (totalShipped === 0)');
|
|
271
|
+
assert.ok(controls !== -1 && firstReturn !== -1 && emptyReturn !== -1);
|
|
272
|
+
assert.ok(controls < firstReturn && controls < emptyReturn,
|
|
273
|
+
'the kind filter is rendered after an early return — an empty result would strand the reader behind it');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the scope toggle is no longer rank-gated', () => {
|
|
277
|
+
const render = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
|
|
278
|
+
const builders = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/builders.js'), 'utf8');
|
|
279
|
+
assert.ok(!/analyticsState\.isArchon\s*&&/.test(render),
|
|
280
|
+
'the scope toggle is still gated on rank');
|
|
281
|
+
// The field is REMOVED, not merely unread — a write-only state flag is how a
|
|
282
|
+
// deleted gate quietly grows back.
|
|
283
|
+
assert.ok(!/analyticsState\.isArchon\s*=/.test(builders),
|
|
284
|
+
'analyticsState.isArchon is still assigned; remove the field, not just its reader');
|
|
285
|
+
assert.ok(!/if \(analyticsState\.isArchon\) \{/.test(builders),
|
|
286
|
+
'the ecosystem fetch is still behind the Archon gate');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('a kind change re-fetches BOTH series, and the newest answer wins', () => {
|
|
290
|
+
const builders = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/builders.js'), 'utf8');
|
|
291
|
+
const fn = builders.slice(builders.indexOf('async function reloadCurveForKind'));
|
|
292
|
+
const body = fn.slice(0, fn.indexOf('\n }') + 4);
|
|
293
|
+
assert.match(body, /analytics\/builder\/\$\{id\}\?days=90\$\{q\}/);
|
|
294
|
+
assert.match(body, /public\/analytics\/ecosystem\?days=90\$\{q\}/);
|
|
295
|
+
// Out-of-order responses: a dropdown changes faster than a round trip.
|
|
296
|
+
assert.match(body, /seq !== curveKindSeq/, 'a stale response can overwrite a newer one');
|
|
297
|
+
assert.match(body, /encodeURIComponent\(kind\)/, 'the kind must be encoded into the query string');
|
|
298
|
+
});
|
package/tests/provision.mjs
CHANGED
|
@@ -28,6 +28,12 @@ const NEUTRAL_PREFIX =
|
|
|
28
28
|
JSON.parse(readFileSync(new URL('../config/branding.neutral.json', import.meta.url), 'utf8')).envPrefix || 'CLOUDBONGOS';
|
|
29
29
|
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
30
|
|
|
31
|
+
// The scaffold-composition assertions below pin the remote as `git@github-deploy:…`.
|
|
32
|
+
// Since task 1003996 that alias is the default only where it actually RESOLVES, so pin
|
|
33
|
+
// it here rather than inheriting whatever ~/.ssh/config the test machine happens to have
|
|
34
|
+
// (the resolution rule itself is covered directly, with an injected probe).
|
|
35
|
+
process.env.PROVISION_GIT_SSH_ALIAS = 'github-deploy';
|
|
36
|
+
|
|
31
37
|
let passed = 0, failed = 0;
|
|
32
38
|
function t(name, fn) {
|
|
33
39
|
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
@@ -528,9 +534,46 @@ t('parseTargetRef: owner/repo, SSH + HTTPS URLs, .git suffix; garbage → null',
|
|
|
528
534
|
assert.equal(P.parseTargetRef(null), null);
|
|
529
535
|
});
|
|
530
536
|
|
|
531
|
-
t('instanceRepoRemote:
|
|
532
|
-
|
|
533
|
-
|
|
537
|
+
t('instanceRepoRemote: the deploy alias only where it RESOLVES, else github.com directly', () => {
|
|
538
|
+
// `ssh -G <name>` prints the effective config: a real `Host` block rewrites `hostname`,
|
|
539
|
+
// an absent one echoes the name straight back. That difference is the whole probe.
|
|
540
|
+
const wired = () => 'user git\nhostname github.com\nport 22\n';
|
|
541
|
+
const absent = (a) => `user x\nhostname ${a}\nport 22\n`;
|
|
542
|
+
const saved = process.env.PROVISION_GIT_SSH_ALIAS;
|
|
543
|
+
delete process.env.PROVISION_GIT_SSH_ALIAS;
|
|
544
|
+
try {
|
|
545
|
+
assert.equal(
|
|
546
|
+
P.instanceRepoRemote({ owner: 'example-owner', name: 'demo' }, { runSsh: wired }),
|
|
547
|
+
'git@github-deploy:example-owner/demo.git', 'founder box: alias resolves, transport unchanged');
|
|
548
|
+
assert.equal(
|
|
549
|
+
P.instanceRepoRemote({ owner: 'example-owner', name: 'demo' }, { runSsh: absent }),
|
|
550
|
+
'git@github.com:example-owner/demo.git', 'any other plane: no alias, so address GitHub directly');
|
|
551
|
+
assert.equal(
|
|
552
|
+
P.instanceRepoRemote({ owner: 'o', name: 'r' }, { runSsh: () => { throw new Error('ssh: not found'); } }),
|
|
553
|
+
'git@github.com:o/r.git', 'no ssh client at all is not an alias either');
|
|
554
|
+
process.env.PROVISION_GIT_SSH_ALIAS = 'gh-env';
|
|
555
|
+
assert.equal(P.instanceRepoRemote({ owner: 'o', name: 'r' }, { runSsh: absent }),
|
|
556
|
+
'git@gh-env:o/r.git', 'PROVISION_GIT_SSH_ALIAS wins over the probe');
|
|
557
|
+
assert.equal(P.instanceRepoRemote({ owner: 'o', name: 'r' }, { sshAlias: 'gh-alt', runSsh: absent }),
|
|
558
|
+
'git@gh-alt:o/r.git', 'an explicit alias wins over both');
|
|
559
|
+
} finally {
|
|
560
|
+
if (saved === undefined) delete process.env.PROVISION_GIT_SSH_ALIAS;
|
|
561
|
+
else process.env.PROVISION_GIT_SSH_ALIAS = saved;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
t('instanceRepoRemotes: the accept-set a stored remote is validated against, probe-free', () => {
|
|
566
|
+
const { instanceRepoRemotes } = createRequire(import.meta.url)('../scripts/gds/provision-repo-auth.js');
|
|
567
|
+
const urls = instanceRepoRemotes({ owner: 'o', name: 'r' });
|
|
568
|
+
// github-deploy is in the set even here, where it does NOT resolve: upgrade.js must keep
|
|
569
|
+
// recognising a remote an alias-carrying box wrote.
|
|
570
|
+
assert.ok(urls.includes('git@github-deploy:o/r.git'), 'the deploy-alias form stays ours');
|
|
571
|
+
assert.ok(urls.includes('git@github.com:o/r.git'), 'so does the direct form');
|
|
572
|
+
const saved = process.env.PROVISION_GIT_SSH_ALIAS;
|
|
573
|
+
process.env.PROVISION_GIT_SSH_ALIAS = 'gh-env';
|
|
574
|
+
try {
|
|
575
|
+
assert.ok(instanceRepoRemotes({ owner: 'o', name: 'r' }).includes('git@gh-env:o/r.git'), 'and the configured one');
|
|
576
|
+
} finally { process.env.PROVISION_GIT_SSH_ALIAS = saved; }
|
|
534
577
|
});
|
|
535
578
|
|
|
536
579
|
t('instanceInitSpec: builds a VALID greenfield init spec from the row + owner login', () => {
|
package/tests/rank_gate.mjs
CHANGED
|
@@ -323,12 +323,41 @@ const __dir = dirname(fileURLToPath(import.meta.url));
|
|
|
323
323
|
// task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
|
|
324
324
|
const dbSrc = lifecycleDbSource();
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
// Isolate the listClaimableTasks function body so we don't match CASE expressions
|
|
327
|
+
// elsewhere in the lifecycle data layer.
|
|
328
|
+
//
|
|
329
|
+
// ANCHORED TO THE NEXT TOP-LEVEL FUNCTION, not to a byte count (task 1004015; the
|
|
330
|
+
// same fix task 1004002 landed in tests/reward_gate.mjs). The window used to be
|
|
331
|
+
// `start + 4000`, and listClaimableTasks measures 12019 chars — so the slice carried
|
|
332
|
+
// a THIRD of the function it claims to scan.
|
|
333
|
+
//
|
|
334
|
+
// That is a false RED for the positive assertion below (the last WHEN clause it needs
|
|
335
|
+
// sits at +3400, leaving 600 characters of margin before an unrelated feature pushes
|
|
336
|
+
// it out of frame — reward_gate was widened four times for exactly that). It is a
|
|
337
|
+
// false GREEN for the NEGATIVE assertions in the next test, which is the worse half:
|
|
338
|
+
// a stale pre-thetes mapping living anywhere past +4000 — two thirds of the body —
|
|
339
|
+
// passes a test whose entire purpose is to catch it.
|
|
340
|
+
//
|
|
341
|
+
// The boundary lookup is ASSERTED rather than defaulted: if listActiveClaims is ever
|
|
342
|
+
// renamed or moved, this fails loudly and names the fix, instead of silently falling
|
|
343
|
+
// back to a byte window and reinstating the bug.
|
|
344
|
+
// LAZY, and called from inside each t() — deliberately not an IIFE evaluated at module
|
|
345
|
+
// load. Every assertion in this file lives inside a t() closure so a failure is caught,
|
|
346
|
+
// named and counted while the other ~30 tests below still run. An assert at top level
|
|
347
|
+
// would throw at IMPORT time in exactly the case it guards (listActiveClaims renamed),
|
|
348
|
+
// aborting the file before any downstream test registers — trading a false green for a
|
|
349
|
+
// silent loss of the rest of the suite.
|
|
350
|
+
const claimableBody = () => {
|
|
329
351
|
const start = dbSrc.indexOf('async function listClaimableTasks');
|
|
330
|
-
assert.ok(start >= 0, 'listClaimableTasks not found in
|
|
331
|
-
const
|
|
352
|
+
assert.ok(start >= 0, 'listClaimableTasks not found in the lifecycle data layer');
|
|
353
|
+
const end = dbSrc.indexOf('async function listActiveClaims', start);
|
|
354
|
+
assert.ok(end > start,
|
|
355
|
+
'listActiveClaims boundary not found — re-anchor this slice to whatever function now follows listClaimableTasks');
|
|
356
|
+
return dbSrc.slice(start, end);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
t('every live rank appears in the claimable CASE at its REQUIRES_RANK_TIER value', () => {
|
|
360
|
+
const body = claimableBody();
|
|
332
361
|
for (const [rank, tier] of Object.entries(db.REQUIRES_RANK_TIER)) {
|
|
333
362
|
const re = new RegExp(`WHEN '${rank}' THEN ${tier}\\b`);
|
|
334
363
|
assert.ok(re.test(body),
|
|
@@ -338,8 +367,7 @@ t('every live rank appears in the claimable CASE at its REQUIRES_RANK_TIER value
|
|
|
338
367
|
});
|
|
339
368
|
|
|
340
369
|
t('the claimable CASE no longer carries the pre-thetes archon=3/metic=2 scale', () => {
|
|
341
|
-
const
|
|
342
|
-
const body = dbSrc.slice(start, start + 4000);
|
|
370
|
+
const body = claimableBody();
|
|
343
371
|
assert.ok(!/WHEN 'archon' THEN 3\b/.test(body),
|
|
344
372
|
"stale archon=3 mapping present — CASE not migrated to the 4-rank scale");
|
|
345
373
|
assert.ok(!/WHEN 'metic' THEN 2\b/.test(body),
|
package/tests/update_channel.mjs
CHANGED
|
@@ -48,6 +48,24 @@ t('compareSemver: numeric precedence + prerelease below stable', () => {
|
|
|
48
48
|
assert.equal(uc.compareSemver('bogus', '1.0.0'), -1); // unparseable sorts lowest
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
// task 1004009: `bongos upgrade` now asks "is --to OLDER than what is installed?", so two
|
|
52
|
+
// orderings this module never needed for auto-targeting have a caller that depends on them.
|
|
53
|
+
t('compareSemver: two prereleases of one x.y.z are ordered by semver precedence', () => {
|
|
54
|
+
assert.equal(uc.compareSemver('1.20.0-rc.1', '1.20.0-rc.2'), -1);
|
|
55
|
+
assert.equal(uc.compareSemver('1.20.0-rc.10', '1.20.0-rc.2'), 1, 'numeric identifiers compare as numbers, not text');
|
|
56
|
+
assert.equal(uc.compareSemver('1.20.0-rc.1', '1.20.0-rc'), 1, 'the longer identifier list wins a tie');
|
|
57
|
+
assert.equal(uc.compareSemver('1.20.0-1', '1.20.0-alpha'), -1, 'a numeric identifier sorts below a text one');
|
|
58
|
+
assert.equal(uc.compareSemver('1.20.0-beta', '1.20.0-alpha'), 1);
|
|
59
|
+
assert.equal(uc.compareSemver('1.20.0-rc.1', '1.20.0-rc.1'), 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t('parseSemver: build metadata parses and never changes precedence', () => {
|
|
63
|
+
assert.deepEqual(uc.parseSemver('1.19.840+hotfix'), { major: 1, minor: 19, patch: 840, pre: '' });
|
|
64
|
+
assert.deepEqual(uc.parseSemver('1.19.840-rc.1+b7'), { major: 1, minor: 19, patch: 840, pre: 'rc.1' });
|
|
65
|
+
assert.equal(uc.compareSemver('1.19.840+hotfix', '1.19.840'), 0);
|
|
66
|
+
assert.equal(uc.compareSemver('1.19.840+hotfix', '1.19.841'), -1);
|
|
67
|
+
});
|
|
68
|
+
|
|
51
69
|
t('normalizeChannel: valid pass through, unknown/blank → patch default', () => {
|
|
52
70
|
assert.equal(uc.normalizeChannel('pinned'), 'pinned');
|
|
53
71
|
assert.equal(uc.normalizeChannel('MINOR'), 'minor');
|
|
@@ -133,6 +151,19 @@ t('listAvailableVersions: ok path parses stdout; non-zero exit is fail-soft', ()
|
|
|
133
151
|
assert.match(threw.error, /ENOENT/);
|
|
134
152
|
});
|
|
135
153
|
|
|
154
|
+
t('listAvailableVersions: the registry read carries a timeout, so a hung npm cannot stall a sweep or a refusal', () => {
|
|
155
|
+
const seen = [];
|
|
156
|
+
const run = (cmd, args, o) => { seen.push(o.timeout); return { status: 0, stdout: '[]' }; };
|
|
157
|
+
uc.listAvailableVersions({ run });
|
|
158
|
+
uc.listAvailableVersions({ run, timeoutMs: 1234 });
|
|
159
|
+
assert.ok(seen[0] > 0, 'a default, not an opt-in — every existing caller gets it');
|
|
160
|
+
assert.equal(seen[1], 1234);
|
|
161
|
+
// spawnSync reports a timeout as { error, status: null } — the fail-soft path, not a throw.
|
|
162
|
+
const hung = uc.listAvailableVersions({ run: () => ({ status: null, error: new Error('spawnSync npm ETIMEDOUT') }) });
|
|
163
|
+
assert.equal(hung.ok, false);
|
|
164
|
+
assert.match(hung.error, /ETIMEDOUT/);
|
|
165
|
+
});
|
|
166
|
+
|
|
136
167
|
// ---- loadSubscriptions -----------------------------------------------------
|
|
137
168
|
|
|
138
169
|
function scratchWith(obj) {
|
package/tests/upgrade.mjs
CHANGED
|
@@ -1557,7 +1557,9 @@ t('pinnedDeployKey / remoteGithubRepo: read the ADR 0176 wiring off the checkout
|
|
|
1557
1557
|
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://github.com/acme/widgets')), acme);
|
|
1558
1558
|
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://x-access-token@github.com/acme/widgets.git')), acme);
|
|
1559
1559
|
const P = require('../scripts/gds/provision.js');
|
|
1560
|
-
|
|
1560
|
+
// Pin the alias: since task 1003996 it is the scaffold's default only on a box where it
|
|
1561
|
+
// resolves, and this assertion is about the ALIAS branch being recognised as GitHub.
|
|
1562
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at(P.instanceRepoRemote(acme, { sshAlias: 'github-deploy' }))), acme, 'the shared SSH alias the public scaffold wires is GitHub too');
|
|
1561
1563
|
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://gitlab.com/acme/widgets.git')), null, 'another host is never pointed at github.com');
|
|
1562
1564
|
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@bitbucket.org:acme/widgets.git')), null);
|
|
1563
1565
|
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', pinRun()), null, 'no such remote');
|
|
@@ -1714,6 +1716,145 @@ t('runUpgrade --commit-pin --pin-no-push: a generator that did not run contribut
|
|
|
1714
1716
|
assert.ok(!calls.some((c) => c.startsWith('git push')), '--pin-no-push');
|
|
1715
1717
|
});
|
|
1716
1718
|
|
|
1719
|
+
// ---- 11. a --to OLDER than the installed core is refused (task 1004009) ------
|
|
1720
|
+
|
|
1721
|
+
// A registry that answers `npm view … versions --json`, on top of the pin-aware shim.
|
|
1722
|
+
function registryRun(calls, { dir, published = null, registryDown = false } = {}) {
|
|
1723
|
+
const base = pinAwareRun(calls, { dir });
|
|
1724
|
+
return (cmd, args, o) => {
|
|
1725
|
+
if (cmd === 'npm' && args[0] === 'view') {
|
|
1726
|
+
calls.push(`npm ${args.join(' ')}`);
|
|
1727
|
+
assert.ok(o.timeout > 0, 'the registry read only words a refusal — it must not be able to hang one');
|
|
1728
|
+
return registryDown ? { status: 1, stderr: 'npm error code E404' } : { status: 0, stdout: JSON.stringify(published || []) };
|
|
1729
|
+
}
|
|
1730
|
+
return base(cmd, args, o);
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
t('downgrade guard: an older --to refuses BEFORE the pin moves, in one line naming both versions + the newest published', async () => {
|
|
1735
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1736
|
+
const before = readFileSync(join(dir, 'package.json'), 'utf8');
|
|
1737
|
+
const calls = [];
|
|
1738
|
+
const res = await u.runUpgrade(
|
|
1739
|
+
{ to: '1.19.840', registry: true, instance: dir, commitPin: true, restartCmd: 'true', healthUrl: 'http://x/healthz' },
|
|
1740
|
+
{ log: () => {}, err: () => {}, run: registryRun(calls, { dir, published: ['1.19.840', '1.19.841', '1.19.842', '1.20.0-rc.1'] }) },
|
|
1741
|
+
);
|
|
1742
|
+
assert.equal(res.ok, false);
|
|
1743
|
+
assert.equal(res.downgrade, true);
|
|
1744
|
+
assert.match(res.error, /1\.19\.840/, 'names the target');
|
|
1745
|
+
assert.match(res.error, /1\.19\.841/, 'names what is installed');
|
|
1746
|
+
assert.match(res.error, /newest published: 1\.19\.842\b/, 'names the newest STABLE release — a prerelease is never the answer');
|
|
1747
|
+
assert.match(res.error, /--allow-downgrade/, 'names the way through');
|
|
1748
|
+
assert.ok(!res.error.includes('\n'), 'one line — a refusal that scrolls is a refusal nobody reads');
|
|
1749
|
+
assert.equal(readFileSync(join(dir, 'package.json'), 'utf8'), before, 'the pin never moved');
|
|
1750
|
+
assert.deepEqual(calls.filter((c) => !c.startsWith('npm view')), [], 'nothing ran but the registry read — no install, no restart, no commit');
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
t('downgrade guard: an unreadable registry still refuses — the newest version just reads unknown', async () => {
|
|
1754
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1755
|
+
const res = await u.runUpgrade({ to: '1.19.840', registry: true, instance: dir },
|
|
1756
|
+
{ log: () => {}, err: () => {}, run: registryRun([], { dir, registryDown: true }) });
|
|
1757
|
+
assert.equal(res.downgrade, true, 'the refusal must not depend on the network');
|
|
1758
|
+
assert.match(res.error, /newest published: unknown/);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
t('downgrade guard: --force does NOT waive it, and --dry-run reports the refusal a real run would give', async () => {
|
|
1762
|
+
for (const extra of [{ force: true }, { dryRun: true }]) {
|
|
1763
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1764
|
+
const res = await u.runUpgrade({ to: '1.19.840', registry: true, instance: dir, ...extra },
|
|
1765
|
+
{ log: () => {}, err: () => {}, run: registryRun([], { dir }) });
|
|
1766
|
+
assert.equal(res.downgrade, true, `${JSON.stringify(extra)} must not get past the guard`);
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
t('downgrade guard: the baseline is the NEWER of installed and pinned — a half-applied bump cannot hide the pin', async () => {
|
|
1771
|
+
// pin moved to 841 by a pull-deploy whose install never ran: node_modules still says 838
|
|
1772
|
+
const pinAhead = scratchConsumer({ pinned: '1.19.841', installed: '1.19.838' });
|
|
1773
|
+
const a = await u.runUpgrade({ to: '1.19.840', registry: true, instance: pinAhead },
|
|
1774
|
+
{ log: () => {}, err: () => {}, run: registryRun([], { dir: pinAhead }) });
|
|
1775
|
+
assert.equal(a.downgrade, true, '840 is newer than what is installed but OLDER than the pin it would overwrite');
|
|
1776
|
+
assert.match(a.error, /1\.19\.841/);
|
|
1777
|
+
assert.match(a.error, /pinned/, 'says which of the two it measured against');
|
|
1778
|
+
// the reverse: a rollback whose reinstall failed leaves 842 on disk behind an 841 pin
|
|
1779
|
+
const diskAhead = scratchConsumer({ pinned: '1.19.841', installed: '1.19.842' });
|
|
1780
|
+
const b = await u.runUpgrade({ to: '1.19.841', registry: true, instance: diskAhead, force: true },
|
|
1781
|
+
{ log: () => {}, err: () => {}, run: registryRun([], { dir: diskAhead }) });
|
|
1782
|
+
assert.equal(b.downgrade, true, 'taking running code backwards needs the flag, even to match the pin');
|
|
1783
|
+
assert.match(b.error, /installed/);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
t('downgrade guard: an older prerelease, and an older version carrying build metadata, are both caught', async () => {
|
|
1787
|
+
for (const [installed, to] of [['1.20.0-rc.2', '1.20.0-rc.1'], ['1.19.841', '1.19.840+hotfix']]) {
|
|
1788
|
+
const dir = scratchConsumer({ pinned: installed, installed });
|
|
1789
|
+
const res = await u.runUpgrade({ to, registry: true, instance: dir },
|
|
1790
|
+
{ log: () => {}, err: () => {}, run: registryRun([], { dir }) });
|
|
1791
|
+
assert.equal(res.downgrade, true, `${installed} → ${to}`);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
t('downgrade guard: an upgrade, and a --to it cannot parse, are not called downgrades', async () => {
|
|
1796
|
+
for (const to of ['1.19.842', 'not-a-version']) {
|
|
1797
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1798
|
+
const calls = [];
|
|
1799
|
+
const res = await u.runUpgrade({ to, registry: true, instance: dir, dryRun: true },
|
|
1800
|
+
{ log: () => {}, err: () => {}, run: registryRun(calls, { dir }) });
|
|
1801
|
+
assert.equal(res.downgrade, undefined, `--to ${to}`);
|
|
1802
|
+
assert.ok(!calls.some((c) => c.startsWith('npm view')), 'the registry is only read to word a refusal');
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
t('--allow-downgrade: goes backwards on purpose, and says so in the first line, the last line AND the ledger', async () => {
|
|
1807
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1808
|
+
const out = [], queries = [];
|
|
1809
|
+
const res = await u.runUpgrade(
|
|
1810
|
+
{ to: '1.19.840', registry: true, instance: dir, allowDowngrade: true, skipPinVerify: true, note: 'owner asked' },
|
|
1811
|
+
{
|
|
1812
|
+
log: (l) => out.push(l), err: (l) => out.push(l), run: registryRun([], { dir }),
|
|
1813
|
+
materialize: () => ({}), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
1814
|
+
databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
|
|
1815
|
+
},
|
|
1816
|
+
);
|
|
1817
|
+
assert.equal(res.ok, true, `an explicit downgrade should run: ${res.error}`);
|
|
1818
|
+
assert.equal(u.readInstalledCoreVersion(dir), '1.19.840');
|
|
1819
|
+
assert.match(out[0], /DOWNGRADE/, 'the first line says which way this is going');
|
|
1820
|
+
assert.match(out[out.length - 1], /downgrade complete/);
|
|
1821
|
+
assert.ok(!out.some((l) => /upgrade (complete|finished)/.test(l)), 'no line calls it an upgrade');
|
|
1822
|
+
// the ledger outlives the console: an auditor must not read this row as a normal bump
|
|
1823
|
+
assert.equal(queries[0].params[3], 'downgrade (--allow-downgrade): owner asked');
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
t('--allow-downgrade: a downgrade that ends with warnings is still called a downgrade', async () => {
|
|
1827
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1828
|
+
const out = [];
|
|
1829
|
+
const res = await u.runUpgrade(
|
|
1830
|
+
{ to: '1.19.840', registry: true, instance: dir, allowDowngrade: true, skipPinVerify: true },
|
|
1831
|
+
{
|
|
1832
|
+
log: (l) => out.push(l), err: (l) => out.push(l),
|
|
1833
|
+
run: fakeRun([], { dir, installTo: '1.19.839' }), // the install lands something other than the target
|
|
1834
|
+
materialize: () => ({}), regenerateApiArtifacts: () => ({ ok: true, ran: [] }), databaseUrl: '', pgDatabase: '',
|
|
1835
|
+
},
|
|
1836
|
+
);
|
|
1837
|
+
assert.equal(res.ok, false);
|
|
1838
|
+
assert.match(out[out.length - 1], /downgrade finished with warnings/);
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
// Same scenario as "rollback on migrate failure" in section 9, on the versions this guard
|
|
1842
|
+
// cares about: a rollback routed through runUpgrade would be refused and stay on 842.
|
|
1843
|
+
t('auto-rollback still goes backwards with no flag — it restores its own pin and never meets the guard', async () => {
|
|
1844
|
+
const dir = scratchConsumer({ pinned: '1.19.841', installed: '1.19.841' });
|
|
1845
|
+
const calls = [];
|
|
1846
|
+
const res = await u.runUpgrade({ to: '1.19.842', registry: true, instance: dir, healthCheck: false },
|
|
1847
|
+
{ log: () => {}, err: () => {}, run: pinAwareRun(calls, { dir, failMigrate: true }), materialize: () => ({}), databaseUrl: '', pgDatabase: '' });
|
|
1848
|
+
assert.equal(res.rolledBack, true);
|
|
1849
|
+
assert.equal(u.readInstalledCoreVersion(dir), '1.19.841', 'back on the previous core, 842 → 841, unprompted');
|
|
1850
|
+
assert.ok(!calls.some((c) => c.startsWith('npm view')), 'it never asked the registry, because it never met the guard');
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
t('parseArgs: --allow-downgrade is read, and is OFF unless asked for', () => {
|
|
1854
|
+
assert.equal(u.parseArgs(['--to', '1.0.0']).allowDowngrade, false);
|
|
1855
|
+
assert.equal(u.parseArgs(['--to', '1.0.0', '--allow-downgrade']).allowDowngrade, true);
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1717
1858
|
await Promise.all(pending);
|
|
1718
1859
|
console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
|
|
1719
1860
|
process.exit(failed ? 1 : 0);
|