@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
package/dist/env/scan.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which environment variables a repository's code reads, by name, and whether `.env.example` lists exactly those.
|
|
3
|
+
*
|
|
4
|
+
* The reads come from each file's syntax tree (see {@link envNamesInText}). A name built at run time
|
|
5
|
+
* (`process.env[name]`) is not seen: list it in `.env.example` under a comment saying `UNSCANNED`.
|
|
6
|
+
*
|
|
7
|
+
* The files are the repository's tracked code (`git ls-files`), less tests, fixtures, type declarations and the paths
|
|
8
|
+
* the caller excludes. Needs `typescript` installed (a peer dependency).
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { duplicateNames, parseEnvExample } from "./example.js";
|
|
15
|
+
const CAPITALS = /^[A-Z][A-Z0-9_]*$/;
|
|
16
|
+
const CODE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
17
|
+
const SKIP_SEGMENTS = new Set(['node_modules', '__tests__', '__mocks__', 'fixtures', '.next', 'dist', 'coverage']);
|
|
18
|
+
/** Test trees at the top of the repository only: `src/app/test/page.tsx` is a route. */
|
|
19
|
+
const SKIP_TOP = new Set(['test', 'tests', 'e2e']);
|
|
20
|
+
const SKIP_FILE = /\.(?:test|spec)\.[^/]+$|\.pw\.ts$|\.d\.ts$/;
|
|
21
|
+
/** npm's own variables and a runtime's internals (`__NEXT_…`) are not the app's to list. */
|
|
22
|
+
const NOT_THE_APPS = /^(?:npm_|_)/;
|
|
23
|
+
/** What Vite itself puts on `import.meta.env`. */
|
|
24
|
+
const VITE_BUILT_INS = new Set(['BASE_URL', 'DEV', 'MODE', 'PROD', 'SSR']);
|
|
25
|
+
/** TypeScript, loaded on first use, so `/env` imports (the guard) work where it is not installed. */
|
|
26
|
+
let tsModule = null;
|
|
27
|
+
function typescript() {
|
|
28
|
+
if (tsModule === null) {
|
|
29
|
+
try {
|
|
30
|
+
tsModule = createRequire(import.meta.url)('typescript');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('tk-check-env-example needs typescript installed (a peer dependency of @catalystiq/test-kit)');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return tsModule;
|
|
37
|
+
}
|
|
38
|
+
/** Whether `name`, read off that object, is an environment variable. A bare `env` object counts in capitals only. */
|
|
39
|
+
function isVariable(kind, name) {
|
|
40
|
+
if (NOT_THE_APPS.test(name))
|
|
41
|
+
return false;
|
|
42
|
+
if (kind === 'bare')
|
|
43
|
+
return CAPITALS.test(name);
|
|
44
|
+
if (kind === 'meta')
|
|
45
|
+
return !VITE_BUILT_INS.has(name);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function scriptKind(ts, fileName) {
|
|
49
|
+
if (/\.tsx$/.test(fileName))
|
|
50
|
+
return ts.ScriptKind.TSX;
|
|
51
|
+
if (/\.[mc]?ts$/.test(fileName))
|
|
52
|
+
return ts.ScriptKind.TS;
|
|
53
|
+
return ts.ScriptKind.JSX;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The variable names a source text reads, from its syntax tree (TypeScript's parser): comments, strings and regular
|
|
57
|
+
* expressions are never mistaken for code.
|
|
58
|
+
*
|
|
59
|
+
* A key may be a literal or a top-level string constant (`env[GOOGLE_CLIENT_ID_ENV]`), this file's or one in
|
|
60
|
+
* `constants` (the repository's, {@link stringConstants}).
|
|
61
|
+
*
|
|
62
|
+
* The environment is `process.env` (also `globalThis.process.env`, `process['env']`), `import.meta.env` (less Vite's
|
|
63
|
+
* own `DEV`, `MODE`, …), a name the file binds to one of those (`const e = process.env`), and any object named `env`
|
|
64
|
+
* (capitals only: `env.local` is not a variable; a file whose `env` is something else goes in `--exclude`). A read off
|
|
65
|
+
* it is a property (`.NAME`, `?.NAME`), an element with a literal key (`['NAME']`), an `in` test (`'NAME' in …`), or a
|
|
66
|
+
* destructuring (`const { NAME, OTHER: x }: T = …`, a parameter default, an assignment `({ NAME } = …)`). A method call
|
|
67
|
+
* (`process.env.toString()`) and a write (`process.env.TZ = …`, `delete process.env.X`) are not reads; a presence check
|
|
68
|
+
* (`Object.hasOwn(process.env, 'X')`, `Reflect.has(…)`, `.hasOwnProperty('X')`) is. `const { env: e } = process` makes
|
|
69
|
+
* `e` an alias.
|
|
70
|
+
*/
|
|
71
|
+
export function envNamesInText(source, fileName = 'file.ts', constants = new Map()) {
|
|
72
|
+
const ts = typescript();
|
|
73
|
+
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKind(ts, fileName));
|
|
74
|
+
const own = topLevelStrings(ts, file);
|
|
75
|
+
const aliases = new Map();
|
|
76
|
+
const unwrapped = (node) => {
|
|
77
|
+
let n = node;
|
|
78
|
+
while (ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n) || ts.isAsExpression(n) || ts.isSatisfiesExpression(n))
|
|
79
|
+
n = n.expression;
|
|
80
|
+
return n;
|
|
81
|
+
};
|
|
82
|
+
const literal = (node) => node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
|
|
83
|
+
/** `process`, or `globalThis.process`. */
|
|
84
|
+
const isProcess = (node) => {
|
|
85
|
+
const n = unwrapped(node);
|
|
86
|
+
if (ts.isIdentifier(n))
|
|
87
|
+
return n.text === 'process';
|
|
88
|
+
return ts.isPropertyAccessExpression(n) && n.name.text === 'process' && ts.isIdentifier(n.expression) && n.expression.text === 'globalThis';
|
|
89
|
+
};
|
|
90
|
+
const envObject = (node) => {
|
|
91
|
+
const n = unwrapped(node);
|
|
92
|
+
if (ts.isIdentifier(n))
|
|
93
|
+
return aliases.get(n.text) ?? (n.text === 'env' ? 'bare' : null);
|
|
94
|
+
const key = ts.isPropertyAccessExpression(n) ? n.name.text : ts.isElementAccessExpression(n) ? literal(n.argumentExpression) : undefined;
|
|
95
|
+
if (key !== 'env' || !(ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)))
|
|
96
|
+
return null;
|
|
97
|
+
const owner = n.expression;
|
|
98
|
+
if (isProcess(owner))
|
|
99
|
+
return 'process';
|
|
100
|
+
if (ts.isMetaProperty(owner) && owner.keywordToken === ts.SyntaxKind.ImportKeyword && owner.name.text === 'meta')
|
|
101
|
+
return 'meta';
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
/** A method call on the object, an assignment to it, or a `delete` of it. */
|
|
105
|
+
const isNotARead = (node) => {
|
|
106
|
+
const parent = node.parent;
|
|
107
|
+
if (ts.isCallExpression(parent) && parent.expression === node)
|
|
108
|
+
return true;
|
|
109
|
+
if (ts.isDeleteExpression(parent))
|
|
110
|
+
return true;
|
|
111
|
+
// Only a plain `=` writes without reading: `??=`, `||=` and `+=` read the variable first.
|
|
112
|
+
return ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
113
|
+
};
|
|
114
|
+
// Aliases first: `const e = process.env` makes `e.NAME` a read, for a name the file declares only once (there is no
|
|
115
|
+
// scope analysis, so a name declared twice, once as the environment and once as something else, is no alias).
|
|
116
|
+
const declared = new Map();
|
|
117
|
+
const candidates = new Map();
|
|
118
|
+
const collectAliases = (node) => {
|
|
119
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && ts.isIdentifier(node.name)) {
|
|
120
|
+
declared.set(node.name.text, (declared.get(node.name.text) ?? 0) + 1);
|
|
121
|
+
const kind = ts.isVariableDeclaration(node) && node.initializer ? envObject(node.initializer) : null;
|
|
122
|
+
if (kind === 'process' || kind === 'meta')
|
|
123
|
+
candidates.set(node.name.text, kind);
|
|
124
|
+
}
|
|
125
|
+
if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
|
|
126
|
+
declared.set(node.name.text, (declared.get(node.name.text) ?? 0) + 1);
|
|
127
|
+
// `const { env: e } = process` (or `globalThis.process`).
|
|
128
|
+
const declaration = node.parent.parent;
|
|
129
|
+
const key = node.propertyName ?? node.name;
|
|
130
|
+
if (ts.isIdentifier(key) && key.text === 'env' && ts.isVariableDeclaration(declaration) && declaration.initializer && isProcess(declaration.initializer)) {
|
|
131
|
+
candidates.set(node.name.text, 'process');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
ts.forEachChild(node, collectAliases);
|
|
135
|
+
};
|
|
136
|
+
collectAliases(file);
|
|
137
|
+
for (const [name, kind] of candidates)
|
|
138
|
+
if (declared.get(name) === 1)
|
|
139
|
+
aliases.set(name, kind);
|
|
140
|
+
/** A literal key, or a named constant holding one (`env[GOOGLE_CLIENT_ID_ENV]`): this file's, else the repository's. */
|
|
141
|
+
const keyOf = (node) => {
|
|
142
|
+
const text = literal(node);
|
|
143
|
+
if (text !== undefined || !ts.isIdentifier(node))
|
|
144
|
+
return text;
|
|
145
|
+
// A name the file declares again (a parameter `KEY`, a local `const KEY`) is not the constant.
|
|
146
|
+
const declarations = declared.get(node.text) ?? 0;
|
|
147
|
+
if (own.has(node.text))
|
|
148
|
+
return declarations === 1 ? own.get(node.text) : undefined;
|
|
149
|
+
return declarations === 0 ? constants.get(node.text) : undefined;
|
|
150
|
+
};
|
|
151
|
+
const names = new Set();
|
|
152
|
+
const add = (kind, name) => {
|
|
153
|
+
if (kind !== null && name !== undefined && isVariable(kind, name))
|
|
154
|
+
names.add(name);
|
|
155
|
+
};
|
|
156
|
+
const keyText = (key) => {
|
|
157
|
+
if (key === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
if (ts.isIdentifier(key) || ts.isStringLiteral(key))
|
|
160
|
+
return key.text;
|
|
161
|
+
if (ts.isComputedPropertyName(key))
|
|
162
|
+
return literal(key.expression);
|
|
163
|
+
return undefined;
|
|
164
|
+
};
|
|
165
|
+
const addPattern = (pattern, from) => {
|
|
166
|
+
if (!from || !ts.isObjectBindingPattern(pattern))
|
|
167
|
+
return;
|
|
168
|
+
const kind = envObject(from);
|
|
169
|
+
for (const element of pattern.elements)
|
|
170
|
+
if (!element.dotDotDotToken)
|
|
171
|
+
add(kind, keyText(element.propertyName ?? element.name));
|
|
172
|
+
};
|
|
173
|
+
/** `Object.hasOwn(env, 'X')`, `Reflect.has(env, 'X')`, `env.hasOwnProperty('X')`: a presence check is a read. */
|
|
174
|
+
const presenceCheck = (node) => {
|
|
175
|
+
const callee = unwrapped(node.expression);
|
|
176
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
177
|
+
return;
|
|
178
|
+
const method = callee.name.text;
|
|
179
|
+
const owner = unwrapped(callee.expression);
|
|
180
|
+
if (method === 'hasOwnProperty' || method === 'propertyIsEnumerable')
|
|
181
|
+
add(envObject(owner), literal(node.arguments[0]));
|
|
182
|
+
else if (ts.isIdentifier(owner) && ((owner.text === 'Object' && method === 'hasOwn') || (owner.text === 'Reflect' && method === 'has'))) {
|
|
183
|
+
const target = node.arguments[0];
|
|
184
|
+
if (target)
|
|
185
|
+
add(envObject(target), literal(node.arguments[1]));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const visit = (node) => {
|
|
189
|
+
if (ts.isCallExpression(node))
|
|
190
|
+
presenceCheck(node);
|
|
191
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
192
|
+
if (!isNotARead(node))
|
|
193
|
+
add(envObject(node.expression), node.name.text);
|
|
194
|
+
}
|
|
195
|
+
else if (ts.isElementAccessExpression(node)) {
|
|
196
|
+
if (!isNotARead(node))
|
|
197
|
+
add(envObject(node.expression), keyOf(node.argumentExpression));
|
|
198
|
+
}
|
|
199
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword) {
|
|
200
|
+
add(envObject(node.right), keyOf(node.left));
|
|
201
|
+
}
|
|
202
|
+
else if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
|
|
203
|
+
addPattern(node.name, node.initializer);
|
|
204
|
+
}
|
|
205
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(unwrapped(node.left))) {
|
|
206
|
+
const kind = envObject(node.right);
|
|
207
|
+
for (const property of unwrapped(node.left).properties) {
|
|
208
|
+
if (ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property))
|
|
209
|
+
add(kind, keyText(property.name));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
ts.forEachChild(node, visit);
|
|
213
|
+
};
|
|
214
|
+
visit(file);
|
|
215
|
+
return [...names].sort();
|
|
216
|
+
}
|
|
217
|
+
/** A file's top-level `const NAME = 'text'` (exported or not, `as const` or not), by name. */
|
|
218
|
+
function topLevelStrings(ts, file) {
|
|
219
|
+
const found = new Map();
|
|
220
|
+
for (const statement of file.statements) {
|
|
221
|
+
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const))
|
|
222
|
+
continue;
|
|
223
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
224
|
+
let init = declaration.initializer;
|
|
225
|
+
while (init && (ts.isAsExpression(init) || ts.isSatisfiesExpression(init) || ts.isParenthesizedExpression(init)))
|
|
226
|
+
init = init.expression;
|
|
227
|
+
if (ts.isIdentifier(declaration.name) && init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init))) {
|
|
228
|
+
found.set(declaration.name.text, init.text);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The repository's top-level string constants, by name, so a read through a constant another file exports
|
|
236
|
+
* (`process.env[SECRET_ENV]`) is seen. A name two files define with different text resolves to nothing.
|
|
237
|
+
*/
|
|
238
|
+
export function stringConstants(texts) {
|
|
239
|
+
const ts = typescript();
|
|
240
|
+
const found = new Map();
|
|
241
|
+
for (const [fileName, text] of texts) {
|
|
242
|
+
const file = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false, scriptKind(ts, fileName));
|
|
243
|
+
for (const [name, value] of topLevelStrings(ts, file))
|
|
244
|
+
found.set(name, found.has(name) && found.get(name) !== value ? null : value);
|
|
245
|
+
}
|
|
246
|
+
return new Map([...found].filter((entry) => entry[1] !== null));
|
|
247
|
+
}
|
|
248
|
+
/** Whether a repository-relative path is code the scan reads. */
|
|
249
|
+
export function isScannedPath(path, exclude = []) {
|
|
250
|
+
if (!CODE.test(path) || SKIP_FILE.test(path))
|
|
251
|
+
return false;
|
|
252
|
+
const segments = path.split('/');
|
|
253
|
+
if (SKIP_TOP.has(segments[0]) || segments.some((segment) => SKIP_SEGMENTS.has(segment)))
|
|
254
|
+
return false;
|
|
255
|
+
return !exclude.map(normalisedPrefix).some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
256
|
+
}
|
|
257
|
+
/** `./scripts/one-off/` as git spells it: `scripts/one-off`. */
|
|
258
|
+
function normalisedPrefix(prefix) {
|
|
259
|
+
return prefix.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
260
|
+
}
|
|
261
|
+
/** The code files of the repository at `root`: tracked, or new and not ignored. */
|
|
262
|
+
export function scannedFiles(root, exclude = []) {
|
|
263
|
+
// Tracked files and new ones git does not ignore, so a file not yet added is scanned too.
|
|
264
|
+
const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
265
|
+
const paths = listed
|
|
266
|
+
.split('\0')
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.filter((path) => isScannedPath(path, exclude));
|
|
269
|
+
return [...new Set(paths)].sort();
|
|
270
|
+
}
|
|
271
|
+
/** Each name read, with the first file that reads it. */
|
|
272
|
+
export function envReads(root, files) {
|
|
273
|
+
const texts = [];
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
try {
|
|
276
|
+
texts.push([file, readFileSync(join(root, file), 'utf8')]);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// listed by git but deleted in the working tree
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const constants = stringConstants(texts);
|
|
283
|
+
const reads = new Map();
|
|
284
|
+
for (const [file, text] of texts)
|
|
285
|
+
for (const name of envNamesInText(text, file, constants))
|
|
286
|
+
if (!reads.has(name))
|
|
287
|
+
reads.set(name, file);
|
|
288
|
+
return reads;
|
|
289
|
+
}
|
|
290
|
+
/** Compares `.env.example` with what the code reads. Every problem names variables, never a value. */
|
|
291
|
+
export function checkEnvExample(root, example, exclude = []) {
|
|
292
|
+
let text;
|
|
293
|
+
try {
|
|
294
|
+
text = readFileSync(join(root, example), 'utf8');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return { problems: [`${example} does not exist`], files: 0, read: 0, listed: 0 };
|
|
298
|
+
}
|
|
299
|
+
const entries = parseEnvExample(text);
|
|
300
|
+
const all = scannedFiles(root);
|
|
301
|
+
const files = all.filter((path) => isScannedPath(path, exclude));
|
|
302
|
+
const reads = envReads(root, files);
|
|
303
|
+
const problems = [];
|
|
304
|
+
if (files.length === 0)
|
|
305
|
+
problems.push('the scan found no code files: the check would pass on nothing');
|
|
306
|
+
else if (reads.size === 0)
|
|
307
|
+
problems.push(`the scan found no environment read in ${files.length} files: the check would pass on nothing`);
|
|
308
|
+
if (entries.length === 0)
|
|
309
|
+
problems.push(`${example} names no variable`);
|
|
310
|
+
for (const prefix of exclude) {
|
|
311
|
+
if (all.every((path) => isScannedPath(path, [prefix])))
|
|
312
|
+
problems.push(`--exclude ${prefix} matches no scanned file`);
|
|
313
|
+
}
|
|
314
|
+
for (const name of duplicateNames(entries))
|
|
315
|
+
problems.push(`${name} is listed more than once in ${example}`);
|
|
316
|
+
const listed = new Map(entries.map((e) => [e.name, e]));
|
|
317
|
+
for (const [name, file] of [...reads].sort(([a], [b]) => a.localeCompare(b))) {
|
|
318
|
+
if (!listed.has(name))
|
|
319
|
+
problems.push(`${name} is read (${file}) but not listed in ${example}`);
|
|
320
|
+
}
|
|
321
|
+
for (const e of entries) {
|
|
322
|
+
if (!e.unscanned && !reads.has(e.name) && listed.get(e.name) === e) {
|
|
323
|
+
problems.push(`${e.name} is listed in ${example} (line ${e.line}) but no code reads it: remove it, or say UNSCANNED in its comment`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { problems, files: files.length, read: reads.size, listed: listed.size };
|
|
327
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# `tk-neon-test-db`
|
|
2
|
+
|
|
3
|
+
The local database test run, `npm run test:db`: a short-lived Neon branch for one command, deleted afterwards.
|
|
4
|
+
|
|
5
|
+
It moved here from `getcatalystiq/github-actions` (`scripts/neon-test-db.mjs`), which every app vendored unchanged. The
|
|
6
|
+
script is the same; apps now run it from the package.
|
|
7
|
+
|
|
8
|
+
## Use it
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"devDependencies": { "@catalystiq/test-kit": "<exact version>" },
|
|
13
|
+
"scripts": { "test:db": "NEON_TEST_DB_PROJECT=… NEON_TEST_DB_PARENT=… NEON_TEST_DB_REFUSE=… tk-neon-test-db -- npm run ci:db" }
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- It **cuts** a short-lived, **schema-only** branch of `NEON_TEST_DB_PARENT` (the parent's schema, none of its rows, so
|
|
20
|
+
never production data), runs the command after `--` with `DATABASE_URL` set to that branch, and **deletes** the branch
|
|
21
|
+
it made whatever the command did, a Ctrl-C included. It deletes nothing else: the delete names the id this run created.
|
|
22
|
+
- Under the kit's Vitest setup, `DATABASE_URL` holds a password, so the credential strip deletes it in each worker unless
|
|
23
|
+
the config lists it in `keepEnv` (`defineTestKitConfig({ keepEnv: ['DATABASE_URL'] })`), which also lets the tests
|
|
24
|
+
reach its host. Neon's serverless HTTP driver queries the region's gateway (`api.<region>.neon.tech`) instead, so a
|
|
25
|
+
suite that uses it lists that host in `allowHosts` too (the setup README, Network).
|
|
26
|
+
- The branch carries an **expiry** (default 2 hours, at most 6), so a branch whose process died is collected by Neon.
|
|
27
|
+
- It **refuses** a project listed in `NEON_TEST_DB_REFUSE` (name production's) before any call.
|
|
28
|
+
- The connection string is never printed. The branch is named `tmp/test-db-<user>-<UTC yyyymmddhhmmss>-<4 hex>`.
|
|
29
|
+
- `NEON_TEST_DB_INIT=parent-data` asks for a **data** copy instead, only of a parent whose branch id (never a name) is
|
|
30
|
+
listed in `NEON_TEST_DB_DATA_PARENTS`: the parents the repository names as customer-free. The branch must come back as
|
|
31
|
+
a data copy of that parent, or it is deleted unused.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
From the environment (a repository's `.env.local` is not read; export them or use `dotenv --`):
|
|
36
|
+
|
|
37
|
+
| Variable | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `NEON_API_KEY` | A key that reaches the project (a personal key, or `neonctl`'s token for a short run). |
|
|
40
|
+
| `NEON_TEST_DB_PROJECT` | The Neon project id. |
|
|
41
|
+
| `NEON_TEST_DB_PARENT` | The parent branch id (`br-…`) or name whose schema is copied. Never production's. |
|
|
42
|
+
| `NEON_TEST_DB_REFUSE` | Project ids this command refuses outright (name production). |
|
|
43
|
+
| `NEON_TEST_DB_DATABASE` | The database to connect to (default: the branch's one database that is not `postgres`). |
|
|
44
|
+
| `NEON_TEST_DB_EXPIRES` | Minutes until Neon deletes the branch on its own (default 120, at most 360). |
|
|
45
|
+
| `NEON_TEST_DB_KEEP=1` | Keep the branch afterwards (it still expires); prints its id, never its password. |
|
|
46
|
+
| `NEON_TEST_DB_INIT` | `schema-only` (the default) or `parent-data` (above). |
|
|
47
|
+
| `NEON_TEST_DB_DATA_PARENTS` | The branch ids `parent-data` may copy. |
|
|
48
|
+
|
|
49
|
+
Exit codes: the command's own; 1 when the branch could not be made, checked or deleted; 2 for a configuration problem;
|
|
50
|
+
128 + the signal number after a signal (130 for a Ctrl-C).
|
|
51
|
+
|
|
52
|
+
The module also exports `readConfig`, `branchName` and `main` (`@catalystiq/test-kit/neon-test-db`), for a repository
|
|
53
|
+
that wants to test its own `test:db` wiring.
|
|
@@ -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>;
|