@catalystiq/test-kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/bin/check-allowlist.d.ts +2 -0
- package/dist/bin/check-allowlist.js +23 -0
- package/dist/bin/check-bundle.d.ts +2 -0
- package/dist/bin/check-bundle.js +19 -0
- package/dist/bundle/allow-list-base.d.ts +3 -0
- package/dist/bundle/allow-list-base.js +27 -0
- package/dist/bundle/manifest.d.ts +29 -0
- package/dist/bundle/manifest.js +124 -0
- package/dist/mcp-apps-host/README.md +70 -0
- package/dist/mcp-apps-host/index.d.ts +3 -0
- package/dist/mcp-apps-host/index.js +3 -0
- package/dist/mcp-apps-host/memory.d.ts +37 -0
- package/dist/mcp-apps-host/memory.js +158 -0
- package/dist/mcp-apps-host/playwright.d.ts +60 -0
- package/dist/mcp-apps-host/playwright.js +190 -0
- package/dist/mcp-apps-host/protocol.d.ts +139 -0
- package/dist/mcp-apps-host/protocol.js +272 -0
- package/dist/setup/README.md +54 -0
- package/dist/setup/allow-list.d.ts +14 -0
- package/dist/setup/allow-list.js +62 -0
- package/dist/setup/config.d.ts +49 -0
- package/dist/setup/config.js +82 -0
- package/dist/setup/credentials.d.ts +20 -0
- package/dist/setup/credentials.js +38 -0
- package/dist/setup/database.d.ts +28 -0
- package/dist/setup/database.js +135 -0
- package/dist/setup/dom.d.ts +16 -0
- package/dist/setup/dom.js +71 -0
- package/dist/setup/index.d.ts +6 -0
- package/dist/setup/index.js +6 -0
- package/dist/setup/network.d.ts +11 -0
- package/dist/setup/network.js +109 -0
- package/dist/setup/react-warnings.d.ts +22 -0
- package/dist/setup/react-warnings.js +168 -0
- package/dist/setup/server-only-stub.d.ts +1 -0
- package/dist/setup/server-only-stub.js +2 -0
- package/dist/setup/setup-file.d.ts +1 -0
- package/dist/setup/setup-file.js +44 -0
- package/package.json +101 -4
|
@@ -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,49 @@
|
|
|
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
|
+
/** The kit's config merged with the repo's; refuses a retry, which hides a broken test. */
|
|
49
|
+
export declare function defineTestKitConfig(options?: TestKitOptions, repo?: ViteUserConfig): ViteUserConfig;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Vitest defaults, for a repo's `vitest.config.ts`:
|
|
3
|
+
*
|
|
4
|
+
* import { defineTestKitConfig } from '@catalystiq/test-kit/setup/config';
|
|
5
|
+
* export default defineTestKitConfig(
|
|
6
|
+
* { productionDatabaseHostsFile: 'db/production-hosts.json', keepEnv: ['TEST_DATABASE_URL'] },
|
|
7
|
+
* { test: { setupFiles: ['./vitest.setup.ts'] } },
|
|
8
|
+
* );
|
|
9
|
+
*
|
|
10
|
+
* Node environment (a DOM test asks for jsdom on its first line), 30 s test and hook timeouts, no retries, `server-only`
|
|
11
|
+
* aliased to an empty module, `*.pw.ts` (Playwright) excluded, and the kit's setup file FIRST, before the repo's own.
|
|
12
|
+
* This module runs in the main process: it reads the `.env*` files and the allow-lists once and hands the workers what
|
|
13
|
+
* they need through Vitest's `provide`.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { mergeConfig } from 'vitest/config';
|
|
19
|
+
import { readAllowList } from "./allow-list.js";
|
|
20
|
+
import { protectedPlaces } from "./database.js";
|
|
21
|
+
const EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
22
|
+
/** The kit's worker setup file. */
|
|
23
|
+
export const SETUP_FILE = fileURLToPath(new URL(`./setup-file${EXT}`, import.meta.url));
|
|
24
|
+
/** The empty module `server-only` resolves to. */
|
|
25
|
+
export const SERVER_ONLY_STUB = fileURLToPath(new URL(`./server-only-stub${EXT}`, import.meta.url));
|
|
26
|
+
function readHostsFile(path) {
|
|
27
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
const list = Array.isArray(value) ? value : value?.hosts;
|
|
29
|
+
if (!Array.isArray(list) || !list.every((h) => typeof h === 'string')) {
|
|
30
|
+
throw new Error(`test-kit: ${path} must be a JSON array of hosts, or { "hosts": [...] }`);
|
|
31
|
+
}
|
|
32
|
+
return list;
|
|
33
|
+
}
|
|
34
|
+
/** What the workers get, from the options. */
|
|
35
|
+
export function provideFor(options = {}) {
|
|
36
|
+
const root = options.root ?? process.cwd();
|
|
37
|
+
const at = (path) => (isAbsolute(path) ? path : resolve(root, path));
|
|
38
|
+
const productionHosts = [
|
|
39
|
+
...(options.productionDatabaseHosts ?? []),
|
|
40
|
+
...(options.productionDatabaseHostsFile ? readHostsFile(at(options.productionDatabaseHostsFile)) : []),
|
|
41
|
+
];
|
|
42
|
+
const allowList = options.reactWarningAllowList ? readAllowList(at(options.reactWarningAllowList)) : null;
|
|
43
|
+
return {
|
|
44
|
+
allowHosts: options.allowHosts ?? [],
|
|
45
|
+
allowDatabaseHosts: options.allowDatabaseHosts ?? true,
|
|
46
|
+
keepEnv: options.keepEnv ?? [],
|
|
47
|
+
stripEnv: options.stripEnv ?? [],
|
|
48
|
+
productionHosts,
|
|
49
|
+
protectedPlaces: protectedPlaces(root, options.testDatabaseEnv ?? []),
|
|
50
|
+
allowedWarnings: (allowList?.entries ?? []).map((e) => ({ pattern: e.pattern, ...(e.file ? { file: e.file } : {}) })),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The kit's part of a Vitest config. */
|
|
54
|
+
export function testKitConfig(options = {}) {
|
|
55
|
+
return {
|
|
56
|
+
resolve: { alias: { 'server-only': SERVER_ONLY_STUB } },
|
|
57
|
+
test: {
|
|
58
|
+
environment: 'node',
|
|
59
|
+
testTimeout: 30_000,
|
|
60
|
+
hookTimeout: 30_000,
|
|
61
|
+
retry: 0,
|
|
62
|
+
include: ['**/*.test.{ts,tsx,mts,cts,js,jsx,mjs}'],
|
|
63
|
+
exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/*.pw.ts'],
|
|
64
|
+
setupFiles: [SETUP_FILE],
|
|
65
|
+
provide: { testKit: provideFor(options) },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The kit's config merged with the repo's; refuses a retry, which hides a broken test. */
|
|
70
|
+
export function defineTestKitConfig(options = {}, repo = {}) {
|
|
71
|
+
const merged = mergeConfig(testKitConfig(options), repo);
|
|
72
|
+
const retry = merged.test?.retry;
|
|
73
|
+
if (retry !== undefined && retry !== 0) {
|
|
74
|
+
throw new Error('test-kit: `retry` must stay 0. A retry makes a broken test pass; raise a timeout instead.');
|
|
75
|
+
}
|
|
76
|
+
const setupFiles = merged.test?.setupFiles;
|
|
77
|
+
const list = Array.isArray(setupFiles) ? setupFiles : setupFiles ? [setupFiles] : [];
|
|
78
|
+
if (list[0] !== SETUP_FILE) {
|
|
79
|
+
throw new Error("test-kit: the kit's setup file must run first, before the repo's own setupFiles.");
|
|
80
|
+
}
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential strip. Every exported variable whose NAME says it holds a credential, and every variable whose VALUE
|
|
3
|
+
* is a URL carrying a password, is deleted before a test file runs. A hand-kept list of names drifts (the next vendor's
|
|
4
|
+
* variable is missing from it), so the rule is a pattern; a repo keeps what it needs by name (`keepEnv`) and sets its
|
|
5
|
+
* own placeholders after the kit's setup file.
|
|
6
|
+
*/
|
|
7
|
+
/** A variable whose name says it holds a credential. */
|
|
8
|
+
export declare const CREDENTIAL_ENV_NAME: RegExp;
|
|
9
|
+
/** Whether `value` is a URL with a password in it (`postgres://u:pw@host/db`, `redis://:pw@host`). */
|
|
10
|
+
export declare function urlHasPassword(value: string | undefined): boolean;
|
|
11
|
+
export interface StripOptions {
|
|
12
|
+
/** Names never deleted, whatever they hold (a test database URL, a fixture secret the repo sets itself). */
|
|
13
|
+
keep?: readonly string[];
|
|
14
|
+
/** Names deleted although the rule would keep them (`PGHOST`, `VERCEL_ENV`). */
|
|
15
|
+
extra?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/** Whether the strip deletes this variable. */
|
|
18
|
+
export declare function isStrippedVariable(name: string, value: string | undefined, options?: StripOptions): boolean;
|
|
19
|
+
/** Deletes every stripped variable from `env` in place, and answers the names it deleted (never the values). */
|
|
20
|
+
export declare function stripCredentials(env: NodeJS.ProcessEnv, options?: StripOptions): string[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential strip. Every exported variable whose NAME says it holds a credential, and every variable whose VALUE
|
|
3
|
+
* is a URL carrying a password, is deleted before a test file runs. A hand-kept list of names drifts (the next vendor's
|
|
4
|
+
* variable is missing from it), so the rule is a pattern; a repo keeps what it needs by name (`keepEnv`) and sets its
|
|
5
|
+
* own placeholders after the kit's setup file.
|
|
6
|
+
*/
|
|
7
|
+
/** A variable whose name says it holds a credential. */
|
|
8
|
+
export const CREDENTIAL_ENV_NAME = /(KEY|TOKEN|SECRET|DSN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|WEBHOOK|COOKIE|SESSION|AUTH)/i;
|
|
9
|
+
/** Whether `value` is a URL with a password in it (`postgres://u:pw@host/db`, `redis://:pw@host`). */
|
|
10
|
+
export function urlHasPassword(value) {
|
|
11
|
+
if (!value || !value.includes('://'))
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
return new URL(value).password !== '';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Whether the strip deletes this variable. */
|
|
21
|
+
export function isStrippedVariable(name, value, options = {}) {
|
|
22
|
+
if (options.keep?.includes(name))
|
|
23
|
+
return false;
|
|
24
|
+
if (options.extra?.includes(name))
|
|
25
|
+
return true;
|
|
26
|
+
return CREDENTIAL_ENV_NAME.test(name) || urlHasPassword(value);
|
|
27
|
+
}
|
|
28
|
+
/** Deletes every stripped variable from `env` in place, and answers the names it deleted (never the values). */
|
|
29
|
+
export function stripCredentials(env, options = {}) {
|
|
30
|
+
const removed = [];
|
|
31
|
+
for (const name of Object.keys(env)) {
|
|
32
|
+
if (isStrippedVariable(name, env[name], options)) {
|
|
33
|
+
delete env[name];
|
|
34
|
+
removed.push(name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return removed.sort();
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
|
|
2
|
+
export declare function endpointOf(host: string): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* The one database a URL names, as a comparable key: `endpoint:<id>` for an endpoint host, else
|
|
5
|
+
* `<host>:<port>/<database>` with every loopback name read as `localhost`. Null when the text is not a Postgres URL.
|
|
6
|
+
*/
|
|
7
|
+
export declare function databasePlace(url: string): string | null;
|
|
8
|
+
/** Every Postgres URL in a text. */
|
|
9
|
+
export declare function postgresUrlsIn(text: string): string[];
|
|
10
|
+
/** The names of the `.env*` files at `root` (`.env`, `.env.local`, `.env.production`, …), sorted. */
|
|
11
|
+
export declare function envFilesAt(root: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* The places every `.env*` file at `root` names, but the lines whose key is in `testDatabaseEnv` (`KEY=…` or
|
|
14
|
+
* `export KEY=…`, with or without spaces).
|
|
15
|
+
*/
|
|
16
|
+
export declare function protectedPlaces(root: string, testDatabaseEnv?: readonly string[]): string[];
|
|
17
|
+
/** Whether `host` is on the production list. */
|
|
18
|
+
export declare function isProductionHost(host: string, productionHosts: readonly string[]): boolean;
|
|
19
|
+
export interface DatabaseRules {
|
|
20
|
+
productionHosts: readonly string[];
|
|
21
|
+
protectedPlaces: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
/** Throws when `url` names a production host or a database a `.env*` file names. The message never carries the URL. */
|
|
24
|
+
export declare function assertSafeDatabaseUrl(url: string, rules: DatabaseRules, label?: string): void;
|
|
25
|
+
/** Runs {@link assertSafeDatabaseUrl} over every Postgres URL any variable of `env` holds. */
|
|
26
|
+
export declare function assertSafeDatabaseEnv(env: NodeJS.ProcessEnv, rules: DatabaseRules): void;
|
|
27
|
+
/** The hosts of every Postgres URL `env` holds: what the network guard lets through once the URLs passed the check. */
|
|
28
|
+
export declare function databaseHostsIn(env: NodeJS.ProcessEnv): string[];
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production-database refusal. Two rules, both thrown before a test runs:
|
|
3
|
+
*
|
|
4
|
+
* 1. A database URL in the test environment whose host is on the repo's production list is refused. An entry matches the
|
|
5
|
+
* host itself, any subdomain of it, and, for a host whose first label is a compute endpoint id (`ep-…`), that
|
|
6
|
+
* endpoint with or without its `-pooler` suffix.
|
|
7
|
+
* 2. A database URL in the test environment that names the same database as any `.env*` file at the repo root is
|
|
8
|
+
* refused: those files hold a developer's own (perhaps production) connection, and a test must never reach it. Every
|
|
9
|
+
* `postgres://` text in the files counts, in any variable, quoted any way, or in a comment, except the lines whose key
|
|
10
|
+
* the repo names as its test database (`testDatabaseEnv`).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
15
|
+
/** A Postgres URL anywhere in a text. */
|
|
16
|
+
const POSTGRES_URL = /postgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
17
|
+
/** The text's own punctuation after a URL is not part of it: `(postgres://h/db)` names `db`. */
|
|
18
|
+
function trimmedUrl(match) {
|
|
19
|
+
return match.replace(/[)>,;.]+$/, '');
|
|
20
|
+
}
|
|
21
|
+
/** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
|
|
22
|
+
export function endpointOf(host) {
|
|
23
|
+
const first = host.toLowerCase().split('.')[0] ?? '';
|
|
24
|
+
if (!first.startsWith('ep-'))
|
|
25
|
+
return null;
|
|
26
|
+
return first.endsWith('-pooler') ? first.slice(0, -'-pooler'.length) : first;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The one database a URL names, as a comparable key: `endpoint:<id>` for an endpoint host, else
|
|
30
|
+
* `<host>:<port>/<database>` with every loopback name read as `localhost`. Null when the text is not a Postgres URL.
|
|
31
|
+
*/
|
|
32
|
+
export function databasePlace(url) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(url);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:')
|
|
41
|
+
return null;
|
|
42
|
+
const host = parsed.hostname.toLowerCase();
|
|
43
|
+
const endpoint = endpointOf(host);
|
|
44
|
+
if (endpoint)
|
|
45
|
+
return `endpoint:${endpoint}`;
|
|
46
|
+
const database = decodeURIComponent(parsed.pathname.replace(/^\//, '')) || decodeURIComponent(parsed.username) || 'postgres';
|
|
47
|
+
return `${LOOPBACK.has(host) || host === '' ? 'localhost' : host}:${parsed.port || '5432'}/${database}`;
|
|
48
|
+
}
|
|
49
|
+
/** Every Postgres URL in a text. */
|
|
50
|
+
export function postgresUrlsIn(text) {
|
|
51
|
+
return [...text.matchAll(POSTGRES_URL)].map((m) => trimmedUrl(m[0]));
|
|
52
|
+
}
|
|
53
|
+
/** The names of the `.env*` files at `root` (`.env`, `.env.local`, `.env.production`, …), sorted. */
|
|
54
|
+
export function envFilesAt(root) {
|
|
55
|
+
if (!existsSync(root))
|
|
56
|
+
return [];
|
|
57
|
+
return readdirSync(root)
|
|
58
|
+
.filter((name) => name === '.env' || name.startsWith('.env.'))
|
|
59
|
+
.sort();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The places every `.env*` file at `root` names, but the lines whose key is in `testDatabaseEnv` (`KEY=…` or
|
|
63
|
+
* `export KEY=…`, with or without spaces).
|
|
64
|
+
*/
|
|
65
|
+
export function protectedPlaces(root, testDatabaseEnv = []) {
|
|
66
|
+
const places = new Set();
|
|
67
|
+
for (const file of envFilesAt(root)) {
|
|
68
|
+
for (const line of readFileSync(join(root, file), 'utf8').split(/\r?\n/)) {
|
|
69
|
+
const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
70
|
+
if (key && testDatabaseEnv.includes(key))
|
|
71
|
+
continue;
|
|
72
|
+
for (const url of postgresUrlsIn(line)) {
|
|
73
|
+
const place = databasePlace(url);
|
|
74
|
+
if (place)
|
|
75
|
+
places.add(place);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...places].sort();
|
|
80
|
+
}
|
|
81
|
+
/** Whether `host` is on the production list. */
|
|
82
|
+
export function isProductionHost(host, productionHosts) {
|
|
83
|
+
const h = host.toLowerCase();
|
|
84
|
+
const endpoint = endpointOf(h);
|
|
85
|
+
return productionHosts.some((raw) => {
|
|
86
|
+
const entry = raw.trim().toLowerCase();
|
|
87
|
+
if (entry === '')
|
|
88
|
+
return false;
|
|
89
|
+
if (h === entry || h.endsWith(`.${entry}`))
|
|
90
|
+
return true;
|
|
91
|
+
return endpoint !== null && endpointOf(entry) === endpoint;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Throws when `url` names a production host or a database a `.env*` file names. The message never carries the URL. */
|
|
95
|
+
export function assertSafeDatabaseUrl(url, rules, label = 'a database URL') {
|
|
96
|
+
const place = databasePlace(url);
|
|
97
|
+
if (place === null)
|
|
98
|
+
return;
|
|
99
|
+
const host = new URL(url).hostname;
|
|
100
|
+
if (isProductionHost(host, rules.productionHosts)) {
|
|
101
|
+
throw new Error(`test-kit: ${label} names a PRODUCTION database host (${host}). Tests never connect to production.`);
|
|
102
|
+
}
|
|
103
|
+
if (rules.protectedPlaces.includes(place)) {
|
|
104
|
+
throw new Error(`test-kit: ${label} names the same database as a .env* file at the repository root. ` +
|
|
105
|
+
'A test database must be its own (a container, or a branch made for the run).');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Runs {@link assertSafeDatabaseUrl} over every Postgres URL any variable of `env` holds. */
|
|
109
|
+
export function assertSafeDatabaseEnv(env, rules) {
|
|
110
|
+
for (const [name, value] of Object.entries(env)) {
|
|
111
|
+
if (!value)
|
|
112
|
+
continue;
|
|
113
|
+
for (const url of postgresUrlsIn(value))
|
|
114
|
+
assertSafeDatabaseUrl(url, rules, `$${name}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** The hosts of every Postgres URL `env` holds: what the network guard lets through once the URLs passed the check. */
|
|
118
|
+
export function databaseHostsIn(env) {
|
|
119
|
+
const hosts = new Set();
|
|
120
|
+
for (const value of Object.values(env)) {
|
|
121
|
+
if (!value)
|
|
122
|
+
continue;
|
|
123
|
+
for (const url of postgresUrlsIn(value)) {
|
|
124
|
+
try {
|
|
125
|
+
const host = new URL(url).hostname;
|
|
126
|
+
if (host)
|
|
127
|
+
hosts.add(host.toLowerCase());
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// not a URL after all
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...hosts];
|
|
135
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** jsdom has no `matchMedia`: a stub that matches nothing, as a desktop browser with no preference would answer. */
|
|
2
|
+
export declare function installMatchMedia(): void;
|
|
3
|
+
/**
|
|
4
|
+
* Node 26 defines `localStorage` / `sessionStorage` on `globalThis` as `undefined` (without `--localstorage-file`), and
|
|
5
|
+
* Vitest's jsdom environment then skips copying jsdom's real `Storage` over it. Re-binds jsdom's own objects. Inert on
|
|
6
|
+
* Node versions without the global and under the node environment.
|
|
7
|
+
*/
|
|
8
|
+
export declare function rebindStorage(): void;
|
|
9
|
+
export interface DomOptions {
|
|
10
|
+
/** Unmount what Testing Library rendered after each test (it does so itself only under Vitest's globals). Default: true. */
|
|
11
|
+
cleanup?: boolean;
|
|
12
|
+
/** Load `@testing-library/jest-dom`'s matchers when it is installed. Default: true. */
|
|
13
|
+
jestDom?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Everything a jsdom test file needs, for the file it is called in. */
|
|
16
|
+
export declare function installDom(options?: DomOptions): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Helpers for a test file that runs under jsdom (`// @vitest-environment jsdom` on its first line). The kit's setup file
|
|
11
|
+
* calls {@link installDom} on its own whenever a `window` exists; a repo may call the pieces directly.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach } from 'vitest';
|
|
14
|
+
/** jsdom has no `matchMedia`: a stub that matches nothing, as a desktop browser with no preference would answer. */
|
|
15
|
+
export function installMatchMedia() {
|
|
16
|
+
if (typeof window === 'undefined' || typeof window.matchMedia === 'function')
|
|
17
|
+
return;
|
|
18
|
+
window.matchMedia = ((query) => ({
|
|
19
|
+
matches: false,
|
|
20
|
+
media: query,
|
|
21
|
+
onchange: null,
|
|
22
|
+
addEventListener: () => { },
|
|
23
|
+
removeEventListener: () => { },
|
|
24
|
+
addListener: () => { },
|
|
25
|
+
removeListener: () => { },
|
|
26
|
+
dispatchEvent: () => false,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Node 26 defines `localStorage` / `sessionStorage` on `globalThis` as `undefined` (without `--localstorage-file`), and
|
|
31
|
+
* Vitest's jsdom environment then skips copying jsdom's real `Storage` over it. Re-binds jsdom's own objects. Inert on
|
|
32
|
+
* Node versions without the global and under the node environment.
|
|
33
|
+
*/
|
|
34
|
+
export function rebindStorage() {
|
|
35
|
+
const g = globalThis;
|
|
36
|
+
const dom = g.jsdom?.window;
|
|
37
|
+
if (!dom)
|
|
38
|
+
return;
|
|
39
|
+
for (const key of ['localStorage', 'sessionStorage']) {
|
|
40
|
+
if (g[key] == null && dom[key] != null) {
|
|
41
|
+
Object.defineProperty(globalThis, key, { value: dom[key], configurable: true, writable: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function optionalImport(specifier) {
|
|
46
|
+
try {
|
|
47
|
+
return (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ specifier)));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const code = error.code;
|
|
51
|
+
const message = String(error.message ?? '');
|
|
52
|
+
if (code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (?:module|package)|Failed to load url/.test(message))
|
|
53
|
+
return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Everything a jsdom test file needs, for the file it is called in. */
|
|
58
|
+
export async function installDom(options = {}) {
|
|
59
|
+
if (typeof window === 'undefined')
|
|
60
|
+
return;
|
|
61
|
+
installMatchMedia();
|
|
62
|
+
rebindStorage();
|
|
63
|
+
if (options.jestDom ?? true)
|
|
64
|
+
await optionalImport('@testing-library/jest-dom/vitest');
|
|
65
|
+
if (options.cleanup ?? true) {
|
|
66
|
+
const rtl = await optionalImport('@testing-library/react');
|
|
67
|
+
const cleanup = rtl?.cleanup;
|
|
68
|
+
if (typeof cleanup === 'function')
|
|
69
|
+
afterEach(() => cleanup());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** What a test file imports from the kit's setup part. */
|
|
2
|
+
export { allowReactWarnings, isReactWarning, REACT_WARNING_PATTERNS } from './react-warnings.ts';
|
|
3
|
+
export { allowNetworkHosts, hostAllowed, LOOPBACK_HOSTS } from './network.ts';
|
|
4
|
+
export { assertSafeDatabaseUrl, databasePlace, endpointOf, isProductionHost, protectedPlaces, type DatabaseRules, } from './database.ts';
|
|
5
|
+
export { CREDENTIAL_ENV_NAME, isStrippedVariable, stripCredentials, urlHasPassword } from './credentials.ts';
|
|
6
|
+
export { allowListGrowth, parseAllowList, readAllowList, type AllowList, type AllowListEntry } from './allow-list.ts';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** What a test file imports from the kit's setup part. */
|
|
2
|
+
export { allowReactWarnings, isReactWarning, REACT_WARNING_PATTERNS } from "./react-warnings.js";
|
|
3
|
+
export { allowNetworkHosts, hostAllowed, LOOPBACK_HOSTS } from "./network.js";
|
|
4
|
+
export { assertSafeDatabaseUrl, databasePlace, endpointOf, isProductionHost, protectedPlaces, } from "./database.js";
|
|
5
|
+
export { CREDENTIAL_ENV_NAME, isStrippedVariable, stripCredentials, urlHasPassword } from "./credentials.js";
|
|
6
|
+
export { allowListGrowth, parseAllowList, readAllowList } from "./allow-list.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const LOOPBACK_HOSTS: readonly string[];
|
|
2
|
+
/** Lower-cased, brackets off an IPv6 literal. */
|
|
3
|
+
export declare function normalizeHost(host: string): string;
|
|
4
|
+
/** Whether `host` is allowed: an exact entry, or a `*.example.test` entry matching a subdomain. */
|
|
5
|
+
export declare function hostAllowed(host: string, permitted: Iterable<string>): boolean;
|
|
6
|
+
/** Installs both guards once per process; a second call replaces the allow-list. */
|
|
7
|
+
export declare function installNetworkGuard(allowHosts?: Iterable<string>): void;
|
|
8
|
+
/** Adds hosts to the allow-list for the rest of the file (a database a test starts itself). */
|
|
9
|
+
export declare function allowNetworkHosts(...hosts: string[]): void;
|
|
10
|
+
/** The refusals since the last take, cleared. */
|
|
11
|
+
export declare function takeNetworkViolations(): string[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network guard. `fetch` and every TCP socket (`net.Socket.prototype.connect`, which `http`, `https`, `tls`,
|
|
3
|
+
* undici and database drivers all reach) are refused unless the host is on the allow-list. A refusal throws (or destroys
|
|
4
|
+
* the socket with an ECONNREFUSED-shaped error) AND is recorded, so code that swallows the error still fails the test.
|
|
5
|
+
* A unix socket always passes.
|
|
6
|
+
*/
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'];
|
|
9
|
+
const STATE = Symbol.for('catalystiq.test-kit.network');
|
|
10
|
+
function state() {
|
|
11
|
+
const g = globalThis;
|
|
12
|
+
g[STATE] ??= { permitted: new Set(LOOPBACK_HOSTS), violations: [] };
|
|
13
|
+
return g[STATE];
|
|
14
|
+
}
|
|
15
|
+
/** Lower-cased, brackets off an IPv6 literal. */
|
|
16
|
+
export function normalizeHost(host) {
|
|
17
|
+
const lower = host.trim().toLowerCase();
|
|
18
|
+
return lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower;
|
|
19
|
+
}
|
|
20
|
+
/** Whether `host` is allowed: an exact entry, or a `*.example.test` entry matching a subdomain. */
|
|
21
|
+
export function hostAllowed(host, permitted) {
|
|
22
|
+
const h = normalizeHost(host);
|
|
23
|
+
for (const entry of permitted) {
|
|
24
|
+
const e = normalizeHost(entry);
|
|
25
|
+
if (e === h)
|
|
26
|
+
return true;
|
|
27
|
+
if (e.startsWith('*.') && h.endsWith(e.slice(1)))
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function requestUrl(input) {
|
|
33
|
+
if (typeof input === 'string')
|
|
34
|
+
return input;
|
|
35
|
+
if (input instanceof URL)
|
|
36
|
+
return input.href;
|
|
37
|
+
if (input && typeof input === 'object' && 'url' in input)
|
|
38
|
+
return String(input.url);
|
|
39
|
+
return String(input);
|
|
40
|
+
}
|
|
41
|
+
function connectTarget(args) {
|
|
42
|
+
const [first, second] = args;
|
|
43
|
+
if (Array.isArray(first))
|
|
44
|
+
return connectTarget(first);
|
|
45
|
+
if (first && typeof first === 'object')
|
|
46
|
+
return first;
|
|
47
|
+
if (typeof first === 'string' && !/^\d+$/.test(first))
|
|
48
|
+
return { path: first };
|
|
49
|
+
return { port: first, host: typeof second === 'string' ? second : undefined };
|
|
50
|
+
}
|
|
51
|
+
/** Installs both guards once per process; a second call replaces the allow-list. */
|
|
52
|
+
export function installNetworkGuard(allowHosts = []) {
|
|
53
|
+
const s = state();
|
|
54
|
+
s.permitted = new Set([...LOOPBACK_HOSTS, ...allowHosts].map(normalizeHost));
|
|
55
|
+
if (!s.realFetch && typeof globalThis.fetch === 'function') {
|
|
56
|
+
const realFetch = globalThis.fetch;
|
|
57
|
+
s.realFetch = realFetch;
|
|
58
|
+
const guardedFetch = (async (input, init) => {
|
|
59
|
+
const url = requestUrl(input);
|
|
60
|
+
let host = '';
|
|
61
|
+
try {
|
|
62
|
+
const parsed = new URL(url);
|
|
63
|
+
if (parsed.protocol === 'data:' || parsed.protocol === 'blob:')
|
|
64
|
+
return (state().realFetch ?? realFetch)(input, init);
|
|
65
|
+
host = parsed.hostname;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
host = '';
|
|
69
|
+
}
|
|
70
|
+
if (!hostAllowed(host, state().permitted)) {
|
|
71
|
+
state().violations.push(`fetch ${url}`);
|
|
72
|
+
throw new TypeError(`fetch failed: ${url} is outside the hosts this test may reach (test-kit network guard)`);
|
|
73
|
+
}
|
|
74
|
+
// Read from the state, so a test of the guard can stand an answer in for the real network.
|
|
75
|
+
return (state().realFetch ?? realFetch)(input, init);
|
|
76
|
+
});
|
|
77
|
+
globalThis.fetch = guardedFetch;
|
|
78
|
+
}
|
|
79
|
+
const prototype = net.Socket.prototype;
|
|
80
|
+
if (!s.realConnect) {
|
|
81
|
+
const realConnect = prototype.connect;
|
|
82
|
+
s.realConnect = realConnect;
|
|
83
|
+
prototype.connect = function guardedConnect(...args) {
|
|
84
|
+
const target = connectTarget(args);
|
|
85
|
+
if (typeof target.path === 'string')
|
|
86
|
+
return realConnect.apply(this, args);
|
|
87
|
+
const host = typeof target.host === 'string' && target.host !== '' ? target.host : 'localhost';
|
|
88
|
+
if (hostAllowed(host, state().permitted))
|
|
89
|
+
return realConnect.apply(this, args);
|
|
90
|
+
const where = `${host}:${String(target.port ?? '')}`;
|
|
91
|
+
state().violations.push(`socket ${where}`);
|
|
92
|
+
const refused = Object.assign(new Error(`connect ECONNREFUSED ${where}: outside the hosts this test may reach (test-kit network guard)`), { code: 'ECONNREFUSED' });
|
|
93
|
+
process.nextTick(() => this.destroy(refused));
|
|
94
|
+
return this;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Adds hosts to the allow-list for the rest of the file (a database a test starts itself). */
|
|
99
|
+
export function allowNetworkHosts(...hosts) {
|
|
100
|
+
for (const host of hosts)
|
|
101
|
+
state().permitted.add(normalizeHost(host));
|
|
102
|
+
}
|
|
103
|
+
/** The refusals since the last take, cleared. */
|
|
104
|
+
export function takeNetworkViolations() {
|
|
105
|
+
const s = state();
|
|
106
|
+
const list = [...s.violations];
|
|
107
|
+
s.violations.length = 0;
|
|
108
|
+
return list;
|
|
109
|
+
}
|