@bongos/core 1.19.850 → 1.19.852
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 +47 -32
- package/.gitattributes +27 -3
- 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 +4 -0
- package/docs/recipes/core-release-pipeline.md +11 -2
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/docs/recipes/windows-builders.md +1 -1
- 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/markdown_line_endings.mjs +78 -0
- package/tests/provision.mjs +46 -3
- 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
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tests/markdown_line_endings.mjs — *.md is pinned to LF in .gitattributes (task 1003981).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS GUARDS. At least six tools in this repo read a .md file AS TEXT and make a
|
|
4
|
+
// decision from what they parse: skill-preflight.js (SKILL.md frontmatter), fitness.js
|
|
5
|
+
// G03, linkify-refs.js, docs-entropy.js, the role-pack guard and the doc-CLI guard.
|
|
6
|
+
// Before this pin, *.md fell through `* text=auto`, so a Windows clone with the common
|
|
7
|
+
// core.autocrlf=true got CRLF in the worktree for 834 of 835 tracked .md files while the
|
|
8
|
+
// index stayed LF. A CRLF-blind reader does not fail loudly — it parses nothing, and a
|
|
9
|
+
// guard with nothing to compare cannot fail. Measured on task 1003754: skill-preflight's
|
|
10
|
+
// /^---\n/ frontmatter regex matched ZERO of 53 skills, and G03 duly reported
|
|
11
|
+
// "53 skill(s) scanned; 0 declare a machine contract" while staying green.
|
|
12
|
+
//
|
|
13
|
+
// Asserted through GIT ITSELF (`git check-attr`), not by pattern-matching .gitattributes:
|
|
14
|
+
// the pattern semantics are git's, and the failure mode this replaces was precisely a
|
|
15
|
+
// rule that read correctly but did not match what it was believed to match (the
|
|
16
|
+
// root-anchored /CLAUDE.md lesson in tests/git_merge_regen.mjs). Reading the file back
|
|
17
|
+
// would re-assert our own spelling rather than git's resolution of it.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/markdown_line_endings.mjs
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
const git = (args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
+
|
|
31
|
+
const eolOf = (p) => {
|
|
32
|
+
const r = git(['check-attr', 'eol', '--', p]);
|
|
33
|
+
return (r.stdout || '').trim().split(': ').pop();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Representative paths, chosen for DEPTH rather than count: a gitattributes pattern
|
|
37
|
+
// with no leading slash matches at any depth, and the bug being pinned bit a .md
|
|
38
|
+
// four directories down, not one at the root.
|
|
39
|
+
test('every tracked .md resolves to eol=lf, at any depth', () => {
|
|
40
|
+
for (const p of [
|
|
41
|
+
'README.md',
|
|
42
|
+
'CLAUDE.md',
|
|
43
|
+
'docs/architecture.md',
|
|
44
|
+
'docs/packs/engineer.md',
|
|
45
|
+
'docs/adr/README.md',
|
|
46
|
+
'.claude/skills/merge-mode/SKILL.md',
|
|
47
|
+
'modules/hall-ui/CLAUDE.md',
|
|
48
|
+
]) {
|
|
49
|
+
assert.equal(eolOf(p), 'lf', `${p} must be pinned to LF — a CRLF copy silently voids every reader that parses it`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The invariant that actually matters, over the REAL tracked set rather than a sample.
|
|
54
|
+
// `git ls-files --eol` reports the index ("i/") and worktree ("w/") encodings side by
|
|
55
|
+
// side; the index is what every clone receives, so an index CRLF is the state that
|
|
56
|
+
// would ship the bug to someone else's checkout.
|
|
57
|
+
test('no tracked .md carries CRLF in the index', () => {
|
|
58
|
+
const r = git(['ls-files', '--eol', '-z', '*.md']);
|
|
59
|
+
assert.equal(r.status, 0, 'git ls-files --eol must succeed');
|
|
60
|
+
const rows = (r.stdout || '').split('\0').filter(Boolean);
|
|
61
|
+
assert.ok(rows.length > 500, `expected the full markdown set, got ${rows.length} rows`);
|
|
62
|
+
|
|
63
|
+
const crlf = rows.filter((row) => /^i\/crlf/.test(row.trim())).map((row) => row.trim());
|
|
64
|
+
assert.deepEqual(crlf, [], `these .md files are CRLF in the index: ${crlf.join(', ')}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The complementary half, and the reason this is not just `*` pinned to LF: the repo
|
|
68
|
+
// deliberately ships CRLF for the Windows-shell wrappers, where cmd.exe and PowerShell
|
|
69
|
+
// expect it. A future over-broad edit that pinned everything to LF would silently break
|
|
70
|
+
// those, and the markdown assertions above would keep passing while it did.
|
|
71
|
+
test('the Windows-shell classes stay pinned to CRLF', () => {
|
|
72
|
+
// scripts/gds/api.ps1 is the one such file that exists today; the .cmd/.bat spellings
|
|
73
|
+
// are the declared-up-front rules for the wrappers .gitattributes says do not exist yet.
|
|
74
|
+
// check-attr resolves a PATTERN, so it answers for those too — which is the point here.
|
|
75
|
+
for (const p of ['scripts/gds/api.ps1', 'scripts/setup-builder.cmd', 'scripts/setup-builder.bat']) {
|
|
76
|
+
assert.equal(eolOf(p), 'crlf', `${p} must stay CRLF — cmd.exe/PowerShell expect it`);
|
|
77
|
+
}
|
|
78
|
+
});
|
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/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);
|