@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,168 @@
1
+ /**
2
+ * REACT WARNINGS FAIL THE TEST. A warning React (or Radix) prints, an update outside `act()`, a duplicate key, invalid
3
+ * DOM nesting, used to vanish into a muted console. The guard screens every `console.error` / `console.warn` call against
4
+ * {@link REACT_WARNING_PATTERNS} and fails the test after it ends.
5
+ *
6
+ * It survives a test that mutes the console itself, three ways at once:
7
+ * - the console's `error` / `warn` are the guard's own screening functions, so an unmuted call is screened as it is made;
8
+ * - `globalThis.console` is a proxy that sees a test install a spy or a plain function on `error` / `warn`
9
+ * (`vi.spyOn(console, 'error').mockImplementation(() => {})`, `console.error = vi.fn()`, `console.error = () => {}`)
10
+ * and wraps it, so the arguments are screened before the test's implementation swallows them;
11
+ * - after each test the calls a console mock recorded are read again, the net under a `mockReset()`.
12
+ *
13
+ * A test that means to provoke a warning says so with {@link allowReactWarnings}; the allowance ends with the test. A
14
+ * repo-wide list (`react-warnings.allow.json`, see `allow-list.ts`) holds the warnings a repo has not fixed yet.
15
+ */
16
+ import { expect, vi } from 'vitest';
17
+ /** React's and Radix's warning texts (format strings: React 19 logs `console.error(format, ...args)`). */
18
+ export const REACT_WARNING_PATTERNS = [
19
+ /not wrapped in act\(/,
20
+ /Encountered two children with the same key/,
21
+ /Each child in a list should have a unique "key" prop/,
22
+ /cannot be a descendant of <|cannot contain a nested <|cannot be a child of <|validateDOMNesting/,
23
+ /In HTML, .* cannot be a (?:child|descendant) of/,
24
+ /A component is changing an? (?:un)?controlled input/,
25
+ /Cannot update a component .* while rendering a different component/,
26
+ /Can't perform a React state update on/,
27
+ /React does not recognize the .* prop on a DOM element/,
28
+ /Received `.*` for a non-boolean attribute/,
29
+ /Invalid DOM property `/,
30
+ /Unknown event handler property `/,
31
+ /Function components cannot be given refs/,
32
+ /Maximum update depth exceeded/,
33
+ /A tree hydrated but some attributes of the server rendered HTML didn't match/,
34
+ /`DialogContent` requires a `DialogTitle`/,
35
+ /Missing `Description` or `aria-describedby=\{undefined\}`/,
36
+ ];
37
+ const STATE = Symbol.for('catalystiq.test-kit.react-warnings');
38
+ // Sets, not marker properties: a Vitest spy copies its original's own properties, so a marker on the guard's screening
39
+ // function would make a spy of it look already wrapped.
40
+ const SCREENING = new WeakSet();
41
+ const WRAPPED_MOCKS = new WeakSet();
42
+ function state() {
43
+ const g = globalThis;
44
+ g[STATE] ??= { caught: new Set(), allowed: [], repoAllowed: [], scannedUpTo: new WeakMap(), installed: false };
45
+ return g[STATE];
46
+ }
47
+ function argText(value) {
48
+ if (value instanceof Error)
49
+ return value.message;
50
+ if (typeof value === 'string')
51
+ return value;
52
+ try {
53
+ return String(value);
54
+ }
55
+ catch {
56
+ return '';
57
+ }
58
+ }
59
+ /** True when these console arguments are a React (or Radix) warning. */
60
+ export function isReactWarning(args) {
61
+ const text = args.map(argText).join(' ');
62
+ return REACT_WARNING_PATTERNS.some((pattern) => pattern.test(text));
63
+ }
64
+ function currentTestPath() {
65
+ try {
66
+ return expect.getState().testPath ?? '';
67
+ }
68
+ catch {
69
+ return '';
70
+ }
71
+ }
72
+ /** Records the call when it is a React warning neither the test nor the repo allowed. */
73
+ export function screenConsoleCall(level, args) {
74
+ if (!isReactWarning(args))
75
+ return;
76
+ const text = args.map(argText).join(' ');
77
+ const s = state();
78
+ if (s.allowed.some((pattern) => pattern.test(text)))
79
+ return;
80
+ const path = currentTestPath();
81
+ if (s.repoAllowed.some((entry) => entry.pattern.test(text) && (!entry.file || path.includes(entry.file))))
82
+ return;
83
+ s.caught.add(`console.${level}: ${text.slice(0, 800)}`);
84
+ }
85
+ function screened(level, next) {
86
+ const fn = function screenedConsole(...args) {
87
+ screenConsoleCall(level, args);
88
+ return next?.apply(this, args);
89
+ };
90
+ SCREENING.add(fn);
91
+ return fn;
92
+ }
93
+ /** Makes a console mock screen its arguments whatever implementation a test later gives it. Idempotent. */
94
+ export function screenConsoleMock(level, mock) {
95
+ if (!vi.isMockFunction(mock))
96
+ return;
97
+ const marked = mock;
98
+ if (WRAPPED_MOCKS.has(mock))
99
+ return;
100
+ WRAPPED_MOCKS.add(mock);
101
+ const setImpl = marked.mockImplementation.bind(mock);
102
+ const setImplOnce = marked.mockImplementationOnce.bind(mock);
103
+ marked.mockImplementation = (next) => setImpl(screened(level, next));
104
+ marked.mockImplementationOnce = (next) => setImplOnce(screened(level, next));
105
+ }
106
+ /** Reads the calls a console mock recorded since the last scan. */
107
+ export function scanConsoleCalls(level, mock) {
108
+ if (!vi.isMockFunction(mock))
109
+ return;
110
+ const s = state();
111
+ const calls = mock.mock.calls;
112
+ const from = s.scannedUpTo.get(mock) ?? 0;
113
+ const start = calls.length < from ? 0 : from;
114
+ for (const args of calls.slice(start))
115
+ screenConsoleCall(level, args);
116
+ s.scannedUpTo.set(mock, calls.length);
117
+ }
118
+ function isLevel(prop) {
119
+ return prop === 'error' || prop === 'warn';
120
+ }
121
+ /** What a test put on `console.error` / `console.warn`, made to screen. */
122
+ function adopt(level, value) {
123
+ if (typeof value !== 'function')
124
+ return value;
125
+ if (SCREENING.has(value))
126
+ return value;
127
+ if (vi.isMockFunction(value)) {
128
+ screenConsoleMock(level, value);
129
+ return value;
130
+ }
131
+ return screened(level, value);
132
+ }
133
+ /** Installs the screening console once per test context, with the repo's own allowed warnings. */
134
+ export function installReactWarningGuard(repoAllowed = []) {
135
+ const s = state();
136
+ s.repoAllowed = [...repoAllowed];
137
+ if (s.installed)
138
+ return;
139
+ s.installed = true;
140
+ const real = globalThis.console;
141
+ real.error = screened('error', real.error.bind(real));
142
+ real.warn = screened('warn', real.warn.bind(real));
143
+ globalThis.console = new Proxy(real, {
144
+ set(target, prop, value) {
145
+ return Reflect.set(target, prop, isLevel(prop) ? adopt(prop, value) : value);
146
+ },
147
+ defineProperty(target, prop, descriptor) {
148
+ if (isLevel(prop) && 'value' in descriptor) {
149
+ return Reflect.defineProperty(target, prop, { ...descriptor, value: adopt(prop, descriptor.value) });
150
+ }
151
+ return Reflect.defineProperty(target, prop, descriptor);
152
+ },
153
+ });
154
+ }
155
+ /** Lets the current test provoke warnings matching `pattern`. */
156
+ export function allowReactWarnings(pattern) {
157
+ state().allowed.push(pattern);
158
+ }
159
+ /** The warnings caught since the last take (after a final scan of any console mock), cleared; allowances reset. */
160
+ export function takeReactWarnings() {
161
+ scanConsoleCalls('error', console.error);
162
+ scanConsoleCalls('warn', console.warn);
163
+ const s = state();
164
+ const list = [...s.caught];
165
+ s.caught.clear();
166
+ s.allowed = [];
167
+ return list;
168
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ // `server-only` throws when it is bundled for a browser. A test runs server code in Node, so it resolves here instead.
2
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The kit's worker setup file: `setupFiles[0]` of a config built by `testKitConfig`. In order, for every test file:
3
+ * strip credentials, refuse a production or `.env*` database, block the network, screen React warnings, and under jsdom
4
+ * install the DOM helpers. After each test it fails the test on a caught React warning or a refused request.
5
+ */
6
+ import { afterEach, beforeAll, inject } from 'vitest';
7
+ import { stripCredentials } from "./credentials.js";
8
+ import { assertSafeDatabaseEnv, databaseHostsIn } from "./database.js";
9
+ import { installDom } from "./dom.js";
10
+ import { allowNetworkHosts, installNetworkGuard, takeNetworkViolations } from "./network.js";
11
+ import { installReactWarningGuard, takeReactWarnings } from "./react-warnings.js";
12
+ const provided = inject('testKit');
13
+ if (!provided) {
14
+ throw new Error('test-kit: this setup file needs the options testKitConfig() provides; build the config with it.');
15
+ }
16
+ stripCredentials(process.env, { keep: provided.keepEnv, extra: provided.stripEnv });
17
+ const rules = { productionHosts: provided.productionHosts, protectedPlaces: provided.protectedPlaces };
18
+ assertSafeDatabaseEnv(process.env, rules);
19
+ installNetworkGuard([...provided.allowHosts, ...(provided.allowDatabaseHosts ? databaseHostsIn(process.env) : [])]);
20
+ installReactWarningGuard(provided.allowedWarnings.map((e) => ({ pattern: new RegExp(e.pattern), ...(e.file ? { file: e.file } : {}) })));
21
+ // A repo's own setup file, which runs after this one, may set the test database URL: ask again before the tests.
22
+ beforeAll(() => {
23
+ assertSafeDatabaseEnv(process.env, rules);
24
+ if (provided.allowDatabaseHosts)
25
+ allowNetworkHosts(...databaseHostsIn(process.env));
26
+ });
27
+ // Registered before the DOM cleanup, so it runs after it (Vitest runs afterEach hooks last-registered first): a warning
28
+ // the unmount prints still fails the test.
29
+ afterEach(() => {
30
+ const warnings = takeReactWarnings();
31
+ const refused = takeNetworkViolations();
32
+ const parts = [];
33
+ if (warnings.length > 0) {
34
+ parts.push('React warned during this test (or just before it, from work an earlier test left running). Fix it, or allow it ' +
35
+ "in this test with allowReactWarnings(/pattern/) from '@catalystiq/test-kit/setup'.\n\n" +
36
+ warnings.join('\n\n'));
37
+ }
38
+ if (refused.length > 0) {
39
+ parts.push('This test tried to reach the network outside its allow-list (test-kit network guard):\n' + refused.join('\n'));
40
+ }
41
+ if (parts.length > 0)
42
+ throw new Error(parts.join('\n\n'));
43
+ });
44
+ await installDom();
package/package.json CHANGED
@@ -1,6 +1,109 @@
1
1
  {
2
2
  "name": "@catalystiq/test-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, and the local Neon test-database run.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": true,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/getcatalystiq/test-kit.git"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.18"
17
+ },
18
+ "exports": {
19
+ "./setup": {
20
+ "types": "./dist/setup/index.d.ts",
21
+ "import": "./dist/setup/index.js"
22
+ },
23
+ "./setup/config": {
24
+ "types": "./dist/setup/config.d.ts",
25
+ "import": "./dist/setup/config.js",
26
+ "default": "./dist/setup/config.js"
27
+ },
28
+ "./setup/dom": {
29
+ "types": "./dist/setup/dom.d.ts",
30
+ "import": "./dist/setup/dom.js"
31
+ },
32
+ "./setup/vitest-setup": "./dist/setup/setup-file.js",
33
+ "./mcp-apps-host": {
34
+ "types": "./dist/mcp-apps-host/index.d.ts",
35
+ "import": "./dist/mcp-apps-host/index.js"
36
+ },
37
+ "./mcp-apps-host/playwright": {
38
+ "types": "./dist/mcp-apps-host/playwright.d.ts",
39
+ "import": "./dist/mcp-apps-host/playwright.js"
40
+ },
41
+ "./neon-test-db": {
42
+ "types": "./dist/neon-test-db/neon-test-db.d.mts",
43
+ "import": "./dist/neon-test-db/neon-test-db.mjs"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "bin": {
48
+ "tk-check-bundle": "dist/bin/check-bundle.js",
49
+ "tk-check-allowlist": "dist/bin/check-allowlist.js",
50
+ "tk-neon-test-db": "dist/neon-test-db/neon-test-db.mjs"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "README.md",
55
+ "LICENSE"
56
+ ],
57
+ "scripts": {
58
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-docs.mjs",
59
+ "typecheck": "tsc --noEmit",
60
+ "lint": "eslint .",
61
+ "test": "vitest run",
62
+ "test:browser": "playwright test && TK_BROWSER=1 vitest run test/browser-host-rules.test.ts",
63
+ "prepublishOnly": "npm run typecheck && npm run build",
64
+ "smoke:package": "npm run build && node scripts/smoke-package.mjs"
65
+ },
66
+ "peerDependencies": {
67
+ "@playwright/test": ">=1.50",
68
+ "@testing-library/jest-dom": ">=6",
69
+ "@testing-library/react": ">=16",
70
+ "vitest": "^4.1.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@playwright/test": {
74
+ "optional": true
75
+ },
76
+ "@testing-library/jest-dom": {
77
+ "optional": true
78
+ },
79
+ "@testing-library/react": {
80
+ "optional": true
81
+ },
82
+ "vitest": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@eslint/js": "9.39.5",
88
+ "@modelcontextprotocol/client": "2.4.0",
89
+ "@modelcontextprotocol/core": "2.4.0",
90
+ "@modelcontextprotocol/ext-apps": "2.0.4",
91
+ "@modelcontextprotocol/server": "2.4.0",
92
+ "@playwright/test": "1.65.0",
93
+ "@testing-library/dom": "10.4.2",
94
+ "@testing-library/jest-dom": "7.0.1",
95
+ "@testing-library/react": "16.3.3",
96
+ "@types/node": "22.20.5",
97
+ "@types/react": "19.3.0",
98
+ "@types/react-dom": "19.3.0",
99
+ "esbuild": "0.28.2",
100
+ "eslint": "9.39.5",
101
+ "jsdom": "30.1.3",
102
+ "react": "19.3.1",
103
+ "react-dom": "19.3.1",
104
+ "typescript": "6.0.3",
105
+ "typescript-eslint": "8.72.1",
106
+ "vitest": "4.1.12",
107
+ "zod": "4.6.7"
108
+ }
109
+ }