@catalystiq/test-kit 0.0.0-stage → 0.2.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/dist/bin/check-allowlist.d.ts +2 -0
  4. package/dist/bin/check-allowlist.js +23 -0
  5. package/dist/bin/check-bundle.d.ts +2 -0
  6. package/dist/bin/check-bundle.js +19 -0
  7. package/dist/bundle/allow-list-base.d.ts +3 -0
  8. package/dist/bundle/allow-list-base.js +27 -0
  9. package/dist/bundle/manifest.d.ts +29 -0
  10. package/dist/bundle/manifest.js +124 -0
  11. package/dist/mcp-apps-host/README.md +70 -0
  12. package/dist/mcp-apps-host/index.d.ts +3 -0
  13. package/dist/mcp-apps-host/index.js +3 -0
  14. package/dist/mcp-apps-host/memory.d.ts +37 -0
  15. package/dist/mcp-apps-host/memory.js +158 -0
  16. package/dist/mcp-apps-host/playwright.d.ts +60 -0
  17. package/dist/mcp-apps-host/playwright.js +190 -0
  18. package/dist/mcp-apps-host/protocol.d.ts +139 -0
  19. package/dist/mcp-apps-host/protocol.js +272 -0
  20. package/dist/neon-test-db/README.md +53 -0
  21. package/dist/neon-test-db/neon-test-db.d.mts +35 -0
  22. package/dist/neon-test-db/neon-test-db.mjs +381 -0
  23. package/dist/setup/README.md +54 -0
  24. package/dist/setup/allow-list.d.ts +14 -0
  25. package/dist/setup/allow-list.js +62 -0
  26. package/dist/setup/config.d.ts +53 -0
  27. package/dist/setup/config.js +88 -0
  28. package/dist/setup/credentials.d.ts +20 -0
  29. package/dist/setup/credentials.js +38 -0
  30. package/dist/setup/database.d.ts +28 -0
  31. package/dist/setup/database.js +135 -0
  32. package/dist/setup/dom.d.ts +16 -0
  33. package/dist/setup/dom.js +71 -0
  34. package/dist/setup/index.d.ts +6 -0
  35. package/dist/setup/index.js +6 -0
  36. package/dist/setup/network.d.ts +11 -0
  37. package/dist/setup/network.js +109 -0
  38. package/dist/setup/react-warnings.d.ts +22 -0
  39. package/dist/setup/react-warnings.js +168 -0
  40. package/dist/setup/server-only-stub.d.ts +1 -0
  41. package/dist/setup/server-only-stub.js +2 -0
  42. package/dist/setup/setup-file.d.ts +1 -0
  43. package/dist/setup/setup-file.js +44 -0
  44. package/package.json +107 -4
