@bongos/core 1.19.90 → 1.19.92
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 +34 -29
- package/config/branding.neutral.json +6 -6
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/renderer/theme.generated.css +9 -9
- package/modules/dev-box/app/scripts/gen-brand.js +10 -10
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +3 -3
- package/modules/dev-box/app/scripts/gen-icons.js +3 -3
- package/modules/dev-box/app/src/brand.generated.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +112 -64
- package/public-landing/index.html +543 -88
- package/scripts/gds/upgrade.js +47 -4
- package/scripts/migrate.sh +60 -3
- package/scripts/status-mirror-sync.js +12 -6
- package/src/bongos/serve-internal.js +53 -10
- package/src/module-api.js +5 -1
- package/src/platform-server.js +26 -2
- package/tests/cloud_bongos_pack.mjs +11 -7
- package/tests/landing_page.mjs +289 -0
- package/tests/migrate_root_resolution.mjs +100 -2
- package/tests/upgrade.mjs +58 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/landing_page.mjs — the apex landing page (task 1002748, goal 1000061).
|
|
2
|
+
//
|
|
3
|
+
// Contract: docs/design/landing-page-direction.md + docs/design/mocks/landing-page-mock.html.
|
|
4
|
+
// These lock the four properties the direction calls load-bearing, each of which
|
|
5
|
+
// fails SILENTLY in a browser and so cannot be caught by looking at the page:
|
|
6
|
+
//
|
|
7
|
+
// * the apex conditional — public-landing/ ships wholesale to every instance,
|
|
8
|
+
// so the platform-only sections must be REMOVED (not hidden) off the hub;
|
|
9
|
+
// * the server-stamped head — social tags are read before JS runs, so they
|
|
10
|
+
// cannot be client-filled, and they must not be baked to one instance;
|
|
11
|
+
// * the branding cascade — a page-local :root silently outranks the pack's
|
|
12
|
+
// injected theme and defeats re-branding (direction C1);
|
|
13
|
+
// * no hardcoded apex — the file is portable or it lies to child instances.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately NOT asserted here: that GET / returns the page. It does not — the
|
|
16
|
+
// apex still 302s into the gate until the hub exists (task 1002743 part 3), which
|
|
17
|
+
// is why tests/platform_boot.mjs stays parked in run-unit-tests KNOWN_FAILING.
|
|
18
|
+
// This file tests the artifact that flip will serve.
|
|
19
|
+
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'public-landing', 'index.html'), 'utf8');
|
|
30
|
+
|
|
31
|
+
const { stripApexOnly } = require('../src/platform-server.js');
|
|
32
|
+
const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
|
|
33
|
+
|
|
34
|
+
// ── the apex conditional ───────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test('the apex-only sentinels are balanced and each one wraps content', () => {
|
|
37
|
+
const starts = HTML.match(/<!--apex-only:start-->/g) || [];
|
|
38
|
+
const ends = HTML.match(/<!--apex-only:end-->/g) || [];
|
|
39
|
+
assert.equal(starts.length, ends.length, 'every apex-only:start needs its end');
|
|
40
|
+
assert.ok(starts.length >= 4, 'the direction marks four apex-only regions');
|
|
41
|
+
// no start after an unclosed start — a nested pair would make the non-greedy
|
|
42
|
+
// strip close on the wrong sentinel and leak the platform's claims.
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (const m of HTML.matchAll(/<!--apex-only:(start|end)-->/g)) {
|
|
45
|
+
depth += m[1] === 'start' ? 1 : -1;
|
|
46
|
+
assert.ok(depth === 0 || depth === 1, 'apex-only regions must not nest');
|
|
47
|
+
}
|
|
48
|
+
assert.equal(depth, 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the hub keeps every apex-only section', () => {
|
|
52
|
+
assert.equal(stripApexOnly(HTML, true), HTML);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a child instance is served none of the platform-only claims', () => {
|
|
56
|
+
const child = stripApexOnly(HTML, false);
|
|
57
|
+
assert.doesNotMatch(child, /<!--apex-only:(start|end)-->/, 'no sentinel survives the strip');
|
|
58
|
+
// the four regions, by their load-bearing content
|
|
59
|
+
assert.doesNotMatch(child, /capture-resistant commons/, 'the commons claim is the platform\'s, not a child\'s');
|
|
60
|
+
assert.doesNotMatch(child, /Built on Cloud Bongos/, 'a child has no catalog of other instances');
|
|
61
|
+
assert.doesNotMatch(child, /What you own/, 'the AGPL/ownership pitch is the platform\'s');
|
|
62
|
+
assert.doesNotMatch(child, /See what's building/, 'the catalog CTA has nothing to point at');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a child instance keeps the whole portable spine', () => {
|
|
66
|
+
const child = stripApexOnly(HTML, false);
|
|
67
|
+
for (const portable of [
|
|
68
|
+
'Building software is laying', // the hero tagline
|
|
69
|
+
'the ledger', 'the gates', 'the grade', // S3, the differentiator
|
|
70
|
+
'How a change reaches your project', // S4
|
|
71
|
+
'Build a project', 'Join as a builder', // S7, both doors
|
|
72
|
+
'Built by agents, on Cloud', // the portable half of the footer
|
|
73
|
+
'id="who"', // the identity chip
|
|
74
|
+
]) {
|
|
75
|
+
assert.ok(child.includes(portable), `a child instance lost "${portable}"`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the footer keeps its attribution half and loses only the commons half', () => {
|
|
80
|
+
const child = stripApexOnly(HTML, false);
|
|
81
|
+
const foot = child.match(/<div class="foot">([\s\S]*?)<\/div>/);
|
|
82
|
+
assert.ok(foot, 'the footer survives on a child instance');
|
|
83
|
+
assert.doesNotMatch(foot[1], /non-profit/);
|
|
84
|
+
assert.match(foot[1], /Built by agents/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── the server-stamped head ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const BRAND = {
|
|
90
|
+
identity: { productName: 'Spike Harbor', worldName: 'Spike Harbor' },
|
|
91
|
+
copy: { tagline: 'A different beat entirely.' },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
test('every head tag a crawler reads is stamped, and no token survives', () => {
|
|
95
|
+
const out = applyBrandTokens(HTML, BRAND, 'https://spike-harbor.example', 'https://builders.spike-harbor.example');
|
|
96
|
+
assert.doesNotMatch(out, /\{\{[a-zA-Z]+\}\}/, 'an unstamped {{token}} would render literally');
|
|
97
|
+
assert.match(out, /<title>Spike Harbor — A different beat entirely\.<\/title>/);
|
|
98
|
+
assert.match(out, /<link rel="canonical" href="https:\/\/spike-harbor\.example\/">/);
|
|
99
|
+
for (const prop of ['og:type', 'og:site_name', 'og:url', 'og:title', 'og:description', 'og:image']) {
|
|
100
|
+
assert.match(out, new RegExp(`property="${prop}"`), `missing ${prop}`);
|
|
101
|
+
}
|
|
102
|
+
for (const name of ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) {
|
|
103
|
+
assert.match(out, new RegExp(`name="${name}"`), `missing ${name}`);
|
|
104
|
+
}
|
|
105
|
+
// the head must not advertise the authoring instance to a child
|
|
106
|
+
assert.doesNotMatch(out.split('</head>')[0], /cloudbongos\.com/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('every cross-host link is stamped to THIS instance, never the platform', () => {
|
|
110
|
+
const out = applyBrandTokens(HTML, BRAND, 'https://spike-harbor.example', 'https://builders.spike-harbor.example');
|
|
111
|
+
const halls = [...out.matchAll(/data-link-hall href="([^"]*)"/g)].map((m) => m[1]);
|
|
112
|
+
assert.ok(halls.length >= 2, 'the header door and the builders door both point at the hall');
|
|
113
|
+
for (const href of halls) assert.equal(href, 'https://builders.spike-harbor.example');
|
|
114
|
+
// C6: this is the bug the stamping fixes — clientBranding() omits domains, so a
|
|
115
|
+
// client fill can never re-point these and every child sent visitors to the platform.
|
|
116
|
+
assert.doesNotMatch(out, /builders\.cloudbongos\.com/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a pack with no hall of its own falls back to its own apex, not the platform', () => {
|
|
120
|
+
const out = applyBrandTokens(HTML, BRAND, 'https://spike-harbor.example', '');
|
|
121
|
+
assert.match(out, /data-link-hall href="https:\/\/spike-harbor\.example"/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the source link is stamped from the pack repo, never a baked owner/name', () => {
|
|
125
|
+
// the authorship audit fails a hardcoded owner/name, and a baked one would
|
|
126
|
+
// point every instance at whoever authored this file
|
|
127
|
+
assert.doesNotMatch(HTML, /github\.com\/[a-z0-9-]+\/[a-z0-9-]+/i, 'no baked repo path');
|
|
128
|
+
const out = applyBrandTokens(HTML, BRAND, 'https://o.example', '', 'https://github.com/acme/harbor');
|
|
129
|
+
assert.match(out, /href="https:\/\/github\.com\/acme\/harbor"/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an unstamped link is removed, not left dangling', () => {
|
|
133
|
+
const out = applyBrandTokens(HTML, BRAND, 'https://o.example', '', '');
|
|
134
|
+
assert.match(out, /href=""/, 'the token resolves empty when the pack names no repo');
|
|
135
|
+
assert.match(CODE, /a\[href=""\]/, 'and the page drops those links');
|
|
136
|
+
assert.match(CODE, /\.remove\(\)/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── the branding cascade + portability ─────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
test('the page declares no :root — a page-local one defeats re-branding (C1)', () => {
|
|
142
|
+
const styles = [...HTML.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
143
|
+
assert.ok(styles.length > 0, 'the page ships a local stylesheet');
|
|
144
|
+
// :root[data-mode="dark"] .bongos{…} is a DESCENDANT rule, not a :root declaration
|
|
145
|
+
// block — it scopes component tokens and cannot outrank the pack's own :root.
|
|
146
|
+
assert.doesNotMatch(styles, /:root\s*\{/, 'a bare :root{ } block outranks the injected pack');
|
|
147
|
+
assert.doesNotMatch(styles, /:root\[data-mode="dark"\]\s*\{/, 'same, for the dark tier');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('every dark-mode rule is written twice — the block and its media twin (C2)', () => {
|
|
151
|
+
const styles = [...HTML.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
152
|
+
const explicit = (styles.match(/:root\[data-mode="dark"\]/g) || []).length;
|
|
153
|
+
const media = (styles.match(/:root:not\(\[data-mode="light"\]\)/g) || []).length;
|
|
154
|
+
assert.equal(explicit, media, 'a dark rule with no prefers-color-scheme twin only half-works');
|
|
155
|
+
assert.ok(explicit >= 2, 'the drum skin and the hero glow both flip');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the drum head never inherits a card colour — it would turn black in dark', () => {
|
|
159
|
+
const styles = [...HTML.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
160
|
+
const skin = styles.match(/\.bongos\{[^}]*\}/);
|
|
161
|
+
assert.ok(skin, '.bongos carries its own skin tokens');
|
|
162
|
+
assert.match(skin[0], /--skin-hi:/);
|
|
163
|
+
assert.doesNotMatch(styles, /--skin-[a-z]+:\s*var\(--bg-card\)/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the page ships the .sky layer the pack backdrop would otherwise show through (C3)', () => {
|
|
167
|
+
assert.match(HTML, /<div class="sky"/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── the static pose under reduced motion (direction §7) ───────────────────
|
|
171
|
+
// The hero animates by default, so this is an accessibility constraint that
|
|
172
|
+
// fails SILENTLY — nothing about the page looks wrong to a developer who does
|
|
173
|
+
// not have the OS setting on. It holds via a cross-FILE dependency (cosmos.css
|
|
174
|
+
// carries one global motion kill switch rather than every page repeating it),
|
|
175
|
+
// which is the right shape but an invisible one, so both halves are locked here.
|
|
176
|
+
// Verified in a real browser with reducedMotion emulation: every hero element
|
|
177
|
+
// computes animation-name:none, and the ripples additionally display:none.
|
|
178
|
+
|
|
179
|
+
const COSMOS = fs.readFileSync(path.join(ROOT, 'public-landing', 'assets', 'cosmos.css'), 'utf8');
|
|
180
|
+
const PAGE_STYLES = [...HTML.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
181
|
+
// declarations only — the comments quote the very shapes these tests rule out
|
|
182
|
+
const PAGE_CSS = PAGE_STYLES.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
183
|
+
|
|
184
|
+
test('cosmos.css still carries the global motion kill switch the hero relies on', () => {
|
|
185
|
+
const block = COSMOS.match(/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{([\s\S]*?)\n\}/);
|
|
186
|
+
assert.ok(block, 'cosmos.css must keep a prefers-reduced-motion block');
|
|
187
|
+
assert.match(block[1], /\*[^{]*\{[^}]*animation:\s*none\s*!important/,
|
|
188
|
+
'the universal selector + !important is what stops the hero; without it the drums keep beating');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('nothing on the page marks an animation !important and escapes that switch', () => {
|
|
192
|
+
// the ONE way this page could break the constraint on its own
|
|
193
|
+
assert.doesNotMatch(PAGE_CSS, /animation[^;:]*:[^;]*!important/,
|
|
194
|
+
'an !important animation here would outrank the global kill switch');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('the hero animations are plain declarations, so the switch reaches them', () => {
|
|
198
|
+
// every animated part of the drum kit, as the mock defines the beat
|
|
199
|
+
for (const cls of ['drumBig', 'drumSml', 'headBig', 'headSml', 'glow', 'bshadow', 'ring']) {
|
|
200
|
+
assert.match(PAGE_STYLES, new RegExp(`\\.bongos\\s+\\.${cls}\\s*\\{[^}]*animation:`),
|
|
201
|
+
`.${cls} should be animated in the default (non-reduced) case`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the ripples are hidden under reduced motion — they rest at full stroke', () => {
|
|
206
|
+
const reduced = PAGE_STYLES.match(/@media\s*\(prefers-reduced-motion:reduce\)\{([^}]*\})*/);
|
|
207
|
+
assert.ok(reduced, 'the page carries its own reduced-motion block');
|
|
208
|
+
assert.match(reduced[0], /\.bongos\s+\.ring\s*\{\s*display:none/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the self-typing eyebrow degrades to real text, not an empty span', () => {
|
|
212
|
+
// the script only decorates; markup must already carry the word
|
|
213
|
+
assert.match(HTML, /<span class="typed" id="typed">bongos<\/span>/);
|
|
214
|
+
assert.match(CODE, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)/);
|
|
215
|
+
assert.match(CODE, /if \(!reduce\) \{/, 'the typing loop is skipped under reduced motion');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('nothing about the platform is hardcoded — the file is portable (C5)', () => {
|
|
219
|
+
// the source repo link is a real, deliberate attribution; every OTHER mention
|
|
220
|
+
// of an origin must be a token.
|
|
221
|
+
const withoutSource = HTML.replace(/https:\/\/github\.com\/[^"]*/g, '');
|
|
222
|
+
assert.doesNotMatch(withoutSource, /cloudbongos\.com/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('the page reads without JavaScript: the spine is markup, not script-built', () => {
|
|
226
|
+
const body = HTML.split('<body>')[1].split('<script>')[0];
|
|
227
|
+
for (const copy of [
|
|
228
|
+
'Building software is laying',
|
|
229
|
+
'Every change is a claimed task',
|
|
230
|
+
'A rank ladder the server enforces',
|
|
231
|
+
'Nothing ships ungraded',
|
|
232
|
+
'How a change reaches your project',
|
|
233
|
+
'Start a project',
|
|
234
|
+
'Enter the Hall',
|
|
235
|
+
]) {
|
|
236
|
+
assert.ok(body.includes(copy), `"${copy}" is only reachable with scripting on`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('the proof band ships hidden so a failed or empty feed leaves no orphan heading', () => {
|
|
241
|
+
assert.match(HTML, /<section class="band" id="proof" hidden>/);
|
|
242
|
+
// and it is only ever revealed on a non-empty 200
|
|
243
|
+
assert.match(HTML, /if \(!list\.length\) return;/);
|
|
244
|
+
assert.match(HTML, /band\.hidden = false;/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// The page script minus its comments — these assertions are about what the code
|
|
248
|
+
// DOES, and the comments deliberately quote the shapes being ruled out.
|
|
249
|
+
const CODE = HTML.split('<script>').pop().replace(/\/\*[\s\S]*?\*\//g, '');
|
|
250
|
+
|
|
251
|
+
test('the catalog claims no liveness the public feed cannot back', () => {
|
|
252
|
+
// listFeaturedProjects projects {origin, name, tagline, art_url} — no status,
|
|
253
|
+
// so a verification chip here would be a claim nothing checked.
|
|
254
|
+
assert.doesNotMatch(CODE, /chip (live|maybe|down)/);
|
|
255
|
+
assert.doesNotMatch(CODE, /ago/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('catalog values are never interpolated as HTML, and hrefs are scheme-checked', () => {
|
|
259
|
+
assert.doesNotMatch(CODE, /innerHTML/, 'the feed carries third-party strings');
|
|
260
|
+
assert.match(CODE, /u\.protocol === 'https:' \|\| u\.protocol === 'http:'/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ── the shared layer gained the primitives, and the page did not fork it ───
|
|
264
|
+
|
|
265
|
+
test('the landing links cosmos.css and does not fork the design system', () => {
|
|
266
|
+
assert.match(HTML, /<link rel="stylesheet" href="assets\/cosmos\.css">/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('the five primitives graduated into cosmos.css, not the page (§6)', () => {
|
|
270
|
+
const css = fs.readFileSync(path.join(ROOT, 'public-landing', 'assets', 'cosmos.css'), 'utf8');
|
|
271
|
+
assert.match(css, /^\.wrap\{/m, 'the page container');
|
|
272
|
+
assert.match(css, /^h2\.sec-h\{/m, 'the type scale below h1');
|
|
273
|
+
assert.match(css, /--pad-xl:/, 'the spacing scale');
|
|
274
|
+
assert.match(css, /^\.grid\{/m, 'the grid primitive');
|
|
275
|
+
assert.match(css, /@media \(max-width:900px\)/, 'the mid breakpoint');
|
|
276
|
+
// and the page must not re-declare them locally
|
|
277
|
+
const styles = [...HTML.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
278
|
+
assert.doesNotMatch(styles, /^\.wrap\{/m, '.wrap belongs to the shared layer');
|
|
279
|
+
assert.doesNotMatch(styles, /^\.grid\{/m, '.grid belongs to the shared layer');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── the assertion tests/platform_boot.mjs will make once the apex flips ────
|
|
283
|
+
|
|
284
|
+
test('the page satisfies the parked platform_boot assertions', () => {
|
|
285
|
+
assert.match(HTML, /id="who"/);
|
|
286
|
+
assert.match(HTML, /Enter the Hall/);
|
|
287
|
+
// an ORG-level action that lives only on the gate — asserted absent there
|
|
288
|
+
assert.doesNotMatch(HTML, /Create a project/);
|
|
289
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { strict as assert } from 'node:assert';
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
import {
|
|
21
|
-
mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, realpathSync,
|
|
21
|
+
mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, copyFileSync, realpathSync,
|
|
22
22
|
} from 'node:fs';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
@@ -116,7 +116,15 @@ function makeCore({ spaced, files = ['001_a.sql', '002_b.sql', '006_pms_v2.sql',
|
|
|
116
116
|
mkdirSync(path.join(dir, 'scripts'));
|
|
117
117
|
mkdirSync(path.join(dir, 'migrations'));
|
|
118
118
|
mkdirSync(path.join(dir, 'src'));
|
|
119
|
-
|
|
119
|
+
// Symlink so the fixture can never drift from the real script. Windows refuses symlinks without
|
|
120
|
+
// elevation (EPERM), so fall back to a COPY taken fresh from MIGRATE on each run — equally
|
|
121
|
+
// drift-proof, and it is what lets these cases run on a Windows checkout at all.
|
|
122
|
+
try {
|
|
123
|
+
symlinkSync(MIGRATE, path.join(dir, 'scripts', 'migrate.sh'));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e.code !== 'EPERM' && e.code !== 'EACCES') throw e;
|
|
126
|
+
copyFileSync(MIGRATE, path.join(dir, 'scripts', 'migrate.sh'));
|
|
127
|
+
}
|
|
120
128
|
// Pin both roots to the fixture explicitly, so the test does not lean on migrate.sh's
|
|
121
129
|
// node-unavailable fallback to decide what CORE_ROOT is.
|
|
122
130
|
writeFileSync(path.join(dir, 'src', 'instance-config.js'), [
|
|
@@ -216,5 +224,95 @@ t('the pre-fix unquoted loop now fails loudly instead of silently skipping', ()
|
|
|
216
224
|
assert.equal(r.applied.length, 0, 'the unquoted loop applies nothing (that was the bug)');
|
|
217
225
|
});
|
|
218
226
|
|
|
227
|
+
// ==========================================================================
|
|
228
|
+
// WHICH DATABASE does migrate target? (task 1002755)
|
|
229
|
+
//
|
|
230
|
+
// scripts/migrate.sh:52 used to read `DB="${PGDATABASE:-amazonprimea}"` — one host instance's
|
|
231
|
+
// database name, baked into a script the core ships to EVERY instance. With no PGDATABASE in the
|
|
232
|
+
// environment, a co-hosted box silently aimed at a stranger's database and everywhere else died
|
|
233
|
+
// with a confusing "database ... does not exist" — after the caller had already moved its version
|
|
234
|
+
// pin. ADR 0149:17 had already named the divergence: the server pool resolves from the branding
|
|
235
|
+
// pack while migrate.sh defaulted to a literal, and "they only agree if PGDATABASE is set for both."
|
|
236
|
+
//
|
|
237
|
+
// These drive the `--print-db` seam, which resolves the name and exits before any psql — the
|
|
238
|
+
// --print-roots pattern. The fixture carries a REAL config/branding.json so the assertions
|
|
239
|
+
// exercise the actual resolver (pool.instanceDbName) rather than a stub of it.
|
|
240
|
+
|
|
241
|
+
// A core fixture that is ALSO an instance: adds the branding pack instanceDbName() reads.
|
|
242
|
+
// db.database === null models the neutral starter — an instance that has not named its database.
|
|
243
|
+
function makeInstance({ database } = {}) {
|
|
244
|
+
const dir = makeCore({ spaced: false });
|
|
245
|
+
mkdirSync(path.join(dir, 'config'), { recursive: true });
|
|
246
|
+
writeFileSync(
|
|
247
|
+
path.join(dir, 'config', 'branding.json'),
|
|
248
|
+
`${JSON.stringify({ db: { database: database ?? null } }, null, 2)}\n`,
|
|
249
|
+
);
|
|
250
|
+
// migrate.sh resolves the name through <CORE_ROOT>/src/bongos/pool. Re-export the REAL module
|
|
251
|
+
// so these cases exercise the actual resolver rather than a restatement of it — a stub here
|
|
252
|
+
// would pass happily while pool.js drifted. The real file resolves its own `pg` from the repo's
|
|
253
|
+
// node_modules (module resolution walks up from ITS path, not the fixture's), and its
|
|
254
|
+
// resolveInstanceRoot() falls back to cwd — which migrate.sh has already set to this fixture.
|
|
255
|
+
mkdirSync(path.join(dir, 'src', 'bongos'), { recursive: true });
|
|
256
|
+
writeFileSync(
|
|
257
|
+
path.join(dir, 'src', 'bongos', 'pool.js'),
|
|
258
|
+
`module.exports = require(${JSON.stringify(path.join(REPO_ROOT, 'src', 'bongos', 'pool.js'))});\n`,
|
|
259
|
+
);
|
|
260
|
+
return dir;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function printDb(coreDir, extraEnv = {}) {
|
|
264
|
+
const env = { ...process.env, ...extraEnv };
|
|
265
|
+
for (const k of Object.keys(env)) if (/_INSTANCE_ROOT$/.test(k)) delete env[k];
|
|
266
|
+
if (!('PGDATABASE' in extraEnv)) delete env.PGDATABASE;
|
|
267
|
+
try {
|
|
268
|
+
const stdout = execFileSync('bash', [path.join(coreDir, 'scripts', 'migrate.sh'), '--print-db'],
|
|
269
|
+
{ cwd: coreDir, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
270
|
+
return { status: 0, stdout: stdout.trim(), stderr: '' };
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return { status: e.status ?? 1, stdout: String(e.stdout || '').trim(), stderr: String(e.stderr || '') };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
t('--print-db reports the database named by the instance\'s own branding pack', () => {
|
|
277
|
+
const r = printDb(makeInstance({ database: 'acme_instance_db' }));
|
|
278
|
+
assert.equal(r.status, 0, r.stderr);
|
|
279
|
+
assert.equal(r.stdout, 'acme_instance_db');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
t('an explicit PGDATABASE wins over the pack — same precedence as pool.js', () => {
|
|
283
|
+
const r = printDb(makeInstance({ database: 'from_the_pack' }), { PGDATABASE: 'from_the_env' });
|
|
284
|
+
assert.equal(r.status, 0, r.stderr);
|
|
285
|
+
assert.equal(r.stdout, 'from_the_env');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
t('an instance that names no database is REFUSED, never defaulted', () => {
|
|
289
|
+
const r = printDb(makeInstance({ database: null }));
|
|
290
|
+
assert.equal(r.status, 2, 'must exit 2 — the seam refuses rather than printing an empty name');
|
|
291
|
+
assert.doesNotMatch(r.stdout, /\S/, 'nothing is printed as the database name');
|
|
292
|
+
assert.match(r.stderr, /cannot determine which database/i);
|
|
293
|
+
assert.match(r.stderr, /Refusing to guess/i);
|
|
294
|
+
// the regression itself: it must not reach for another instance's database
|
|
295
|
+
assert.doesNotMatch(`${r.stdout}${r.stderr}`, /amazonprimea/i);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
t('--print-migrations stays DB-free — a dry run must not require a database name', () => {
|
|
299
|
+
// the seam walks the real loop and touches no psql, so an instance with no db.database
|
|
300
|
+
// must still be able to list what would be applied
|
|
301
|
+
const core = makeInstance({ database: null });
|
|
302
|
+
const r = printMigrations(core);
|
|
303
|
+
assert.equal(r.status, 0, `a dry run must not need a database: ${r.stderr}`);
|
|
304
|
+
assert.equal(r.listed, 4);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
t('migrate.sh hardcodes no instance database name', () => {
|
|
308
|
+
// the static lock on the original defect — a literal default reintroduced anywhere in the
|
|
309
|
+
// script points every other instance at a database that is not theirs
|
|
310
|
+
const src = readFileSync(MIGRATE, 'utf8');
|
|
311
|
+
const code = src.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
312
|
+
assert.doesNotMatch(code, /PGDATABASE:-\s*[A-Za-z]/,
|
|
313
|
+
'PGDATABASE must have no literal fallback — resolve from the branding pack instead');
|
|
314
|
+
assert.doesNotMatch(code, /amazonprimea/i, 'no instance database name in executable lines');
|
|
315
|
+
});
|
|
316
|
+
|
|
219
317
|
console.log(`\nmigrate_root_resolution: ${passed} passed, ${failed} failed`);
|
|
220
318
|
process.exit(failed ? 1 : 0);
|
package/tests/upgrade.mjs
CHANGED
|
@@ -1033,6 +1033,64 @@ t('runUpgrade: a REFUSED bump still reports the non-blocking module findings', a
|
|
|
1033
1033
|
assert.match(text, /author unreachable/);
|
|
1034
1034
|
});
|
|
1035
1035
|
|
|
1036
|
+
// ── which database will migrate target? (task 1002755) ────────────────────
|
|
1037
|
+
// The pre-flight probe is only meaningful if it tests the SAME database migrate will use. It used
|
|
1038
|
+
// to be handed process.env.PGDATABASE — undefined on a normal box — so psql fell through to a
|
|
1039
|
+
// database named after the OS user, failed for an unrelated reason, and soft-skipped; migrate then
|
|
1040
|
+
// picked its own name and died on a database that did not exist, AFTER the pin had moved.
|
|
1041
|
+
// The three-way result is the point: "names no database" and "resolver could not run" are
|
|
1042
|
+
// different problems and only the first may block a bump.
|
|
1043
|
+
|
|
1044
|
+
t('resolveInstanceDb: an explicit PGDATABASE wins without spawning anything', () => {
|
|
1045
|
+
const prev = process.env.PGDATABASE;
|
|
1046
|
+
process.env.PGDATABASE = 'from_the_env';
|
|
1047
|
+
try {
|
|
1048
|
+
let spawned = false;
|
|
1049
|
+
const r = u.resolveInstanceDb({ instanceDir: '/x' }, () => { spawned = true; return { status: 0, stdout: 'other' }; });
|
|
1050
|
+
assert.deepEqual(r, { db: 'from_the_env', ran: true });
|
|
1051
|
+
assert.equal(spawned, false, 'the env answer needs no child process');
|
|
1052
|
+
} finally {
|
|
1053
|
+
if (prev === undefined) delete process.env.PGDATABASE; else process.env.PGDATABASE = prev;
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
t('resolveInstanceDb: reports the name the core resolver printed', () => {
|
|
1058
|
+
const prev = process.env.PGDATABASE;
|
|
1059
|
+
delete process.env.PGDATABASE;
|
|
1060
|
+
try {
|
|
1061
|
+
const r = u.resolveInstanceDb({ instanceDir: '/x' }, () => ({ status: 0, stdout: 'acme_db\n' }));
|
|
1062
|
+
assert.deepEqual(r, { db: 'acme_db', ran: true });
|
|
1063
|
+
} finally { if (prev !== undefined) process.env.PGDATABASE = prev; }
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
t('resolveInstanceDb: ran-but-empty is FATAL, unrunnable is not', () => {
|
|
1067
|
+
const prev = process.env.PGDATABASE;
|
|
1068
|
+
delete process.env.PGDATABASE;
|
|
1069
|
+
try {
|
|
1070
|
+
// the instance genuinely names no database — knowable, and a bump must stop
|
|
1071
|
+
assert.deepEqual(u.resolveInstanceDb({ instanceDir: '/x' }, () => ({ status: 0, stdout: '' })),
|
|
1072
|
+
{ db: null, ran: true });
|
|
1073
|
+
// the resolver could not run — a DIFFERENT problem; must not be reported as "no database"
|
|
1074
|
+
assert.deepEqual(u.resolveInstanceDb({ instanceDir: '/x' }, () => ({ status: 1, stdout: '' })),
|
|
1075
|
+
{ db: null, ran: false });
|
|
1076
|
+
assert.deepEqual(u.resolveInstanceDb({ instanceDir: '/x' }, () => ({ error: new Error('ENOENT') })),
|
|
1077
|
+
{ db: null, ran: false });
|
|
1078
|
+
} finally { if (prev !== undefined) process.env.PGDATABASE = prev; }
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
t('runUpgrade: refuses BEFORE the pin moves when the instance names no database', async () => {
|
|
1082
|
+
const dir = mkdtempSync(join(tmpdir(), 'nodb-'));
|
|
1083
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.0.0' } }, null, 2));
|
|
1084
|
+
const res = await u.runUpgrade({ to: '1.0.1', instance: dir, registry: true, force: true }, {
|
|
1085
|
+
resolveInstanceDb: () => ({ db: null, ran: true }),
|
|
1086
|
+
log: () => {},
|
|
1087
|
+
});
|
|
1088
|
+
assert.equal(res.ok, false);
|
|
1089
|
+
assert.match(res.error, /cannot determine which database/i);
|
|
1090
|
+
// the pin must be untouched — that is the whole point of checking in pre-flight
|
|
1091
|
+
assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"@bongos\/core": "1\.0\.0"/);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1036
1094
|
await Promise.all(pending);
|
|
1037
1095
|
console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
|
|
1038
1096
|
process.exit(failed ? 1 : 0);
|