@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CatalystIQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @catalystiq/test-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared test helpers: one package, added to part by part. Pin an exact version (`"@catalystiq/test-kit": "0.2.0"`);
4
+ Dependabot raises it.
5
+
6
+ | Part | Import | What |
7
+ |---|---|---|
8
+ | Setup | `@catalystiq/test-kit/setup/config`, `/setup`, `/setup/dom` | Vitest defaults and the safety setup: credentials stripped, network blocked, production and `.env*` databases refused, React warnings fail the test. (`dist/setup/README.md` in the package) |
9
+ | Neon test database | `tk-neon-test-db` (bin), `@catalystiq/test-kit/neon-test-db` | `npm run test:db`: a short-lived schema-only Neon branch for one command, deleted afterwards; moved from github-actions' vendored `scripts/neon-test-db.mjs`. (`dist/neon-test-db/README.md` in the package) |
10
+ | MCP Apps host | `@catalystiq/test-kit/mcp-apps-host`, `/mcp-apps-host/playwright` | A fake ChatGPT or Claude host for an MCP Apps card, in jsdom and in Playwright, and `tk-check-bundle`. (`dist/mcp-apps-host/README.md` in the package) |
11
+
12
+ Vitest 4.1 and Node 22.18 or later. Every peer dependency is optional; install what the parts you use need (`vitest`,
13
+ `@playwright/test`, `@testing-library/react`, `@testing-library/jest-dom`).
14
+
15
+ ## Developing
16
+
17
+ `npm test` runs the unit suite and the rule proofs: each rule is shown working, then cut out of a copy of `src` and shown
18
+ failing (`test/support/run-fixture.ts`). `npm run test:browser` runs the Playwright host and its rule proofs (Chromium).
19
+ `npm run typecheck`, `npm run lint`, `npm run build`.
20
+
21
+ ## Releasing
22
+
23
+ Raise `version` in `package.json` in a pull request. After it merges, publish a GitHub release tagged `v<version>`;
24
+ `.github/workflows/publish.yml` checks the tag matches, runs the checks, and publishes to npm.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // tk-check-allowlist <file> [--base origin/main]: fails when the React-warning allow-list grew against its base.
3
+ import { checkAllowListAgainstBase } from "../bundle/allow-list-base.js";
4
+ const args = process.argv.slice(2);
5
+ const at = args.indexOf('--base');
6
+ const base = at >= 0 ? args[at + 1] : 'origin/main';
7
+ const file = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--base');
8
+ try {
9
+ if (!file || !base)
10
+ throw new Error('usage: tk-check-allowlist <file> [--base origin/main]');
11
+ const problems = checkAllowListAgainstBase(file, base);
12
+ if (problems.length > 0) {
13
+ console.error(`tk-check-allowlist: ${file} grew against ${base}; its entries only shrink:\n ${problems.join('\n ')}`);
14
+ process.exitCode = 1;
15
+ }
16
+ else {
17
+ console.log(`ok ${file} did not grow against ${base}`);
18
+ }
19
+ }
20
+ catch (error) {
21
+ console.error(`tk-check-allowlist: ${error.message}`);
22
+ process.exitCode = 1;
23
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // tk-check-bundle [--config tk-bundles.json] [--write]: see src/bundle/manifest.ts.
3
+ import { runBundleCheck } from "../bundle/manifest.js";
4
+ const args = process.argv.slice(2);
5
+ const at = args.indexOf('--config');
6
+ const config = at >= 0 ? args[at + 1] : 'tk-bundles.json';
7
+ const write = args.includes('--write');
8
+ try {
9
+ if (!config)
10
+ throw new Error('--config needs a path');
11
+ const results = runBundleCheck(process.cwd(), config, write);
12
+ for (const r of results)
13
+ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.message}`);
14
+ process.exitCode = results.every((r) => r.ok) ? 0 : 1;
15
+ }
16
+ catch (error) {
17
+ console.error(`tk-check-bundle: ${error.message}`);
18
+ process.exitCode = 1;
19
+ }
@@ -0,0 +1,3 @@
1
+ /** The file as `base` has it, or null when the base has no such file. Throws when the base ref itself is missing. */
2
+ export declare function readAtBase(file: string, base: string, cwd?: string): unknown;
3
+ export declare function checkAllowListAgainstBase(file: string, base: string, cwd?: string): string[];
@@ -0,0 +1,27 @@
1
+ /** `tk-check-allowlist`: the React-warning allow-list may only shrink against its base branch. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { relative, resolve } from 'node:path';
4
+ import { allowListGrowth, parseAllowList, readAllowList } from "../setup/allow-list.js";
5
+ /** The file as `base` has it, or null when the base has no such file. Throws when the base ref itself is missing. */
6
+ export function readAtBase(file, base, cwd = process.cwd()) {
7
+ const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
8
+ try {
9
+ git(['rev-parse', '--verify', '--quiet', `${base}^{commit}`]);
10
+ }
11
+ catch {
12
+ throw new Error(`the base ${base} is not in this checkout: fetch it (actions/checkout with fetch-depth: 0)`);
13
+ }
14
+ // `<rev>:./path` is relative to the working directory, which survives a symlinked checkout path.
15
+ const path = relative(cwd, resolve(cwd, file)).split('\\').join('/');
16
+ try {
17
+ return JSON.parse(git(['show', `${base}:./${path}`]));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function checkAllowListAgainstBase(file, base, cwd = process.cwd()) {
24
+ const current = readAllowList(resolve(cwd, file));
25
+ const atBase = readAtBase(file, base, cwd);
26
+ return allowListGrowth(current, atBase === null ? null : parseAllowList(atBase, `${file} at ${base}`));
27
+ }
@@ -0,0 +1,29 @@
1
+ export type VersionSource = {
2
+ file: string;
3
+ pattern: string;
4
+ } | {
5
+ file: string;
6
+ pointer: string;
7
+ };
8
+ export interface BundleEntry {
9
+ bundle: string;
10
+ version: VersionSource;
11
+ manifest?: string;
12
+ }
13
+ export interface BundleManifest {
14
+ version: string;
15
+ sha256: string;
16
+ }
17
+ export interface BundleResult {
18
+ bundle: string;
19
+ ok: boolean;
20
+ message: string;
21
+ }
22
+ export declare function parseBundleConfig(value: unknown, source?: string): BundleEntry[];
23
+ export declare function readVersion(root: string, source: VersionSource): string;
24
+ export declare function manifestPath(entry: BundleEntry): string;
25
+ /** Checks one bundle against its manifest. */
26
+ export declare function checkBundle(root: string, entry: BundleEntry): BundleResult;
27
+ /** Records one bundle's manifest, refusing a changed bundle under an unchanged version. */
28
+ export declare function writeBundleManifest(root: string, entry: BundleEntry): BundleResult;
29
+ export declare function runBundleCheck(root: string, configFile: string, write: boolean): BundleResult[];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `tk-check-bundle`: a built card bundle that changed must carry a new version. Hosts cache an MCP Apps resource by its
3
+ * URI, so a card whose bytes changed under the same version reaches some users and not others.
4
+ *
5
+ * Each bundle has a committed manifest beside it, `{ "version": "…", "sha256": "…" }`. `--write` records the bundle's
6
+ * hash and current version, and REFUSES when the hash moved but the version did not; the check (CI) fails unless the
7
+ * manifest matches the bundle and version exactly, so the manifest is updated in the same change as the bundle.
8
+ *
9
+ * The config, `tk-bundles.json` at the repository root:
10
+ *
11
+ * { "bundles": [{
12
+ * "bundle": "src/card/generated/card.html",
13
+ * "version": { "file": "src/card/version.ts", "pattern": "CARD_VERSION = '([^']+)'" },
14
+ * "manifest": "src/card/generated/card.version.json"
15
+ * }] }
16
+ *
17
+ * `version` is a regular expression whose first group is the version, or `{ "file": "package.json", "pointer":
18
+ * "/version" }` for a JSON file. `manifest` defaults to the bundle's path plus `.version.json`.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { resolve } from 'node:path';
23
+ export function parseBundleConfig(value, source = 'tk-bundles.json') {
24
+ const bundles = value?.bundles;
25
+ if (!Array.isArray(bundles))
26
+ throw new Error(`${source} must be { "bundles": [...] }`);
27
+ if (bundles.length === 0)
28
+ throw new Error(`${source} lists no bundles: a check over nothing proves nothing`);
29
+ return bundles.map((raw, i) => {
30
+ const e = raw;
31
+ if (!e || typeof e.bundle !== 'string')
32
+ throw new Error(`${source}: bundle ${i} needs a "bundle" path`);
33
+ const v = e.version;
34
+ if (!v || typeof v.file !== 'string' || (typeof v.pattern !== 'string' && typeof v.pointer !== 'string')) {
35
+ throw new Error(`${source}: bundle ${i} needs "version": { "file", "pattern" } or { "file", "pointer" }`);
36
+ }
37
+ return { bundle: e.bundle, version: v, ...(typeof e.manifest === 'string' ? { manifest: e.manifest } : {}) };
38
+ });
39
+ }
40
+ function jsonPointer(value, pointer) {
41
+ if (pointer === '')
42
+ return value;
43
+ return pointer
44
+ .split('/')
45
+ .slice(1)
46
+ .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
47
+ .reduce((at, key) => (at && typeof at === 'object' ? at[key] : undefined), value);
48
+ }
49
+ export function readVersion(root, source) {
50
+ const path = resolve(root, source.file);
51
+ if (!existsSync(path))
52
+ throw new Error(`the version file ${source.file} does not exist`);
53
+ const text = readFileSync(path, 'utf8');
54
+ if ('pattern' in source) {
55
+ const match = new RegExp(source.pattern).exec(text);
56
+ if (!match?.[1])
57
+ throw new Error(`no version in ${source.file}: /${source.pattern}/ matched no first group`);
58
+ return match[1];
59
+ }
60
+ const found = jsonPointer(JSON.parse(text), source.pointer);
61
+ if (typeof found !== 'string' && typeof found !== 'number')
62
+ throw new Error(`no version at ${source.pointer} in ${source.file}`);
63
+ return String(found);
64
+ }
65
+ export function manifestPath(entry) {
66
+ return entry.manifest ?? `${entry.bundle}.version.json`;
67
+ }
68
+ function current(root, entry) {
69
+ const path = resolve(root, entry.bundle);
70
+ if (!existsSync(path))
71
+ throw new Error(`the bundle ${entry.bundle} does not exist; build it first`);
72
+ return { version: readVersion(root, entry.version), sha256: createHash('sha256').update(readFileSync(path)).digest('hex') };
73
+ }
74
+ function recorded(root, entry) {
75
+ const path = resolve(root, manifestPath(entry));
76
+ if (!existsSync(path))
77
+ return null;
78
+ const value = JSON.parse(readFileSync(path, 'utf8'));
79
+ if (typeof value.version !== 'string' || typeof value.sha256 !== 'string')
80
+ throw new Error(`${manifestPath(entry)} is not { version, sha256 }`);
81
+ return { version: value.version, sha256: value.sha256 };
82
+ }
83
+ const UNCHANGED_VERSION = (entry, version) => `${entry.bundle} changed but its version is still ${version}. Raise the version, rebuild, then run tk-check-bundle --write.`;
84
+ /** Checks one bundle against its manifest. */
85
+ export function checkBundle(root, entry) {
86
+ try {
87
+ const now = current(root, entry);
88
+ const was = recorded(root, entry);
89
+ if (was === null)
90
+ return { bundle: entry.bundle, ok: false, message: `${manifestPath(entry)} is missing: run tk-check-bundle --write` };
91
+ if (was.sha256 !== now.sha256 && was.version === now.version) {
92
+ return { bundle: entry.bundle, ok: false, message: UNCHANGED_VERSION(entry, now.version) };
93
+ }
94
+ if (was.sha256 !== now.sha256 || was.version !== now.version) {
95
+ return { bundle: entry.bundle, ok: false, message: `${manifestPath(entry)} is out of date: run tk-check-bundle --write and commit it` };
96
+ }
97
+ return { bundle: entry.bundle, ok: true, message: `${entry.bundle} ${now.version}` };
98
+ }
99
+ catch (error) {
100
+ return { bundle: entry.bundle, ok: false, message: error.message };
101
+ }
102
+ }
103
+ /** Records one bundle's manifest, refusing a changed bundle under an unchanged version. */
104
+ export function writeBundleManifest(root, entry) {
105
+ try {
106
+ const now = current(root, entry);
107
+ const was = recorded(root, entry);
108
+ if (was !== null && was.sha256 !== now.sha256 && was.version === now.version) {
109
+ return { bundle: entry.bundle, ok: false, message: UNCHANGED_VERSION(entry, now.version) };
110
+ }
111
+ writeFileSync(resolve(root, manifestPath(entry)), `${JSON.stringify(now, null, 2)}\n`);
112
+ return { bundle: entry.bundle, ok: true, message: `wrote ${manifestPath(entry)} (${now.version})` };
113
+ }
114
+ catch (error) {
115
+ return { bundle: entry.bundle, ok: false, message: error.message };
116
+ }
117
+ }
118
+ export function runBundleCheck(root, configFile, write) {
119
+ const path = resolve(root, configFile);
120
+ if (!existsSync(path))
121
+ throw new Error(`${configFile} does not exist`);
122
+ const entries = parseBundleConfig(JSON.parse(readFileSync(path, 'utf8')), configFile);
123
+ return entries.map((entry) => (write ? writeBundleManifest(root, entry) : checkBundle(root, entry)));
124
+ }
@@ -0,0 +1,70 @@
1
+ # `@catalystiq/test-kit/mcp-apps-host`
2
+
3
+ One fake MCP Apps host, in two forms that share one protocol core, so a card meets the same answers in jsdom and in a
4
+ real browser. Every message is recorded both ways (`host.log`, `from: 'app' | 'host'`), and a console error, a page
5
+ error, a stray network request or a tool call the test did not expect fails the test.
6
+
7
+ ## Profiles
8
+
9
+ | | `chatgpt` | `claude` |
10
+ |---|---|---|
11
+ | `updateModelContext` capability | listed | not listed; `ui/update-model-context` answers -32601 |
12
+ | `window.openai` (browser form) | optional stub: `openai: 'both' \| 'upload-only' \| 'upload-rejects' \| 'download-rejects'` | refused |
13
+
14
+ Both: `openLinks`, `serverTools`, `logging`, `message`; the app's `protocolVersion` is echoed; after `initialized` the
15
+ host sends `tool-input` (when given) then `tool-result`.
16
+
17
+ Options both forms take: `answer(name, args)` (return `undefined` for a call you did not expect), `hold` (hold answers
18
+ until `release()`), `theme`, `displayMode`, `displayModes: 'declared' | 'inline' | 'fail'`, `openLink`, `message`,
19
+ `modelContext` (chatgpt), `refuseInitialize`, `width`, `height`, `locale`, `timeZone`, `hostCapabilities`,
20
+ `hostContext`. As the host: `sendToolResult`, `sendToolInput`, `cancelTool`, `changeContext`, `setTheme`, `teardown`.
21
+ To read: `calls(name?)`, `sent(method)`, `methods()`, `actions()`, `messages()`, `modelContext()`, `links()`,
22
+ `unanswered`.
23
+
24
+ ## jsdom (Vitest)
25
+
26
+ ```ts
27
+ // @vitest-environment jsdom
28
+ import { createMemoryHost } from '@catalystiq/test-kit/mcp-apps-host';
29
+
30
+ const host = createMemoryHost({ profile: 'chatgpt', toolResult: { content: [], structuredContent: { items: 2 } } });
31
+ await app.connect(host.transport); // or host.installParent() for an app that connects to window.parent itself
32
+ ```
33
+
34
+ Messages arrive on a microtask, as `postMessage` delivers them. Opened in a test, the host fails it when it ends on any
35
+ problem; `assertClean()` asks at any moment; `network(url)` answers a request the test expects.
36
+
37
+ ## Playwright
38
+
39
+ ```ts
40
+ import { openApp, test, expect } from '@catalystiq/test-kit/mcp-apps-host/playwright';
41
+
42
+ test('the card', async ({ page }) => {
43
+ const host = await openApp(page, { html: cardBundleHtml, profile: 'claude', toolResult });
44
+ await host.ui.getByRole('button', { name: 'Confirm' }).click();
45
+ expect(host.calls('create_lead')).toHaveLength(1);
46
+ });
47
+ ```
48
+
49
+ The host page is on `https://host.mcp-apps.test`, the card on `https://sandbox.mcp-apps.test` in an iframe without
50
+ `allow-same-origin` (opaque to the host), under `STRICT_APP_CSP` unless `csp` says otherwise. Nothing leaves the
51
+ machine: other requests are aborted and recorded (`network(route)` serves one the test expects). The exported `test`
52
+ checks every host when the test ends, and refuses any request from a page that opened none. Install Chromium with
53
+ `npx playwright install chromium`.
54
+
55
+ ## `tk-check-bundle`
56
+
57
+ A card whose bytes changed must carry a new version, or hosts that cached it by URI serve the old one. List your
58
+ bundles in `tk-bundles.json`:
59
+
60
+ ```json
61
+ { "bundles": [{
62
+ "bundle": "src/card/generated/card.html",
63
+ "version": { "file": "src/card/version.ts", "pattern": "CARD_VERSION = '([^']+)'" }
64
+ }] }
65
+ ```
66
+
67
+ (`"version": { "file": "package.json", "pointer": "/version" }` reads JSON.) `tk-check-bundle --write` records
68
+ `{ version, sha256 }` beside each bundle (`<bundle>.version.json`) and refuses when the bytes changed but the version
69
+ did not; `tk-check-bundle` in CI fails unless every manifest matches its bundle and version. A config listing no
70
+ bundles fails.
@@ -0,0 +1,3 @@
1
+ /** The fake MCP Apps host: the protocol core and its in-memory (jsdom) form. The Playwright form is `./playwright`. */
2
+ export { McpAppsHost, type Action, type DisplayMode, type HostOptions, type HostProfile, type Json, type Recorded, type RpcMessage, type Theme, type ToolAnswer, type ToolCall, } from './protocol.ts';
3
+ export { createMemoryHost, MemoryHost, type MemoryHostOptions, type MemoryTransport } from './memory.ts';
@@ -0,0 +1,3 @@
1
+ /** The fake MCP Apps host: the protocol core and its in-memory (jsdom) form. The Playwright form is `./playwright`. */
2
+ export { McpAppsHost, } from "./protocol.js";
3
+ export { createMemoryHost, MemoryHost } from "./memory.js";
@@ -0,0 +1,37 @@
1
+ import { McpAppsHost, type HostOptions, type RpcMessage } from './protocol.ts';
2
+ export interface MemoryTransport {
3
+ start(): Promise<void>;
4
+ send(message: RpcMessage): Promise<void>;
5
+ close(): Promise<void>;
6
+ onmessage?: (message: RpcMessage) => void;
7
+ onclose?: () => void;
8
+ onerror?: (error: Error) => void;
9
+ }
10
+ export interface MemoryHostOptions extends HostOptions {
11
+ /** A request the test expects: answer it (a Response) or return undefined to refuse it as stray. */
12
+ network?: (url: string) => Response | undefined;
13
+ /** Fail the test when it ends on any problem. Default: true. */
14
+ failOnProblems?: boolean;
15
+ }
16
+ export declare class MemoryHost extends McpAppsHost {
17
+ readonly transport: MemoryTransport;
18
+ /** Console errors, page errors and stray requests, in order. */
19
+ readonly problems: string[];
20
+ readonly strayRequests: string[];
21
+ private readonly undo;
22
+ private closed;
23
+ constructor(options: MemoryHostOptions);
24
+ private watch;
25
+ /**
26
+ * For an app that connects to `window.parent` itself (`new PostMessageTransport(window.parent, window.parent)`):
27
+ * replaces `window.parent` with a stand-in whose `postMessage` reaches this host, and answers with `message` events
28
+ * whose `source` is that stand-in. Undone by `close()`.
29
+ */
30
+ installParent(): Window;
31
+ /** Puts back everything the host replaced. */
32
+ close(): void;
33
+ /** Throws listing every problem: console and page errors, stray requests, unexpected tool calls. */
34
+ assertClean(): void;
35
+ }
36
+ /** Opens a host for the current test. */
37
+ export declare function createMemoryHost(options: MemoryHostOptions): MemoryHost;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The in-memory form of the host, for a Vitest file under jsdom. The app connects to `host.transport` as its bundle
3
+ * connects to `window.parent` (with ext-apps: `await app.connect(host.transport)`); each message is delivered on a
4
+ * microtask, as `postMessage` delivers it, so an ordering bug shows here as it would in a frame. An app that connects
5
+ * to `window.parent` itself gets a stand-in parent from `host.installParent()`.
6
+ *
7
+ * While the host is open, a console error, a page error (`window` `error` event) or a network request (`fetch`,
8
+ * `XMLHttpRequest`) is a problem, and so is a tool call the test gave no answer for. Opened inside a test, the host
9
+ * fails that test on any problem when it ends; `assertClean()` asks the same at any moment.
10
+ */
11
+ import { onTestFinished } from 'vitest';
12
+ import { McpAppsHost } from "./protocol.js";
13
+ export class MemoryHost extends McpAppsHost {
14
+ transport;
15
+ /** Console errors, page errors and stray requests, in order. */
16
+ problems = [];
17
+ strayRequests = [];
18
+ undo = [];
19
+ closed = false;
20
+ constructor(options) {
21
+ super(options);
22
+ const transport = {
23
+ start: async () => { },
24
+ send: async (message) => {
25
+ queueMicrotask(() => {
26
+ this.receive(message).catch((error) => this.problems.push(`host: ${String(error)}`));
27
+ });
28
+ },
29
+ close: async () => {
30
+ transport.onclose?.();
31
+ },
32
+ };
33
+ this.transport = transport;
34
+ this.post = (message) => {
35
+ queueMicrotask(() => transport.onmessage?.(structuredClone(message)));
36
+ };
37
+ this.watch(options);
38
+ }
39
+ watch(options) {
40
+ const realError = console.error;
41
+ const host = this;
42
+ const recording = function recordingConsoleError(...args) {
43
+ host.problems.push(`console error: ${args.map(String).join(' ')}`);
44
+ return realError.apply(this, args);
45
+ };
46
+ console.error = recording;
47
+ this.undo.push(() => {
48
+ if (console.error === recording)
49
+ console.error = realError;
50
+ });
51
+ if (typeof window !== 'undefined') {
52
+ const onError = (event) => this.problems.push(`page error: ${event.message || String(event.error)}`);
53
+ window.addEventListener('error', onError);
54
+ this.undo.push(() => window.removeEventListener('error', onError));
55
+ }
56
+ const stray = (url) => {
57
+ this.strayRequests.push(url);
58
+ return new TypeError(`test-kit host: a request the test did not expect: ${url}`);
59
+ };
60
+ if (typeof globalThis.fetch === 'function') {
61
+ const realFetch = globalThis.fetch;
62
+ const guarded = (async (input) => {
63
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
64
+ if (/^(data|blob):/.test(url))
65
+ return realFetch(input);
66
+ const answered = options.network?.(url);
67
+ if (answered)
68
+ return answered;
69
+ throw stray(url);
70
+ });
71
+ globalThis.fetch = guarded;
72
+ this.undo.push(() => {
73
+ if (globalThis.fetch === guarded)
74
+ globalThis.fetch = realFetch;
75
+ });
76
+ }
77
+ const Xhr = globalThis.XMLHttpRequest;
78
+ if (Xhr) {
79
+ const realOpen = Xhr.prototype.open;
80
+ const realSend = Xhr.prototype.send;
81
+ const urls = new WeakMap();
82
+ Xhr.prototype.open = function open(...args) {
83
+ urls.set(this, String(args[1]));
84
+ return realOpen.apply(this, args);
85
+ };
86
+ Xhr.prototype.send = function send() {
87
+ throw stray(urls.get(this) ?? 'an XMLHttpRequest');
88
+ };
89
+ this.undo.push(() => {
90
+ Xhr.prototype.open = realOpen;
91
+ Xhr.prototype.send = realSend;
92
+ });
93
+ }
94
+ if (options.failOnProblems ?? true) {
95
+ try {
96
+ onTestFinished(() => {
97
+ this.close();
98
+ this.assertClean();
99
+ });
100
+ }
101
+ catch {
102
+ // Opened outside a test (a beforeAll): the test calls assertClean() itself.
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * For an app that connects to `window.parent` itself (`new PostMessageTransport(window.parent, window.parent)`):
108
+ * replaces `window.parent` with a stand-in whose `postMessage` reaches this host, and answers with `message` events
109
+ * whose `source` is that stand-in. Undone by `close()`.
110
+ */
111
+ installParent() {
112
+ if (typeof window === 'undefined')
113
+ throw new Error('test-kit host: installParent() needs a jsdom window');
114
+ const original = Object.getOwnPropertyDescriptor(window, 'parent');
115
+ const parent = {
116
+ postMessage: (message) => {
117
+ queueMicrotask(() => {
118
+ this.receive(structuredClone(message)).catch((error) => this.problems.push(`host: ${String(error)}`));
119
+ });
120
+ },
121
+ };
122
+ Object.defineProperty(window, 'parent', { value: parent, configurable: true });
123
+ this.post = (message) => {
124
+ queueMicrotask(() => {
125
+ const event = new MessageEvent('message', { data: structuredClone(message) });
126
+ Object.defineProperty(event, 'source', { value: parent });
127
+ window.dispatchEvent(event);
128
+ });
129
+ };
130
+ this.undo.push(() => {
131
+ if (original)
132
+ Object.defineProperty(window, 'parent', original);
133
+ });
134
+ return parent;
135
+ }
136
+ /** Puts back everything the host replaced. */
137
+ close() {
138
+ if (this.closed)
139
+ return;
140
+ this.closed = true;
141
+ for (const undo of this.undo.splice(0).reverse())
142
+ undo();
143
+ }
144
+ /** Throws listing every problem: console and page errors, stray requests, unexpected tool calls. */
145
+ assertClean() {
146
+ const lines = [
147
+ ...this.problems,
148
+ ...this.strayRequests.map((url) => `stray request: ${url}`),
149
+ ...this.unanswered.map((call) => `unexpected tool call: ${call.name} ${JSON.stringify(call.arguments)}`),
150
+ ];
151
+ if (lines.length > 0)
152
+ throw new Error(`The MCP Apps test host saw problems:\n${lines.join('\n')}`);
153
+ }
154
+ }
155
+ /** Opens a host for the current test. */
156
+ export function createMemoryHost(options) {
157
+ return new MemoryHost(options);
158
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The Playwright form of the host: a real browser page on a host origin holding the app in a sandboxed iframe on another
3
+ * origin (no `allow-same-origin`, so the frame is opaque to its host, as a chat client's is), served under a CSP header.
4
+ * No socket opens: `page.route` serves the two documents and aborts and records every other request. The protocol runs
5
+ * in Node (`McpAppsHost`, the same class the in-memory form drives), so both forms answer alike.
6
+ *
7
+ * Use the exported `test`: on every page it fails the test on a console error, a page error, a stray request or a tool
8
+ * call the test gave no answer for, without the test asking.
9
+ */
10
+ import { expect, type Frame, type FrameLocator, type Page, type Route } from '@playwright/test';
11
+ import { McpAppsHost, type HostOptions } from './protocol.ts';
12
+ export declare const HOST_ORIGIN = "https://host.mcp-apps.test";
13
+ export declare const SANDBOX_ORIGIN = "https://sandbox.mcp-apps.test";
14
+ /** A CSP for the app document that lets inline code run and nothing load or connect. */
15
+ export declare const STRICT_APP_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'";
16
+ /** ChatGPT's `window.openai` file functions in the app: absent, both, only `uploadFile`, or one of them failing. */
17
+ export type OpenaiStub = 'none' | 'both' | 'upload-only' | 'upload-rejects' | 'download-rejects';
18
+ export interface BrowserHostOptions extends HostOptions {
19
+ /** The app's document (a built card bundle). */
20
+ html: string;
21
+ /** The app document's CSP header; null sends none. Default: {@link STRICT_APP_CSP}. */
22
+ csp?: string | null;
23
+ /** The iframe's `sandbox`. Default `allow-scripts allow-forms`. */
24
+ sandbox?: string;
25
+ /** Only on the chatgpt profile. Default `none`. */
26
+ openai?: OpenaiStub;
27
+ /** A request the test expects: fulfil it and answer true; false leaves it stray. */
28
+ network?: (route: Route) => Promise<boolean>;
29
+ }
30
+ export declare class BrowserHost extends McpAppsHost {
31
+ readonly page: Page;
32
+ /** Console errors, page errors and failed requests, in order. */
33
+ readonly problems: string[];
34
+ /** Requests that were neither the host page nor the app document. */
35
+ readonly strayRequests: string[];
36
+ /** What the app asked of `window.openai`: `upload <file name>` or `download <file id>`. */
37
+ readonly openaiCalls: string[];
38
+ constructor(page: Page, options: HostOptions);
39
+ /** The app's iframe, to find things in. */
40
+ get ui(): FrameLocator;
41
+ /** The app's frame, to evaluate in. */
42
+ frame(): Frame;
43
+ /** A modal in the host page, as a permission prompt opens one: the frame goes inert and loses focus. */
44
+ openHostDialog(): Promise<void>;
45
+ closeHostDialog(): Promise<void>;
46
+ /** The app document's sideways overflow, in pixels. */
47
+ sidewaysOverflow(): Promise<number>;
48
+ /** Fails on a console or page error, a stray request or a tool call the test did not expect. */
49
+ assertClean(): void;
50
+ }
51
+ /** Opens the app in a host page, handshake done unless the host refuses it. */
52
+ export declare function openApp(page: Page, options: BrowserHostOptions): Promise<BrowserHost>;
53
+ /**
54
+ * Playwright's `test` with the host's watch on every page: requests a page makes before or outside `openApp` are
55
+ * refused and recorded, and when the test ends every host it opened must be clean.
56
+ */
57
+ export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
58
+ mcpAppsHostWatch: void;
59
+ }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
60
+ export { expect };