@@ -0,0 +1,35 @@
1
+ // Types for neon-test-db.mjs, the `tk-neon-test-db` CLI (see README.md beside it). The script stays plain JavaScript so
2
+ // it runs with no build step and is the same file github-actions' apps ran as a vendored copy.
3
+
4
+ export declare const SCHEMA_ONLY_SOURCES: string[];
5
+ export declare const INIT_SOURCES: string[];
6
+ export declare const NEON_API: string;
7
+ export declare const MAX_EXPIRES: number;
8
+
9
+ export interface NeonTestDbConfig {
10
+ problems: string[];
11
+ key: string;
12
+ project: string;
13
+ parent: string;
14
+ expires: number;
15
+ database: string;
16
+ init: string;
17
+ dataParents: string[];
18
+ keep: boolean;
19
+ command: string[];
20
+ }
21
+
22
+ export interface NeonTestDbDeps {
23
+ /** What the script reads of an answer: `ok`, `status` and `json()`. */
24
+ fetch: (url: string, init: RequestInit) => Promise<Pick<Response, 'ok' | 'status' | 'json'>>;
25
+ run: (command: string[], env: Record<string, string | undefined>) => Promise<number>;
26
+ log: (line: string) => void;
27
+ now: () => Date;
28
+ sleep: (ms: number) => Promise<void>;
29
+ onSignal: (handler: (signal?: string) => void) => () => void;
30
+ }
31
+
32
+ export declare function readConfig(env: Record<string, string | undefined>, argv: string[]): NeonTestDbConfig;
33
+ export declare function branchName(user: string | undefined, now: Date, rand: string): string;
34
+ /** Runs the whole flow; returns the exit code. */
35
+ export declare function main(env: Record<string, string | undefined>, argv: string[], deps: NeonTestDbDeps): Promise<number>;
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ # `@catalystiq/test-kit/setup`
2
+
3
+ The shared Vitest setup: one config helper and one worker setup file that every test file runs first.
4
+
5
+ ## Use it
6
+
7
+ ```ts
8
+ // vitest.config.ts
9
+ import { defineTestKitConfig } from '@catalystiq/test-kit/setup/config';
10
+
11
+ export default defineTestKitConfig(
12
+ {
13
+ productionDatabaseHostsFile: 'db/production-hosts.json', // your production hosts, kept in your repo
14
+ keepEnv: ['TEST_DATABASE_URL'],
15
+ testDatabaseEnv: ['TEST_DATABASE_URL'],
16
+ allowHosts: [],
17
+ reactWarningAllowList: 'react-warnings.allow.json',
18
+ },
19
+ { test: { setupFiles: ['./vitest.setup.ts'] } }, // your own setup runs after the kit's
20
+ );
21
+ ```
22
+
23
+ A file that needs a DOM says so on its first line: `// @vitest-environment jsdom`.
24
+
25
+ ## What it does, for every test file
26
+
27
+ | Rule | What happens |
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. 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
+ | 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). 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
+ | 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
+ | `.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
+ | 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 = () => {}`). |
35
+ | DOM files | Under jsdom: a `matchMedia` stub, `localStorage` / `sessionStorage` re-bound on Node 26, Testing Library's `cleanup` after each test, and jest-dom's matchers when installed. |
36
+
37
+ ## Allowing a React warning
38
+
39
+ In one test, while you mean to provoke it:
40
+
41
+ ```ts
42
+ import { allowReactWarnings } from '@catalystiq/test-kit/setup';
43
+ allowReactWarnings(/not wrapped in act\(/);
44
+ ```
45
+
46
+ Across the repo, for warnings not fixed yet, `react-warnings.allow.json`:
47
+
48
+ ```json
49
+ { "max": 1, "entries": [{ "pattern": "not wrapped in act\\(", "file": "src/cart/", "reason": "cart timers, being fixed" }] }
50
+ ```
51
+
52
+ `max` must equal the number of entries, and the list only shrinks: run `tk-check-allowlist react-warnings.allow.json`
53
+ in CI (it needs the base branch fetched); it fails when an entry was added or `max` rose against `origin/main`
54
+ (`--base` to change it).
@@ -0,0 +1,14 @@
1
+ export interface AllowListEntry {
2
+ pattern: string;
3
+ reason: string;
4
+ file?: string;
5
+ }
6
+ export interface AllowList {
7
+ max: number;
8
+ entries: AllowListEntry[];
9
+ }
10
+ /** Validates a parsed allow-list; throws naming the first problem. */
11
+ export declare function parseAllowList(value: unknown, source?: string): AllowList;
12
+ export declare function readAllowList(path: string): AllowList;
13
+ /** The problems with `current` against `base` (the file as the base branch has it); empty when it only shrank. */
14
+ export declare function allowListGrowth(current: AllowList, base: AllowList | null): string[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The repo's React-warning allow-list: the warnings it has not fixed yet. A JSON file:
3
+ *
4
+ * { "max": 2, "entries": [{ "pattern": "not wrapped in act\\(", "file": "src/cart/", "reason": "…" }] }
5
+ *
6
+ * Its entries only shrink. `max` must equal the number of entries, so removing one means lowering `max` in the same
7
+ * change and adding one means raising it, which a reviewer sees; `tk-check-allowlist` fails a change whose file has an
8
+ * entry its base (`origin/main`) did not have, or a larger `max`.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ /** Validates a parsed allow-list; throws naming the first problem. */
12
+ export function parseAllowList(value, source = 'the React-warning allow-list') {
13
+ if (!value || typeof value !== 'object')
14
+ throw new Error(`${source} must be a JSON object { max, entries }`);
15
+ const { max, entries } = value;
16
+ if (!Array.isArray(entries))
17
+ throw new Error(`${source}: "entries" must be an array`);
18
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 0)
19
+ throw new Error(`${source}: "max" must be a whole number`);
20
+ if (max !== entries.length) {
21
+ throw new Error(`${source}: "max" is ${max} but there are ${entries.length} entries. It must equal the number of entries: lower it when you remove one.`);
22
+ }
23
+ const seen = new Set();
24
+ const parsed = entries.map((raw, i) => {
25
+ const e = raw;
26
+ if (!e || typeof e.pattern !== 'string' || e.pattern === '')
27
+ throw new Error(`${source}: entry ${i} needs a "pattern"`);
28
+ if (typeof e.reason !== 'string' || e.reason.trim() === '')
29
+ throw new Error(`${source}: entry ${i} needs a "reason"`);
30
+ if (e.file !== undefined && typeof e.file !== 'string')
31
+ throw new Error(`${source}: entry ${i}'s "file" must be a string`);
32
+ try {
33
+ new RegExp(e.pattern);
34
+ }
35
+ catch {
36
+ throw new Error(`${source}: entry ${i}'s pattern is not a valid regular expression`);
37
+ }
38
+ const key = `${e.pattern}\u0000${e.file ?? ''}`;
39
+ if (seen.has(key))
40
+ throw new Error(`${source}: entry ${i} repeats an earlier one`);
41
+ seen.add(key);
42
+ return { pattern: e.pattern, reason: e.reason, ...(e.file === undefined ? {} : { file: e.file }) };
43
+ });
44
+ return { max, entries: parsed };
45
+ }
46
+ export function readAllowList(path) {
47
+ return parseAllowList(JSON.parse(readFileSync(path, 'utf8')), path);
48
+ }
49
+ /** The problems with `current` against `base` (the file as the base branch has it); empty when it only shrank. */
50
+ export function allowListGrowth(current, base) {
51
+ if (base === null)
52
+ return [];
53
+ const problems = [];
54
+ if (current.max > base.max)
55
+ problems.push(`"max" rose from ${base.max} to ${current.max}`);
56
+ const before = new Set(base.entries.map((e) => `${e.pattern}\u0000${e.file ?? ''}`));
57
+ for (const e of current.entries) {
58
+ if (!before.has(`${e.pattern}\u0000${e.file ?? ''}`))
59
+ problems.push(`a new entry: /${e.pattern}/${e.file ? ` in ${e.file}` : ''}`);
60
+ }
61
+ return problems;
62
+ }
@@ -0,0 +1,53 @@
1
+ import { type ViteUserConfig } from 'vitest/config';
2
+ export interface TestKitOptions {
3
+ /** The repository root, where the `.env*` files are. Default: `process.cwd()`. */
4
+ root?: string;
5
+ /** Hosts a test may reach besides loopback (`*.example.test` matches subdomains). */
6
+ allowHosts?: string[];
7
+ /** Let a test reach the host of each database URL that passed the production check. Default: true. */
8
+ allowDatabaseHosts?: boolean;
9
+ /** Variables the credential strip keeps (the test database URL, a key a global setup needs). */
10
+ keepEnv?: string[];
11
+ /** Variables the strip deletes although their names look harmless (`PGHOST`, `VERCEL_ENV`). */
12
+ stripEnv?: string[];
13
+ /** Production database hosts or endpoint ids. */
14
+ productionDatabaseHosts?: string[];
15
+ /** A JSON file holding more of them: an array, or `{ "hosts": [...] }`. Relative to `root`. */
16
+ productionDatabaseHostsFile?: string;
17
+ /** `.env*` keys that name the TEST database, whose lines do not protect what they name. */
18
+ testDatabaseEnv?: string[];
19
+ /** The React-warning allow-list file, relative to `root` (see the README). */
20
+ reactWarningAllowList?: string;
21
+ }
22
+ /** What the workers receive. */
23
+ export interface TestKitProvided {
24
+ allowHosts: string[];
25
+ allowDatabaseHosts: boolean;
26
+ keepEnv: string[];
27
+ stripEnv: string[];
28
+ productionHosts: string[];
29
+ protectedPlaces: string[];
30
+ allowedWarnings: Array<{
31
+ pattern: string;
32
+ file?: string;
33
+ }>;
34
+ }
35
+ declare module 'vitest' {
36
+ interface ProvidedContext {
37
+ testKit: TestKitProvided;
38
+ }
39
+ }
40
+ /** The kit's worker setup file. */
41
+ export declare const SETUP_FILE: string;
42
+ /** The empty module `server-only` resolves to. */
43
+ export declare const SERVER_ONLY_STUB: string;
44
+ /** What the workers get, from the options. */
45
+ export declare function provideFor(options?: TestKitOptions): TestKitProvided;
46
+ /** The kit's part of a Vitest config. */
47
+ export declare function testKitConfig(options?: TestKitOptions): ViteUserConfig;
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
+ */
53
+ export declare function defineTestKitConfig(options?: TestKitOptions, repo?: ViteUserConfig): ViteUserConfig;