@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,88 @@
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
+ /**
70
+ * The kit's config merged with the repo's; refuses a retry, which hides a broken test. A repo that names its own
71
+ * `include` gets exactly that list: Vite's merge concatenates arrays, which would add the kit's catch-all glob and run
72
+ * files the repo never ran (another suite's, a script's `.test.mjs`).
73
+ */
74
+ export function defineTestKitConfig(options = {}, repo = {}) {
75
+ const merged = mergeConfig(testKitConfig(options), repo);
76
+ if (repo.test?.include !== undefined && merged.test)
77
+ merged.test.include = repo.test.include;
78
+ const retry = merged.test?.retry;
79
+ if (retry !== undefined && retry !== 0) {
80
+ throw new Error('test-kit: `retry` must stay 0. A retry makes a broken test pass; raise a timeout instead.');
81
+ }
82
+ const setupFiles = merged.test?.setupFiles;
83
+ const list = Array.isArray(setupFiles) ? setupFiles : setupFiles ? [setupFiles] : [];
84
+ if (list[0] !== SETUP_FILE) {
85
+ throw new Error("test-kit: the kit's setup file must run first, before the repo's own setupFiles.");
86
+ }
87
+ return merged;
88
+ }
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ /** React's and Radix's warning texts (format strings: React 19 logs `console.error(format, ...args)`). */
2
+ export declare const REACT_WARNING_PATTERNS: readonly RegExp[];
3
+ export type ConsoleLevel = 'error' | 'warn';
4
+ /** A warning a repo allows in every test, or in the test files whose path contains `file`. */
5
+ export interface AllowedWarning {
6
+ pattern: RegExp;
7
+ file?: string;
8
+ }
9
+ /** True when these console arguments are a React (or Radix) warning. */
10
+ export declare function isReactWarning(args: readonly unknown[]): boolean;
11
+ /** Records the call when it is a React warning neither the test nor the repo allowed. */
12
+ export declare function screenConsoleCall(level: ConsoleLevel, args: readonly unknown[]): void;
13
+ /** Makes a console mock screen its arguments whatever implementation a test later gives it. Idempotent. */
14
+ export declare function screenConsoleMock(level: ConsoleLevel, mock: unknown): void;
15
+ /** Reads the calls a console mock recorded since the last scan. */
16
+ export declare function scanConsoleCalls(level: ConsoleLevel, mock: unknown): void;
17
+ /** Installs the screening console once per test context, with the repo's own allowed warnings. */
18
+ export declare function installReactWarningGuard(repoAllowed?: readonly AllowedWarning[]): void;
19
+ /** Lets the current test provoke warnings matching `pattern`. */
20
+ export declare function allowReactWarnings(pattern: RegExp): void;
21
+ /** The warnings caught since the last take (after a final scan of any console mock), cleared; allowances reset. */
22
+ export declare function takeReactWarnings(): string[];