@catalystiq/test-kit 0.1.0 → 0.3.0
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/README.md +4 -2
- package/dist/bin/check-env-example.d.ts +2 -0
- package/dist/bin/check-env-example.js +23 -0
- package/dist/bin/dev-guard.d.ts +2 -0
- package/dist/bin/dev-guard.js +7 -0
- package/dist/env/README.md +66 -0
- package/dist/env/args.d.ts +5 -0
- package/dist/env/args.js +20 -0
- package/dist/env/dev-guard.d.ts +61 -0
- package/dist/env/dev-guard.js +254 -0
- package/dist/env/example.d.ts +25 -0
- package/dist/env/example.js +77 -0
- package/dist/env/index.d.ts +3 -0
- package/dist/env/index.js +3 -0
- package/dist/env/scan.d.ts +36 -0
- package/dist/env/scan.js +327 -0
- package/dist/neon-test-db/README.md +53 -0
- package/dist/neon-test-db/neon-test-db.d.mts +35 -0
- package/dist/neon-test-db/neon-test-db.mjs +381 -0
- package/dist/setup/README.md +2 -2
- package/dist/setup/config.d.ts +5 -1
- package/dist/setup/config.js +7 -1
- package/package.json +21 -5
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE LOCAL DATABASE TEST RUN (@catalystiq/test-kit, bin `tk-neon-test-db`): `npm run test:db`.
|
|
3
|
+
//
|
|
4
|
+
// Moved here from getcatalystiq/github-actions' scripts/neon-test-db.mjs, which every app vendored unchanged; the apps now
|
|
5
|
+
// run it from the package. Add the package as an exact-pinned dev dependency and the script:
|
|
6
|
+
//
|
|
7
|
+
// "test:db": "tk-neon-test-db -- npm run ci:db"
|
|
8
|
+
//
|
|
9
|
+
// It CUTS a short-lived, SCHEMA-ONLY Neon branch (init_source schema-only: the parent's schema, none of its rows, so
|
|
10
|
+
// never production data; NEON_TEST_DB_INIT=parent-data, below, is the one exception, for a synthetic parent), runs the
|
|
11
|
+
// command after `--` with DATABASE_URL set to that branch, and DELETES the branch it made whatever the command did, a
|
|
12
|
+
// Ctrl-C included. The branch also carries an expiry (default 2 hours, at most 6), so one whose process died is collected
|
|
13
|
+
// by Neon. Nothing else is ever deleted: the delete names the id this run created.
|
|
14
|
+
//
|
|
15
|
+
// Configuration, from the environment (a repository's `.env.local` is NOT read; export them or use `dotenv --`):
|
|
16
|
+
// NEON_API_KEY a key that reaches the project (a personal key, or `neonctl`'s token for a short run)
|
|
17
|
+
// NEON_TEST_DB_PROJECT the Neon project id (each repository's CLAUDE.md or README names it)
|
|
18
|
+
// NEON_TEST_DB_PARENT the parent branch id (br-…) or name whose schema is copied (staging's, or the CI project's
|
|
19
|
+
// ci-synthetic). Never production's: NEON_TEST_DB_REFUSE names project ids to refuse.
|
|
20
|
+
// NEON_TEST_DB_REFUSE project ids this command refuses outright (name production)
|
|
21
|
+
// NEON_TEST_DB_DATABASE the database to connect to (default: the branch's one database that is not `postgres`)
|
|
22
|
+
// NEON_TEST_DB_EXPIRES minutes until Neon deletes the branch on its own (default 120, at most 360)
|
|
23
|
+
// NEON_TEST_DB_KEEP=1 keep the branch afterwards (it still expires); prints its id, never its password
|
|
24
|
+
// NEON_TEST_DB_INIT `schema-only` (the default) or `parent-data`: a DATA copy of the parent, for a suite that
|
|
25
|
+
// needs rows the parent carries (divorce-virtual's migration ledger and reference catalog on
|
|
26
|
+
// ci-synthetic). Only for a SYNTHETIC parent, never one that holds customer rows: it is
|
|
27
|
+
// refused unless the parent's branch ID (br-…, never a name, which anyone can give another
|
|
28
|
+
// branch) is listed in NEON_TEST_DB_DATA_PARENTS, the parents the repository's own script
|
|
29
|
+
// names as customer-free. The lock is the parent, not the project, because a project can hold
|
|
30
|
+
// production beside a synthetic branch (divorce-virtual's does). The branch must come back as
|
|
31
|
+
// a data copy (not schema-only) of that parent, or it is deleted unused.
|
|
32
|
+
//
|
|
33
|
+
// The connection string is never printed. The branch is named `tmp/test-db-<user>-<UTC yyyymmddhhmmss>-<4 hex>`.
|
|
34
|
+
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { randomBytes } from 'node:crypto';
|
|
37
|
+
import { constants as osConstants, userInfo } from 'node:os';
|
|
38
|
+
import { realpathSync } from 'node:fs';
|
|
39
|
+
import { resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
/** The init_source values that mean schema-only: Neon's answer and the request's spelling (actions/neon-branch's inventory reads the same list). */
|
|
43
|
+
export const SCHEMA_ONLY_SOURCES = ['parent-schema', 'schema-only'];
|
|
44
|
+
/** NEON_TEST_DB_INIT's values: what the create asks for. */
|
|
45
|
+
export const INIT_SOURCES = ['schema-only', 'parent-data'];
|
|
46
|
+
|
|
47
|
+
export const NEON_API = 'https://console.neon.tech/api/v2';
|
|
48
|
+
export const MAX_EXPIRES = 360;
|
|
49
|
+
const ID = /^[a-z0-9-]{1,64}$/;
|
|
50
|
+
|
|
51
|
+
/** One Neon API call; returns { ok, status, body }. Never throws; never puts the key or a body in an error. */
|
|
52
|
+
async function call(deps, key, method, path, body) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await deps.fetch(`${NEON_API}${path}`, {
|
|
55
|
+
method,
|
|
56
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
57
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
58
|
+
signal: AbortSignal.timeout(30_000),
|
|
59
|
+
});
|
|
60
|
+
let parsed = null;
|
|
61
|
+
try {
|
|
62
|
+
parsed = await res.json();
|
|
63
|
+
} catch {}
|
|
64
|
+
return { ok: res.ok, status: res.status, body: parsed };
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, status: 0, body: null };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readConfig(env, argv) {
|
|
71
|
+
const sep = argv.indexOf('--');
|
|
72
|
+
const command = sep < 0 ? [] : argv.slice(sep + 1);
|
|
73
|
+
const problems = [];
|
|
74
|
+
if (command.length === 0) problems.push('no command: run it as `tk-neon-test-db -- npm run ci:db`');
|
|
75
|
+
const key = env.NEON_API_KEY ?? '';
|
|
76
|
+
if (!key) problems.push('NEON_API_KEY is not set');
|
|
77
|
+
const project = (env.NEON_TEST_DB_PROJECT ?? '').trim();
|
|
78
|
+
if (!ID.test(project)) problems.push('NEON_TEST_DB_PROJECT is not a Neon project id');
|
|
79
|
+
const refuse = (env.NEON_TEST_DB_REFUSE ?? '').split(/[\s,]+/).filter(Boolean);
|
|
80
|
+
if (refuse.includes(project)) problems.push(`NEON_TEST_DB_PROJECT ${project} is in NEON_TEST_DB_REFUSE: this command never cuts from that project`);
|
|
81
|
+
const parent = (env.NEON_TEST_DB_PARENT ?? '').trim();
|
|
82
|
+
if (!/^[A-Za-z0-9_./-]{1,128}$/.test(parent)) problems.push('NEON_TEST_DB_PARENT is not a branch id or name');
|
|
83
|
+
const expiresRaw = (env.NEON_TEST_DB_EXPIRES ?? '120').trim();
|
|
84
|
+
const expires = Number(expiresRaw);
|
|
85
|
+
if (!/^[0-9]+$/.test(expiresRaw) || expires < 5 || expires > MAX_EXPIRES) problems.push(`NEON_TEST_DB_EXPIRES must be whole minutes from 5 to ${MAX_EXPIRES}`);
|
|
86
|
+
const database = (env.NEON_TEST_DB_DATABASE ?? '').trim();
|
|
87
|
+
const init = (env.NEON_TEST_DB_INIT ?? '').trim() || 'schema-only';
|
|
88
|
+
if (!INIT_SOURCES.includes(init)) problems.push(`NEON_TEST_DB_INIT must be ${INIT_SOURCES.join(' or ')}`);
|
|
89
|
+
// The parents allowed to hand out rows, by branch id; checked against the RESOLVED parent's id in main().
|
|
90
|
+
const dataParents = (env.NEON_TEST_DB_DATA_PARENTS ?? '').split(/[\s,]+/).filter(Boolean);
|
|
91
|
+
if (init === 'parent-data' && dataParents.length === 0) {
|
|
92
|
+
problems.push('NEON_TEST_DB_INIT=parent-data copies the parent\'s rows: list the customer-free parent\'s branch id in NEON_TEST_DB_DATA_PARENTS');
|
|
93
|
+
}
|
|
94
|
+
return { problems, key, project, parent, expires, database, init, dataParents, keep: env.NEON_TEST_DB_KEEP === '1', command };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function branchName(user, now, rand) {
|
|
98
|
+
const who = (user || 'dev').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 20) || 'dev';
|
|
99
|
+
const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
100
|
+
return `tmp/test-db-${who}-${stamp}-${rand}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Runs the whole flow; returns the exit code. `deps`: fetch, run(command, env) → Promise<code>, log, now, sleep, onSignal. */
|
|
104
|
+
export async function main(env, argv, deps) {
|
|
105
|
+
const c = readConfig(env, argv);
|
|
106
|
+
if (c.problems.length) {
|
|
107
|
+
for (const p of c.problems) deps.log(`neon-test-db: ${p}`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
// The parent: by id, or the one branch of that name.
|
|
111
|
+
const list = await call(deps, c.key, 'GET', `/projects/${c.project}/branches`);
|
|
112
|
+
if (!list.ok) {
|
|
113
|
+
deps.log(`neon-test-db: could not list the project's branches (HTTP ${list.status || 'no answer'}). Does the key reach ${c.project}?`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const branches = Array.isArray(list.body?.branches) ? list.body.branches : [];
|
|
117
|
+
const matches = branches.filter((b) => b.id === c.parent || b.name === c.parent);
|
|
118
|
+
if (matches.length !== 1) {
|
|
119
|
+
deps.log(`neon-test-db: ${matches.length === 0 ? 'no' : matches.length} branch(es) match NEON_TEST_DB_PARENT=${c.parent}.`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const parent = matches[0];
|
|
123
|
+
if (c.init === 'parent-data' && !c.dataParents.includes(parent.id)) {
|
|
124
|
+
deps.log(`neon-test-db: NEON_TEST_DB_INIT=parent-data copies the parent's rows, and ${parent.id} is not in NEON_TEST_DB_DATA_PARENTS; nothing was created.`);
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
127
|
+
// userInfo() throws in a container whose uid has no passwd entry; branchName names an empty user 'dev'.
|
|
128
|
+
let user = env.USER || '';
|
|
129
|
+
if (!user) try { user = userInfo().username; } catch { /* no passwd entry */ }
|
|
130
|
+
const name = branchName(user, deps.now(), randomBytes(2).toString('hex'));
|
|
131
|
+
const expiresAt = new Date(deps.now().getTime() + c.expires * 60_000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
132
|
+
// Ctrl-C is caught from before the create: once a branch exists, every exit path below deletes it (or keeps it, with
|
|
133
|
+
// NEON_TEST_DB_KEEP=1), an interrupt included.
|
|
134
|
+
// `interrupted` holds the signal's name; the exit is 128 + its number (130 SIGINT, 143 SIGTERM, 129 SIGHUP).
|
|
135
|
+
let interrupted = '';
|
|
136
|
+
const stop = deps.onSignal((sig) => {
|
|
137
|
+
interrupted = interrupted || sig || 'SIGINT';
|
|
138
|
+
});
|
|
139
|
+
const signalExit = () => signalCode(interrupted);
|
|
140
|
+
const startedAt = deps.now().getTime();
|
|
141
|
+
const madeByThisRun = (b) => {
|
|
142
|
+
const t = Date.parse(b?.created_at ?? '');
|
|
143
|
+
// Ten minutes of slack either way: Neon's clock and this machine's need not agree, and no other branch's
|
|
144
|
+
// created_at falls this close to a create whose answer named it.
|
|
145
|
+
return Number.isFinite(t) && t >= startedAt - 600_000 && t <= deps.now().getTime() + 600_000;
|
|
146
|
+
};
|
|
147
|
+
// ONE ownership rule: a branch is deleted only when it is shown to be this run's, by the name asked for or by a
|
|
148
|
+
// created_at inside this run. Anything else (another name, or no name whose read-back failed) may be a branch that
|
|
149
|
+
// existed (the list read may be one page of several), so it is left alone; a branch this run did make carries the
|
|
150
|
+
// expiry it asked for.
|
|
151
|
+
const named = (b) => typeof b?.name === 'string' && b.name !== '';
|
|
152
|
+
let owned = false; // set once ANY answer about this id (the create, or a read with the same id) shows it this run's
|
|
153
|
+
const note = (b) => {
|
|
154
|
+
if ((named(b) && b.name === name) || madeByThisRun(b)) owned = true;
|
|
155
|
+
};
|
|
156
|
+
const ours = (b) => {
|
|
157
|
+
note(b);
|
|
158
|
+
return owned;
|
|
159
|
+
};
|
|
160
|
+
// One rule for "schema-only": the refusal and the delete message both read it.
|
|
161
|
+
const isSchemaOnly = (b) => SCHEMA_ONLY_SOURCES.includes(b?.init_source ?? '');
|
|
162
|
+
// One rule for "the source asked for": schema-only (either spelling), or a data copy: any reported source that is not
|
|
163
|
+
// schema-only (Neon spells its answers its own way, as 'parent-schema' shows), and, checked below, this parent's.
|
|
164
|
+
const dataCopy = c.init === 'parent-data';
|
|
165
|
+
const isSourceAsked = (b) => (dataCopy ? b?.init_source != null && !isSchemaOnly(b) : isSchemaOnly(b));
|
|
166
|
+
// One rule for the expiry: no later than asked (60 s of clock slack). The check and the delete message both use it.
|
|
167
|
+
const latest = Date.parse(expiresAt) + 60_000;
|
|
168
|
+
const expiresInTime = (b) => {
|
|
169
|
+
const t = Date.parse(b?.expires_at ?? '');
|
|
170
|
+
return Number.isFinite(t) && t <= latest;
|
|
171
|
+
};
|
|
172
|
+
let branch;
|
|
173
|
+
let code = 1;
|
|
174
|
+
let ok = false;
|
|
175
|
+
try {
|
|
176
|
+
const created = await call(deps, c.key, 'POST', `/projects/${c.project}/branches`, {
|
|
177
|
+
branch: { name, parent_id: parent.id, init_source: c.init, expires_at: expiresAt },
|
|
178
|
+
endpoints: [{ type: 'read_write' }],
|
|
179
|
+
});
|
|
180
|
+
branch = created.body?.branch;
|
|
181
|
+
// An id goes into the read-back and delete paths, so it must look like one (br-…) and must not be a branch that
|
|
182
|
+
// existed before this create (the list read above): otherwise nothing is touched by it.
|
|
183
|
+
// The id must be a STRING of the id shape: a non-string (an array, an object) would pass a String() test and
|
|
184
|
+
// miss the `===` comparison with the list, and then name another branch in every path built from it.
|
|
185
|
+
if (!created.ok || branch?.id === undefined) {
|
|
186
|
+
// A failed create deletes nothing, even if its answer names an id: a branch it may have made carries the expiry.
|
|
187
|
+
deps.log(`neon-test-db: could not create ${name} (HTTP ${created.status || 'no answer'}). Nothing to delete; if it was created anyway it expires at ${expiresAt}.`);
|
|
188
|
+
branch = undefined;
|
|
189
|
+
return 1; // an interrupt's code is set in finally
|
|
190
|
+
}
|
|
191
|
+
// A malformed id, or one already in the list, is never touched even under the name asked for: deleting it safely
|
|
192
|
+
// would need another list by that name; a branch this run made carries its expiry.
|
|
193
|
+
const idOk = typeof branch.id === 'string' && ID.test(branch.id);
|
|
194
|
+
const existed = idOk && branches.some((b) => b.id === branch.id);
|
|
195
|
+
if (!idOk || existed) {
|
|
196
|
+
deps.log(`neon-test-db: the create answered with ${existed ? 'the id of a branch that already existed' : 'a malformed branch id'}; touching nothing. ${name}, if it exists, expires at ${expiresAt}.`);
|
|
197
|
+
branch = undefined;
|
|
198
|
+
return 1; // an interrupt's code is set in finally
|
|
199
|
+
}
|
|
200
|
+
// Every refusal below deletes this id only when an answer about it (the create, or a read of it) shows it this
|
|
201
|
+
// run's (ours); otherwise the branch is left to its expiry, and nothing else is ever deleted. An answer missing the
|
|
202
|
+
// name, the source or the expiry is read back from GET /branches/<id> (a few tries); a read that never answers is
|
|
203
|
+
// said as such.
|
|
204
|
+
note(branch);
|
|
205
|
+
if (!named(branch) || branch.init_source == null || branch.expires_at == null) {
|
|
206
|
+
let read = null;
|
|
207
|
+
for (let attempt = 1; attempt <= 5 && !interrupted; attempt++) {
|
|
208
|
+
const r = await call(deps, c.key, 'GET', `/projects/${c.project}/branches/${branch.id}`);
|
|
209
|
+
// Neon's read is the authority: every checked field comes from it, never from the incomplete create answer.
|
|
210
|
+
const got = r.ok && typeof r.body?.branch?.id === 'string' && r.body.branch.id === branch.id ? r.body.branch : null;
|
|
211
|
+
if (got) note(got); // a partial read still counts as evidence that the branch is this run's
|
|
212
|
+
if (got && named(got) && got.init_source != null && got.expires_at != null) {
|
|
213
|
+
read = got;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (attempt < 5) await deps.sleep(2000);
|
|
217
|
+
}
|
|
218
|
+
if (interrupted) return signalExit();
|
|
219
|
+
if (!read) {
|
|
220
|
+
deps.log(`neon-test-db: could not read back the branch answered (${branch.id}) with its name, source and expiry; ${ours(branch) ? 'deleting it' : 'it is not shown to be this run\'s, so it is left alone'}.`);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
branch = read;
|
|
224
|
+
}
|
|
225
|
+
// Neon answers a schema-only create with parent_id null and init_source 'parent-schema' (measured on easy-passport's
|
|
226
|
+
// project, 2026-11-07); the request's spelling 'schema-only' counts too (SCHEMA_ONLY_SOURCES, pinned equal to the
|
|
227
|
+
// inventory's list by a test). The branch must carry the name asked for, a schema-only source, an expiry no later
|
|
228
|
+
// than the one asked for, and the parent asked for or, as Neon reports it, none. With no parent in the answer the
|
|
229
|
+
// branch cannot be traced to the parent asked for; the log says so rather than naming one.
|
|
230
|
+
// A data copy must name its parent: rows from an unknown branch are never used.
|
|
231
|
+
const why =
|
|
232
|
+
branch.name !== name ? `came back named ${branch.name ?? '(none)'}`
|
|
233
|
+
: !isSourceAsked(branch) ? `came back not ${dataCopy ? 'a data copy' : 'schema-only'} (init_source=${branch.init_source ?? 'none'})`
|
|
234
|
+
: dataCopy && branch.parent_id !== parent.id ? `came back ${branch.parent_id == null ? 'naming no parent' : `from another parent (${branch.parent_id})`}`
|
|
235
|
+
: branch.parent_id != null && branch.parent_id !== parent.id ? `came back from another parent (${branch.parent_id})`
|
|
236
|
+
: !Number.isFinite(Date.parse(branch.expires_at ?? '')) ? 'came back with no expiry'
|
|
237
|
+
: !expiresInTime(branch) ? `came back expiring later than asked (${branch.expires_at})`
|
|
238
|
+
: '';
|
|
239
|
+
if (why) {
|
|
240
|
+
deps.log(`neon-test-db: the branch answered (${branch.id}) ${why}; ${ours(branch) ? 'deleting it' : 'Neon does not show it as made by this run, so it is left alone'}.`);
|
|
241
|
+
return 1; // an interrupt's code is set in finally
|
|
242
|
+
}
|
|
243
|
+
const from = branch.parent_id === parent.id ? `from ${parent.name}` : `(Neon names no parent; asked for ${parent.name})`;
|
|
244
|
+
deps.log(`neon-test-db: ${name} (${branch.id}), ${dataCopy ? 'a data copy' : 'schema-only'} ${from}, expires ${branch.expires_at}.`);
|
|
245
|
+
ok = true;
|
|
246
|
+
if (interrupted) return signalExit();
|
|
247
|
+
const url = await connectionString(deps, c, branch.id, () => interrupted);
|
|
248
|
+
if (interrupted) return signalExit();
|
|
249
|
+
if (!url) return 1;
|
|
250
|
+
code = await deps.run(c.command, { ...env, DATABASE_URL: url, NEON_TEST_DB_BRANCH: name });
|
|
251
|
+
} finally {
|
|
252
|
+
// The handlers stay until the delete is done: a second Ctrl-C or a follow-up SIGTERM must not kill the cleanup.
|
|
253
|
+
if (branch?.id && !ours(branch)) {
|
|
254
|
+
deps.log(`neon-test-db: touching nothing. ${name}, if it exists, expires at ${expiresAt}.`);
|
|
255
|
+
} else if (branch?.id) {
|
|
256
|
+
if (ok && c.keep) deps.log(`neon-test-db: kept ${name} (${branch.id}); it expires ${branch.expires_at}.`);
|
|
257
|
+
else {
|
|
258
|
+
// Its own expiry, and that it holds no data, are promised only for a branch confirmed both. The log names the
|
|
259
|
+
// branch Neon reported, which is the one left behind if the delete fails.
|
|
260
|
+
const confirmed = expiresInTime(branch) && isSourceAsked(branch);
|
|
261
|
+
if (!(await remove(deps, c, branch.id, named(branch) ? branch.name : name, confirmed, dataCopy)) && ok) code = code || 1;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
stop();
|
|
265
|
+
// An interrupt at ANY point, the cleanup included, ends the run with the signal's code. Returning from finally
|
|
266
|
+
// overrides the try's own return (a refusal's 1, a run's code, a failed delete's 1) on purpose.
|
|
267
|
+
// eslint-disable-next-line no-unsafe-finally
|
|
268
|
+
if (interrupted) return signalExit();
|
|
269
|
+
}
|
|
270
|
+
// Reached only by the run itself (every refusal returned above): a failed delete after a passing run is a failure.
|
|
271
|
+
return code;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function connectionString(deps, c, branchId, stopped = () => false) {
|
|
275
|
+
let database = c.database;
|
|
276
|
+
let role = '';
|
|
277
|
+
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
278
|
+
if (stopped()) return null;
|
|
279
|
+
const dbs = await call(deps, c.key, 'GET', `/projects/${c.project}/branches/${branchId}/databases`);
|
|
280
|
+
const list = Array.isArray(dbs.body?.databases) ? dbs.body.databases : [];
|
|
281
|
+
const pick = database ? list.filter((d) => d.name === database) : list.filter((d) => d.name && d.name !== 'postgres' && d.owner_name);
|
|
282
|
+
if (dbs.ok && pick.length === 1) {
|
|
283
|
+
database = pick[0].name;
|
|
284
|
+
role = pick[0].owner_name;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
if (attempt === 30) {
|
|
288
|
+
deps.log(`neon-test-db: expected one ${database ? `database named ${database}` : 'application database'} on the branch, found ${pick.length}.`);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
await deps.sleep(2000);
|
|
292
|
+
}
|
|
293
|
+
const q = new URLSearchParams({ branch_id: branchId, database_name: database, role_name: role });
|
|
294
|
+
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
295
|
+
if (stopped()) return null;
|
|
296
|
+
const uri = await call(deps, c.key, 'GET', `/projects/${c.project}/connection_uri?${q}`);
|
|
297
|
+
const value = typeof uri.body?.uri === 'string' ? uri.body.uri : '';
|
|
298
|
+
if (uri.ok && /^postgres(ql)?:\/\//.test(value)) return value;
|
|
299
|
+
if (attempt === 30) {
|
|
300
|
+
deps.log(`neon-test-db: could not read the branch's connection string (HTTP ${uri.status || 'no answer'}).`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
await deps.sleep(2000);
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A shell's exit code for a process ended by a signal: 128 + its number (SIGINT 130, SIGTERM 143, SIGHUP 129). */
|
|
309
|
+
function signalCode(name) {
|
|
310
|
+
return 128 + (osConstants.signals[name] ?? 2);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Deletes ONLY the branch this run created, by id, retrying while Neon holds it (423). */
|
|
314
|
+
async function remove(deps, c, branchId, name, confirmed = true, dataCopy = false) {
|
|
315
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
316
|
+
const r = await call(deps, c.key, 'DELETE', `/projects/${c.project}/branches/${branchId}`);
|
|
317
|
+
if (r.ok || r.status === 404) {
|
|
318
|
+
deps.log(`neon-test-db: deleted ${name}.`);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
if (r.status !== 423 && r.status !== 0) break;
|
|
322
|
+
await deps.sleep(3000);
|
|
323
|
+
}
|
|
324
|
+
deps.log(`neon-test-db: could not delete ${name} (${branchId}); ${confirmed ? 'it expires on its own. Delete it in the Neon console to free the slot now' : `it was not confirmed to expire on its own or to hold ${dataCopy ? 'only its parent\'s rows' : 'no data'}. Delete it in the Neon console`}.`);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function realMain() {
|
|
329
|
+
// After a SIGHUP the terminal is gone and a log line to it fails (EIO); that must not kill the cleanup.
|
|
330
|
+
process.stderr.on('error', () => {});
|
|
331
|
+
return main(process.env, process.argv.slice(2), {
|
|
332
|
+
fetch: (url, init) => fetch(url, init),
|
|
333
|
+
log: (line) => console.error(line),
|
|
334
|
+
now: () => new Date(),
|
|
335
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
336
|
+
run: (command, env) =>
|
|
337
|
+
new Promise((done) => {
|
|
338
|
+
const child = spawn(command[0], command.slice(1), { stdio: 'inherit', env });
|
|
339
|
+
// SIGINT is never passed on: a Ctrl-C or a runner's cancel reaches the child through its process group, and a
|
|
340
|
+
// second one makes Vitest force-quit. SIGTERM and SIGHUP, which a runner or a closed terminal may send to this
|
|
341
|
+
// process alone, are; one sent to the whole group therefore reaches the child twice, an accepted cost (this
|
|
342
|
+
// process cannot tell the two apart). (`kill -INT <this pid>` alone leaves the child running: send it to the group.)
|
|
343
|
+
const handlers = ['SIGTERM', 'SIGHUP'].map((sig) => [sig, () => child.kill(sig)]);
|
|
344
|
+
for (const [sig, h] of handlers) process.on(sig, h);
|
|
345
|
+
const end = (code) => {
|
|
346
|
+
for (const [sig, h] of handlers) process.off(sig, h);
|
|
347
|
+
done(code);
|
|
348
|
+
};
|
|
349
|
+
child.on('error', () => end(127));
|
|
350
|
+
// A child killed by a signal exits 128 + its number (130 for SIGINT, 143 for SIGTERM), as a shell reports it.
|
|
351
|
+
child.on('exit', (code, signal) => end(code ?? (signal ? signalCode(signal) : 1)));
|
|
352
|
+
}),
|
|
353
|
+
onSignal: (fn) => {
|
|
354
|
+
// This process stays up on Ctrl-C, SIGTERM or SIGHUP (a closed terminal) to delete the branch.
|
|
355
|
+
const h = (sig) => fn(sig);
|
|
356
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, h);
|
|
357
|
+
return () => {
|
|
358
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(sig, h);
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Whether this file is the process's entry point (npm's `.bin` link included); never throws, as it runs on import. */
|
|
365
|
+
function isEntryPoint() {
|
|
366
|
+
try {
|
|
367
|
+
return Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]));
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (isEntryPoint()) {
|
|
374
|
+
realMain().then(
|
|
375
|
+
(code) => process.exit(code),
|
|
376
|
+
() => {
|
|
377
|
+
console.error('neon-test-db: crashed. It tried to delete any branch it made; one it could not delete expires on its own (tmp/test-db-*).');
|
|
378
|
+
process.exit(1);
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
}
|
package/dist/setup/README.md
CHANGED
|
@@ -26,9 +26,9 @@ A file that needs a DOM says so on its first line: `// @vitest-environment jsdom
|
|
|
26
26
|
|
|
27
27
|
| Rule | What happens |
|
|
28
28
|
|---|---|
|
|
29
|
-
| Defaults | `environment: 'node'`, 30 s test and hook timeouts, `retry: 0` (a retry is refused), `server-only` resolves to an empty module, `*.pw.ts` is excluded, the kit's setup file runs first. |
|
|
29
|
+
| Defaults | `environment: 'node'`, 30 s test and hook timeouts, `retry: 0` (a retry is refused), `server-only` resolves to an empty module, `*.pw.ts` is excluded, the kit's setup file runs first. A repo's own `include` replaces the kit's catch-all glob rather than joining it; every other list is merged. The config helper loads from a CommonJS `vitest.config.ts` too (a package without `"type": "module"`). |
|
|
30
30
|
| Credentials | Every variable whose name holds `KEY`, `TOKEN`, `SECRET`, `DSN`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `PRIVATE`, `WEBHOOK`, `COOKIE`, `SESSION` or `AUTH`, and every variable whose value is a URL with a password, is deleted. `keepEnv` keeps names; `stripEnv` deletes more. Set the placeholders your code needs in your own setup file. |
|
|
31
|
-
| Network | `fetch` and every TCP socket (`http`, `https`, `tls`, undici, database drivers) are refused unless the host is loopback, on `allowHosts` (`*.example.test` matches subdomains), or the host of a database URL that passed the check below (`allowDatabaseHosts`, default true). A refused request fails the test even when the code swallowed the error. `allowNetworkHosts(host)` adds one for the rest of a file. |
|
|
31
|
+
| Network | `fetch` and every TCP socket (`http`, `https`, `tls`, undici, database drivers) are refused unless the host is loopback, on `allowHosts` (`*.example.test` matches subdomains), or the host of a database URL that passed the check below (`allowDatabaseHosts`, default true). Only a URL the credential strip kept counts, so name the test database in `keepEnv` or set it in your own setup file. Neon's serverless HTTP driver sends a query to the region's gateway (`api.<region>.neon.tech`, the database named in a header), not to the database's host: a repo that runs it against a Neon branch lists that gateway in `allowHosts` itself, knowing it serves every database in the region (the credential strip is what keeps another database's password out). A refused request fails the test even when the code swallowed the error. `allowNetworkHosts(host)` adds one for the rest of a file. |
|
|
32
32
|
| Production databases | A Postgres URL in the environment whose host is on your production list (a host, a subdomain of it, or a compute endpoint id `ep-…` with or without `-pooler`) is refused before any test runs, and again before the tests (your own setup file may set the URL). |
|
|
33
33
|
| `.env*` files | A Postgres URL in the environment that names the same database as any `.env*` file at the root is refused: those hold a developer's own connections. Every `postgres://` text counts, comments included, except lines keyed by `testDatabaseEnv`. |
|
|
34
34
|
| React warnings | A React or Radix warning (`console.error` / `console.warn`) fails the test, also when the test muted the console (`vi.spyOn(console, 'error').mockImplementation(() => {})`, `console.error = vi.fn()`, `console.error = () => {}`). |
|
package/dist/setup/config.d.ts
CHANGED
|
@@ -45,5 +45,9 @@ export declare const SERVER_ONLY_STUB: string;
|
|
|
45
45
|
export declare function provideFor(options?: TestKitOptions): TestKitProvided;
|
|
46
46
|
/** The kit's part of a Vitest config. */
|
|
47
47
|
export declare function testKitConfig(options?: TestKitOptions): ViteUserConfig;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The kit's config merged with the repo's; refuses a retry, which hides a broken test. A repo that names its own
|
|
50
|
+
* `include` gets exactly that list: Vite's merge concatenates arrays, which would add the kit's catch-all glob and run
|
|
51
|
+
* files the repo never ran (another suite's, a script's `.test.mjs`).
|
|
52
|
+
*/
|
|
49
53
|
export declare function defineTestKitConfig(options?: TestKitOptions, repo?: ViteUserConfig): ViteUserConfig;
|
package/dist/setup/config.js
CHANGED
|
@@ -66,9 +66,15 @@ export function testKitConfig(options = {}) {
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* The kit's config merged with the repo's; refuses a retry, which hides a broken test. A repo that names its own
|
|
71
|
+
* `include` gets exactly that list: Vite's merge concatenates arrays, which would add the kit's catch-all glob and run
|
|
72
|
+
* files the repo never ran (another suite's, a script's `.test.mjs`).
|
|
73
|
+
*/
|
|
70
74
|
export function defineTestKitConfig(options = {}, repo = {}) {
|
|
71
75
|
const merged = mergeConfig(testKitConfig(options), repo);
|
|
76
|
+
if (repo.test?.include !== undefined && merged.test)
|
|
77
|
+
merged.test.include = repo.test.include;
|
|
72
78
|
const retry = merged.test?.retry;
|
|
73
79
|
if (retry !== undefined && retry !== 0) {
|
|
74
80
|
throw new Error('test-kit: `retry` must stay 0. A retry makes a broken test pass; raise a timeout instead.');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalystiq/test-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared test setup for Vitest
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, the local Neon test-database run, and the local environment guard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": true,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"./setup/config": {
|
|
24
24
|
"types": "./dist/setup/config.d.ts",
|
|
25
|
-
"import": "./dist/setup/config.js"
|
|
25
|
+
"import": "./dist/setup/config.js",
|
|
26
|
+
"default": "./dist/setup/config.js"
|
|
26
27
|
},
|
|
27
28
|
"./setup/dom": {
|
|
28
29
|
"types": "./dist/setup/dom.d.ts",
|
|
@@ -37,11 +38,22 @@
|
|
|
37
38
|
"types": "./dist/mcp-apps-host/playwright.d.ts",
|
|
38
39
|
"import": "./dist/mcp-apps-host/playwright.js"
|
|
39
40
|
},
|
|
41
|
+
"./env": {
|
|
42
|
+
"types": "./dist/env/index.d.ts",
|
|
43
|
+
"import": "./dist/env/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./neon-test-db": {
|
|
46
|
+
"types": "./dist/neon-test-db/neon-test-db.d.mts",
|
|
47
|
+
"import": "./dist/neon-test-db/neon-test-db.mjs"
|
|
48
|
+
},
|
|
40
49
|
"./package.json": "./package.json"
|
|
41
50
|
},
|
|
42
51
|
"bin": {
|
|
43
52
|
"tk-check-bundle": "dist/bin/check-bundle.js",
|
|
44
|
-
"tk-check-allowlist": "dist/bin/check-allowlist.js"
|
|
53
|
+
"tk-check-allowlist": "dist/bin/check-allowlist.js",
|
|
54
|
+
"tk-dev-guard": "dist/bin/dev-guard.js",
|
|
55
|
+
"tk-check-env-example": "dist/bin/check-env-example.js",
|
|
56
|
+
"tk-neon-test-db": "dist/neon-test-db/neon-test-db.mjs"
|
|
45
57
|
},
|
|
46
58
|
"files": [
|
|
47
59
|
"dist",
|
|
@@ -61,7 +73,8 @@
|
|
|
61
73
|
"@playwright/test": ">=1.50",
|
|
62
74
|
"@testing-library/jest-dom": ">=6",
|
|
63
75
|
"@testing-library/react": ">=16",
|
|
64
|
-
"vitest": "^4.1.0"
|
|
76
|
+
"vitest": "^4.1.0",
|
|
77
|
+
"typescript": ">=5"
|
|
65
78
|
},
|
|
66
79
|
"peerDependenciesMeta": {
|
|
67
80
|
"@playwright/test": {
|
|
@@ -75,6 +88,9 @@
|
|
|
75
88
|
},
|
|
76
89
|
"vitest": {
|
|
77
90
|
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"typescript": {
|
|
93
|
+
"optional": true
|
|
78
94
|
}
|
|
79
95
|
},
|
|
80
96
|
"devDependencies": {
|