@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,190 @@
|
|
|
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 { test as base, expect } from '@playwright/test';
|
|
11
|
+
import { McpAppsHost } from "./protocol.js";
|
|
12
|
+
export const HOST_ORIGIN = 'https://host.mcp-apps.test';
|
|
13
|
+
export const SANDBOX_ORIGIN = 'https://sandbox.mcp-apps.test';
|
|
14
|
+
const APP_URL = `${SANDBOX_ORIGIN}/app.html`;
|
|
15
|
+
/** A CSP for the app document that lets inline code run and nothing load or connect. */
|
|
16
|
+
export 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'";
|
|
17
|
+
const HOST_HTML = `<!doctype html>
|
|
18
|
+
<html lang="en"><head><meta charset="utf-8"><title>MCP Apps test host</title>
|
|
19
|
+
<style>html,body{margin:0;padding:0}iframe{display:block;border:0;background:transparent}</style></head>
|
|
20
|
+
<body><iframe id="app" title="app"></iframe><dialog id="host-dialog">Allow this?</dialog>
|
|
21
|
+
<script>
|
|
22
|
+
const config = /*__CONFIG__*/ null;
|
|
23
|
+
const frame = document.getElementById('app');
|
|
24
|
+
frame.setAttribute('sandbox', config.sandbox);
|
|
25
|
+
document.documentElement.style.colorScheme = config.theme;
|
|
26
|
+
document.documentElement.style.background = config.theme === 'dark' ? '#212121' : '#ffffff';
|
|
27
|
+
const size = (s) => { if (s.width !== undefined) frame.style.width = s.width + 'px'; if (s.height !== undefined) frame.style.height = s.height + 'px'; };
|
|
28
|
+
size(config.frame);
|
|
29
|
+
window.__tkResize = size;
|
|
30
|
+
window.__tkToApp = (message) => frame.contentWindow.postMessage(message, '*');
|
|
31
|
+
addEventListener('message', (event) => {
|
|
32
|
+
if (event.source !== frame.contentWindow) return;
|
|
33
|
+
const message = event.data;
|
|
34
|
+
if (message && typeof message === 'object' && typeof message.tkOpenai === 'string') { window.__tkOpenai(message); return; }
|
|
35
|
+
if (message && typeof message === 'object' && message.jsonrpc === '2.0') window.__tkHostReceive(message);
|
|
36
|
+
});
|
|
37
|
+
frame.src = config.appUrl;
|
|
38
|
+
</script></body></html>`;
|
|
39
|
+
function openaiScript(mode) {
|
|
40
|
+
if (mode === 'none')
|
|
41
|
+
return '';
|
|
42
|
+
const upload = mode === 'upload-rejects'
|
|
43
|
+
? `() => Promise.reject(new Error('no upload'))`
|
|
44
|
+
: `(file) => { parent.postMessage({ tkOpenai: 'upload', name: file && file.name }, '*'); return Promise.resolve({ fileId: 'file-fixture' }); }`;
|
|
45
|
+
const download = mode === 'upload-only'
|
|
46
|
+
? ''
|
|
47
|
+
: mode === 'download-rejects'
|
|
48
|
+
? `getFileDownloadUrl: () => Promise.reject(new Error('no link')),`
|
|
49
|
+
: `getFileDownloadUrl: ({ fileId }) => { parent.postMessage({ tkOpenai: 'download', name: fileId }, '*'); return Promise.resolve({ downloadUrl: 'https://files.mcp-apps.test/' + fileId }); },`;
|
|
50
|
+
return `<script>window.openai = { uploadFile: ${upload}, ${download} };</script>`;
|
|
51
|
+
}
|
|
52
|
+
function withHeadScript(html, script) {
|
|
53
|
+
if (script === '')
|
|
54
|
+
return html;
|
|
55
|
+
return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (head) => `${head}${script}`) : `${script}${html}`;
|
|
56
|
+
}
|
|
57
|
+
export class BrowserHost extends McpAppsHost {
|
|
58
|
+
page;
|
|
59
|
+
/** Console errors, page errors and failed requests, in order. */
|
|
60
|
+
problems = [];
|
|
61
|
+
/** Requests that were neither the host page nor the app document. */
|
|
62
|
+
strayRequests = [];
|
|
63
|
+
/** What the app asked of `window.openai`: `upload <file name>` or `download <file id>`. */
|
|
64
|
+
openaiCalls = [];
|
|
65
|
+
constructor(page, options) {
|
|
66
|
+
super(options);
|
|
67
|
+
this.page = page;
|
|
68
|
+
}
|
|
69
|
+
/** The app's iframe, to find things in. */
|
|
70
|
+
get ui() {
|
|
71
|
+
return this.page.frameLocator('#app');
|
|
72
|
+
}
|
|
73
|
+
/** The app's frame, to evaluate in. */
|
|
74
|
+
frame() {
|
|
75
|
+
const frame = this.page.frames().find((f) => f.url() === APP_URL);
|
|
76
|
+
if (!frame)
|
|
77
|
+
throw new Error('test-kit host: the app frame is not there');
|
|
78
|
+
return frame;
|
|
79
|
+
}
|
|
80
|
+
/** A modal in the host page, as a permission prompt opens one: the frame goes inert and loses focus. */
|
|
81
|
+
async openHostDialog() {
|
|
82
|
+
await this.page.evaluate(() => document.getElementById('host-dialog').showModal());
|
|
83
|
+
}
|
|
84
|
+
async closeHostDialog() {
|
|
85
|
+
await this.page.evaluate(() => document.getElementById('host-dialog').close());
|
|
86
|
+
}
|
|
87
|
+
/** The app document's sideways overflow, in pixels. */
|
|
88
|
+
sidewaysOverflow() {
|
|
89
|
+
return this.frame().evaluate(() => {
|
|
90
|
+
const root = document.documentElement;
|
|
91
|
+
return Math.max(root.scrollWidth, document.body.scrollWidth) - root.clientWidth;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Fails on a console or page error, a stray request or a tool call the test did not expect. */
|
|
95
|
+
assertClean() {
|
|
96
|
+
expect(this.problems, 'console errors, page errors, failed requests').toEqual([]);
|
|
97
|
+
expect(this.strayRequests, 'requests other than the host page and the app').toEqual([]);
|
|
98
|
+
expect(this.unanswered, 'tool calls the test did not expect').toEqual([]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const HOSTS = new WeakMap();
|
|
102
|
+
/** Opens the app in a host page, handshake done unless the host refuses it. */
|
|
103
|
+
export async function openApp(page, options) {
|
|
104
|
+
const { html, csp = STRICT_APP_CSP, sandbox = 'allow-scripts allow-forms', network } = options;
|
|
105
|
+
const openai = options.openai ?? 'none';
|
|
106
|
+
if (options.profile !== 'chatgpt' && openai !== 'none') {
|
|
107
|
+
throw new Error('test-kit host: window.openai belongs to the chatgpt profile');
|
|
108
|
+
}
|
|
109
|
+
const host = new BrowserHost(page, options);
|
|
110
|
+
HOSTS.set(page, [...(HOSTS.get(page) ?? []), host]);
|
|
111
|
+
const width = options.width ?? 720;
|
|
112
|
+
const height = options.displayMode === 'fullscreen' ? (options.height ?? 900) : (options.height ?? 400);
|
|
113
|
+
page.on('console', (m) => {
|
|
114
|
+
if (m.type() === 'error')
|
|
115
|
+
host.problems.push(`console error: ${m.text()}`);
|
|
116
|
+
});
|
|
117
|
+
page.on('pageerror', (e) => host.problems.push(`page error: ${e.message}`));
|
|
118
|
+
page.on('requestfailed', (r) => {
|
|
119
|
+
if (!host.strayRequests.includes(r.url()))
|
|
120
|
+
host.problems.push(`request failed: ${r.url()}`);
|
|
121
|
+
});
|
|
122
|
+
await page.setViewportSize({ width: width + 40, height: Math.max(height, options.height ?? 900) + 40 });
|
|
123
|
+
await page.emulateMedia({ colorScheme: options.theme ?? 'light' });
|
|
124
|
+
await page.route('**/*', async (route) => {
|
|
125
|
+
const url = route.request().url();
|
|
126
|
+
if (url === `${HOST_ORIGIN}/`) {
|
|
127
|
+
const config = { theme: options.theme ?? 'light', sandbox, appUrl: APP_URL, frame: { width, height } };
|
|
128
|
+
await route.fulfill({ contentType: 'text/html', body: HOST_HTML.replace('/*__CONFIG__*/ null', JSON.stringify(config)) });
|
|
129
|
+
}
|
|
130
|
+
else if (url === APP_URL) {
|
|
131
|
+
await route.fulfill({
|
|
132
|
+
contentType: 'text/html',
|
|
133
|
+
headers: csp === null ? {} : { 'content-security-policy': csp },
|
|
134
|
+
body: withHeadScript(html, openaiScript(openai)),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else if (!(await network?.(route))) {
|
|
138
|
+
host.strayRequests.push(url);
|
|
139
|
+
await route.abort('blockedbyclient');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
await page.exposeFunction('__tkHostReceive', (message) => host.receive(message));
|
|
143
|
+
await page.exposeFunction('__tkOpenai', (message) => {
|
|
144
|
+
host.openaiCalls.push(`${message.tkOpenai} ${message.name ?? ''}`.trim());
|
|
145
|
+
});
|
|
146
|
+
host.post = async (message) => {
|
|
147
|
+
await page.evaluate((m) => window.__tkToApp(m), message);
|
|
148
|
+
};
|
|
149
|
+
host.resize = async (s) => {
|
|
150
|
+
await page.evaluate((size) => window.__tkResize(size), s);
|
|
151
|
+
};
|
|
152
|
+
await page.goto(`${HOST_ORIGIN}/`);
|
|
153
|
+
// The frame is opaque to its host, as a chat client's is.
|
|
154
|
+
await page.waitForFunction(() => {
|
|
155
|
+
const frame = document.getElementById('app');
|
|
156
|
+
return frame !== null && frame.contentDocument === null;
|
|
157
|
+
});
|
|
158
|
+
if (!options.refuseInitialize) {
|
|
159
|
+
const deadline = Date.now() + 15_000;
|
|
160
|
+
while (!host.initialized) {
|
|
161
|
+
if (Date.now() > deadline)
|
|
162
|
+
throw new Error('test-kit host: the app never sent ui/notifications/initialized');
|
|
163
|
+
await page.waitForTimeout(20);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return host;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Playwright's `test` with the host's watch on every page: requests a page makes before or outside `openApp` are
|
|
170
|
+
* refused and recorded, and when the test ends every host it opened must be clean.
|
|
171
|
+
*/
|
|
172
|
+
export const test = base.extend({
|
|
173
|
+
mcpAppsHostWatch: [
|
|
174
|
+
async ({ page }, use) => {
|
|
175
|
+
const problems = [];
|
|
176
|
+
page.on('pageerror', (error) => problems.push(`page error: ${error.message}`));
|
|
177
|
+
await page.route('**/*', async (route) => {
|
|
178
|
+
problems.push(`request outside the host: ${route.request().url()}`);
|
|
179
|
+
await route.abort('blockedbyclient');
|
|
180
|
+
});
|
|
181
|
+
await use();
|
|
182
|
+
for (const host of HOSTS.get(page) ?? [])
|
|
183
|
+
host.assertClean();
|
|
184
|
+
if ((HOSTS.get(page) ?? []).length === 0)
|
|
185
|
+
expect(problems, 'page errors and requests').toEqual([]);
|
|
186
|
+
},
|
|
187
|
+
{ auto: true },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
export { expect };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's half of the MCP Apps protocol, with no transport: a message from the app in, messages to the app out
|
|
3
|
+
* through `post`. The in-memory form (`./memory.ts`, for jsdom) and the Playwright form (`./playwright.ts`) both drive
|
|
4
|
+
* this one class, so the two answer every message the same way.
|
|
5
|
+
*
|
|
6
|
+
* Every message is recorded in order, both ways (`log`), so a test asserts on the wire, not on the DOM's opinion of
|
|
7
|
+
* what happened.
|
|
8
|
+
*/
|
|
9
|
+
export type Json = Record<string, unknown>;
|
|
10
|
+
export interface RpcMessage {
|
|
11
|
+
jsonrpc?: '2.0';
|
|
12
|
+
id?: number | string;
|
|
13
|
+
method?: string;
|
|
14
|
+
params?: Json;
|
|
15
|
+
result?: unknown;
|
|
16
|
+
error?: {
|
|
17
|
+
code: number;
|
|
18
|
+
message: string;
|
|
19
|
+
data?: unknown;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface Recorded {
|
|
23
|
+
from: 'app' | 'host';
|
|
24
|
+
message: RpcMessage;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The two hosts. `chatgpt` takes `ui/update-model-context` and can give the page `window.openai`'s file functions;
|
|
28
|
+
* `claude` lists no model-context capability and refuses the method with -32601. Everything else is the same.
|
|
29
|
+
*/
|
|
30
|
+
export type HostProfile = 'chatgpt' | 'claude';
|
|
31
|
+
export type Theme = 'light' | 'dark';
|
|
32
|
+
export type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
33
|
+
/** A tool call's answer: a result, or `undefined` for a call the test did not expect. A throw is the call failing. */
|
|
34
|
+
export type ToolAnswer = (name: string, args: Json) => Json | undefined | Promise<Json | undefined>;
|
|
35
|
+
export interface ToolCall {
|
|
36
|
+
name: string;
|
|
37
|
+
arguments: Json;
|
|
38
|
+
}
|
|
39
|
+
/** One thing the app asked the host to do, as a test states it. */
|
|
40
|
+
export type Action = {
|
|
41
|
+
tool: string;
|
|
42
|
+
arguments: Json;
|
|
43
|
+
} | {
|
|
44
|
+
message: string;
|
|
45
|
+
} | {
|
|
46
|
+
context: string;
|
|
47
|
+
} | {
|
|
48
|
+
link: string;
|
|
49
|
+
} | {
|
|
50
|
+
display: string;
|
|
51
|
+
};
|
|
52
|
+
export interface HostOptions {
|
|
53
|
+
profile: HostProfile;
|
|
54
|
+
/** The tool the host says opened the app. */
|
|
55
|
+
tool?: {
|
|
56
|
+
name: string;
|
|
57
|
+
inputSchema?: Json;
|
|
58
|
+
};
|
|
59
|
+
/** Sent as `ui/notifications/tool-input` right after `initialized`; left out when undefined. */
|
|
60
|
+
toolInput?: Json;
|
|
61
|
+
/** Sent as `ui/notifications/tool-result` after the input; null sends none. */
|
|
62
|
+
toolResult?: Json | null;
|
|
63
|
+
/** Answers each `tools/call`. Default: none, so every call is unexpected. */
|
|
64
|
+
answer?: ToolAnswer;
|
|
65
|
+
/** Hold every tool call's answer (or the named tools') until `release()`. */
|
|
66
|
+
hold?: boolean | readonly string[];
|
|
67
|
+
theme?: Theme;
|
|
68
|
+
displayMode?: DisplayMode;
|
|
69
|
+
availableDisplayModes?: DisplayMode[];
|
|
70
|
+
/** `declared` grants a mode the app declared and the host lists; `inline` never moves; `fail` refuses the request. */
|
|
71
|
+
displayModes?: 'declared' | 'inline' | 'fail';
|
|
72
|
+
locale?: string;
|
|
73
|
+
/** An IANA zone; null gives none. */
|
|
74
|
+
timeZone?: string | null;
|
|
75
|
+
width?: number;
|
|
76
|
+
height?: number;
|
|
77
|
+
/** `ui/open-link`: opened, refused (`isError: true`), or failed (a JSON-RPC error). */
|
|
78
|
+
openLink?: 'open' | 'refuse' | 'fail';
|
|
79
|
+
/** `ui/message`: accepted, refused by the person (`isError: true`), or unsupported (no capability, -32601). */
|
|
80
|
+
message?: 'accept' | 'refuse' | 'unsupported';
|
|
81
|
+
/** `ui/update-model-context` on the chatgpt profile: answered, never answered, or refused with -32601. */
|
|
82
|
+
modelContext?: 'accept' | 'ignore' | 'reject';
|
|
83
|
+
/** Answer `ui/initialize` with an error. */
|
|
84
|
+
refuseInitialize?: boolean;
|
|
85
|
+
/** Merged over the profile's capabilities and context. */
|
|
86
|
+
hostCapabilities?: Json;
|
|
87
|
+
hostContext?: Json;
|
|
88
|
+
}
|
|
89
|
+
export declare class McpAppsHost {
|
|
90
|
+
readonly options: HostOptions;
|
|
91
|
+
readonly log: Recorded[];
|
|
92
|
+
/** Tool calls the test had no answer for. */
|
|
93
|
+
readonly unanswered: ToolCall[];
|
|
94
|
+
/** The app's `ui/initialize` params, once it sent them. */
|
|
95
|
+
initialize: Json | null;
|
|
96
|
+
initialized: boolean;
|
|
97
|
+
hostContext: Json;
|
|
98
|
+
readonly hostCapabilities: Json;
|
|
99
|
+
/** Set by the transport: how a message reaches the app, and how the frame is resized. */
|
|
100
|
+
post: (message: RpcMessage) => void | Promise<void>;
|
|
101
|
+
resize: (size: {
|
|
102
|
+
width?: number;
|
|
103
|
+
height?: number;
|
|
104
|
+
}) => void | Promise<void>;
|
|
105
|
+
private readonly held;
|
|
106
|
+
private readonly pending;
|
|
107
|
+
private nextId;
|
|
108
|
+
constructor(options: HostOptions);
|
|
109
|
+
/** Every message the app sent with `method`, in order. */
|
|
110
|
+
sent(method: string): RpcMessage[];
|
|
111
|
+
/** The app's tool calls, optionally of one tool. */
|
|
112
|
+
calls(name?: string): ToolCall[];
|
|
113
|
+
/** The methods the app sent, in order. */
|
|
114
|
+
methods(): string[];
|
|
115
|
+
/** Everything the app asked of the host, in order: tool calls, messages, context, links, display modes. */
|
|
116
|
+
actions(): Action[];
|
|
117
|
+
messages(): string[];
|
|
118
|
+
modelContext(): string[];
|
|
119
|
+
links(): string[];
|
|
120
|
+
/** Answers every held tool call, in the order the app made them. */
|
|
121
|
+
release(): Promise<void>;
|
|
122
|
+
/** A later tool result, as the next model call in a conversation would send. */
|
|
123
|
+
sendToolResult(result: Json): Promise<void>;
|
|
124
|
+
sendToolInput(args: Json): Promise<void>;
|
|
125
|
+
cancelTool(reason?: string): Promise<void>;
|
|
126
|
+
/** `ui/notifications/host-context-changed`, merged into what the host holds. */
|
|
127
|
+
changeContext(patch: Json): Promise<void>;
|
|
128
|
+
setTheme(theme: Theme): Promise<void>;
|
|
129
|
+
/** `ui/resource-teardown`, answering the app's reply. */
|
|
130
|
+
teardown(): Promise<RpcMessage>;
|
|
131
|
+
/** A request from the host; resolves with the app's reply. */
|
|
132
|
+
request(method: string, params: Json): Promise<RpcMessage>;
|
|
133
|
+
private deliver;
|
|
134
|
+
private answer;
|
|
135
|
+
private refuse;
|
|
136
|
+
private holds;
|
|
137
|
+
/** One message from the app. */
|
|
138
|
+
receive(message: RpcMessage): Promise<void>;
|
|
139
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's half of the MCP Apps protocol, with no transport: a message from the app in, messages to the app out
|
|
3
|
+
* through `post`. The in-memory form (`./memory.ts`, for jsdom) and the Playwright form (`./playwright.ts`) both drive
|
|
4
|
+
* this one class, so the two answer every message the same way.
|
|
5
|
+
*
|
|
6
|
+
* Every message is recorded in order, both ways (`log`), so a test asserts on the wire, not on the DOM's opinion of
|
|
7
|
+
* what happened.
|
|
8
|
+
*/
|
|
9
|
+
const HOST_INFO = {
|
|
10
|
+
chatgpt: { name: 'test-kit-chatgpt-host', version: '1.0.0' },
|
|
11
|
+
claude: { name: 'test-kit-claude-host', version: '1.0.0' },
|
|
12
|
+
};
|
|
13
|
+
function textOf(params) {
|
|
14
|
+
const content = (params?.content ?? []);
|
|
15
|
+
return content.map((part) => (typeof part.text === 'string' ? part.text : '')).join('\n');
|
|
16
|
+
}
|
|
17
|
+
function actionOf(message) {
|
|
18
|
+
const p = message.params;
|
|
19
|
+
switch (message.method) {
|
|
20
|
+
case 'tools/call':
|
|
21
|
+
return { tool: String(p?.name), arguments: (p?.arguments ?? {}) };
|
|
22
|
+
case 'ui/message':
|
|
23
|
+
return { message: textOf(p) };
|
|
24
|
+
case 'ui/update-model-context':
|
|
25
|
+
return { context: textOf(p) };
|
|
26
|
+
case 'ui/open-link':
|
|
27
|
+
return { link: String(p?.url) };
|
|
28
|
+
case 'ui/request-display-mode':
|
|
29
|
+
return { display: String(p?.mode) };
|
|
30
|
+
default:
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class McpAppsHost {
|
|
35
|
+
options;
|
|
36
|
+
log = [];
|
|
37
|
+
/** Tool calls the test had no answer for. */
|
|
38
|
+
unanswered = [];
|
|
39
|
+
/** The app's `ui/initialize` params, once it sent them. */
|
|
40
|
+
initialize = null;
|
|
41
|
+
initialized = false;
|
|
42
|
+
hostContext;
|
|
43
|
+
hostCapabilities;
|
|
44
|
+
/** Set by the transport: how a message reaches the app, and how the frame is resized. */
|
|
45
|
+
post = () => {
|
|
46
|
+
throw new Error('test-kit: the host has no transport yet');
|
|
47
|
+
};
|
|
48
|
+
resize = () => { };
|
|
49
|
+
held = [];
|
|
50
|
+
pending = new Map();
|
|
51
|
+
nextId = 1_000_000;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
if (options.profile !== 'chatgpt' && options.profile !== 'claude') {
|
|
54
|
+
throw new Error(`test-kit: unknown host profile ${String(options.profile)}; use 'chatgpt' or 'claude'`);
|
|
55
|
+
}
|
|
56
|
+
if (options.profile === 'claude' && options.modelContext !== undefined) {
|
|
57
|
+
throw new Error('test-kit: the claude profile takes no model context, so `modelContext` does not apply');
|
|
58
|
+
}
|
|
59
|
+
this.options = options;
|
|
60
|
+
const mode = options.displayMode ?? 'inline';
|
|
61
|
+
const width = options.width ?? 720;
|
|
62
|
+
this.hostContext = {
|
|
63
|
+
theme: options.theme ?? 'light',
|
|
64
|
+
displayMode: mode,
|
|
65
|
+
availableDisplayModes: options.availableDisplayModes ?? ['inline', 'fullscreen'],
|
|
66
|
+
locale: options.locale ?? 'en-US',
|
|
67
|
+
...(options.timeZone === null ? {} : { timeZone: options.timeZone ?? 'America/New_York' }),
|
|
68
|
+
...(options.tool ? { toolInfo: { tool: { inputSchema: { type: 'object' }, ...options.tool } } } : {}),
|
|
69
|
+
containerDimensions: mode === 'fullscreen' ? { width, height: options.height ?? 900 } : { width, maxHeight: 6000 },
|
|
70
|
+
...options.hostContext,
|
|
71
|
+
};
|
|
72
|
+
const message = options.message ?? 'accept';
|
|
73
|
+
this.hostCapabilities = {
|
|
74
|
+
openLinks: {},
|
|
75
|
+
serverTools: {},
|
|
76
|
+
logging: {},
|
|
77
|
+
...(message === 'unsupported' ? {} : { message: { text: {} } }),
|
|
78
|
+
...(options.profile === 'chatgpt' && options.modelContext !== 'reject' ? { updateModelContext: { text: {} } } : {}),
|
|
79
|
+
...options.hostCapabilities,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ─── What a test reads ─────────────────────────────────────────────────────
|
|
83
|
+
/** Every message the app sent with `method`, in order. */
|
|
84
|
+
sent(method) {
|
|
85
|
+
return this.log.filter((r) => r.from === 'app' && r.message.method === method).map((r) => r.message);
|
|
86
|
+
}
|
|
87
|
+
/** The app's tool calls, optionally of one tool. */
|
|
88
|
+
calls(name) {
|
|
89
|
+
return this.sent('tools/call')
|
|
90
|
+
.map((m) => ({ name: String(m.params?.name), arguments: (m.params?.arguments ?? {}) }))
|
|
91
|
+
.filter((c) => name === undefined || c.name === name);
|
|
92
|
+
}
|
|
93
|
+
/** The methods the app sent, in order. */
|
|
94
|
+
methods() {
|
|
95
|
+
return this.log.flatMap((r) => (r.from === 'app' && r.message.method ? [r.message.method] : []));
|
|
96
|
+
}
|
|
97
|
+
/** Everything the app asked of the host, in order: tool calls, messages, context, links, display modes. */
|
|
98
|
+
actions() {
|
|
99
|
+
return this.log.flatMap((r) => {
|
|
100
|
+
if (r.from !== 'app')
|
|
101
|
+
return [];
|
|
102
|
+
const action = actionOf(r.message);
|
|
103
|
+
return action ? [action] : [];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
messages() {
|
|
107
|
+
return this.sent('ui/message').map((m) => textOf(m.params));
|
|
108
|
+
}
|
|
109
|
+
modelContext() {
|
|
110
|
+
return this.sent('ui/update-model-context').map((m) => textOf(m.params));
|
|
111
|
+
}
|
|
112
|
+
links() {
|
|
113
|
+
return this.sent('ui/open-link').map((m) => String(m.params?.url));
|
|
114
|
+
}
|
|
115
|
+
// ─── What a test does as the host ──────────────────────────────────────────
|
|
116
|
+
/** Answers every held tool call, in the order the app made them. */
|
|
117
|
+
async release() {
|
|
118
|
+
for (const send of this.held.splice(0))
|
|
119
|
+
await send();
|
|
120
|
+
}
|
|
121
|
+
/** A later tool result, as the next model call in a conversation would send. */
|
|
122
|
+
sendToolResult(result) {
|
|
123
|
+
return this.deliver({ jsonrpc: '2.0', method: 'ui/notifications/tool-result', params: result });
|
|
124
|
+
}
|
|
125
|
+
sendToolInput(args) {
|
|
126
|
+
return this.deliver({ jsonrpc: '2.0', method: 'ui/notifications/tool-input', params: { arguments: args } });
|
|
127
|
+
}
|
|
128
|
+
cancelTool(reason = 'cancelled by the person') {
|
|
129
|
+
return this.deliver({ jsonrpc: '2.0', method: 'ui/notifications/tool-cancelled', params: { reason } });
|
|
130
|
+
}
|
|
131
|
+
/** `ui/notifications/host-context-changed`, merged into what the host holds. */
|
|
132
|
+
changeContext(patch) {
|
|
133
|
+
this.hostContext = { ...this.hostContext, ...patch };
|
|
134
|
+
return this.deliver({ jsonrpc: '2.0', method: 'ui/notifications/host-context-changed', params: patch });
|
|
135
|
+
}
|
|
136
|
+
setTheme(theme) {
|
|
137
|
+
return this.changeContext({ theme });
|
|
138
|
+
}
|
|
139
|
+
/** `ui/resource-teardown`, answering the app's reply. */
|
|
140
|
+
teardown() {
|
|
141
|
+
return this.request('ui/resource-teardown', {});
|
|
142
|
+
}
|
|
143
|
+
/** A request from the host; resolves with the app's reply. */
|
|
144
|
+
request(method, params) {
|
|
145
|
+
const id = this.nextId++;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
this.pending.set(id, resolve);
|
|
148
|
+
this.deliver({ jsonrpc: '2.0', id, method, params }).catch(reject);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// ─── The protocol ──────────────────────────────────────────────────────────
|
|
152
|
+
async deliver(message) {
|
|
153
|
+
const full = { jsonrpc: '2.0', ...message };
|
|
154
|
+
this.log.push({ from: 'host', message: full });
|
|
155
|
+
await this.post(full);
|
|
156
|
+
}
|
|
157
|
+
answer(id, result) {
|
|
158
|
+
return this.deliver({ jsonrpc: '2.0', id, result });
|
|
159
|
+
}
|
|
160
|
+
refuse(id, message, code = -32000, data) {
|
|
161
|
+
return this.deliver({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
|
|
162
|
+
}
|
|
163
|
+
holds(name) {
|
|
164
|
+
const hold = this.options.hold;
|
|
165
|
+
return hold === true || (Array.isArray(hold) && hold.includes(name));
|
|
166
|
+
}
|
|
167
|
+
/** One message from the app. */
|
|
168
|
+
async receive(message) {
|
|
169
|
+
if (message === null || typeof message !== 'object' || message.jsonrpc !== '2.0')
|
|
170
|
+
return;
|
|
171
|
+
this.log.push({ from: 'app', message });
|
|
172
|
+
const { id, method } = message;
|
|
173
|
+
const params = message.params ?? {};
|
|
174
|
+
if (method === undefined) {
|
|
175
|
+
if (id !== undefined) {
|
|
176
|
+
this.pending.get(id)?.(message);
|
|
177
|
+
this.pending.delete(id);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (id === undefined) {
|
|
182
|
+
if (method === 'ui/notifications/initialized') {
|
|
183
|
+
this.initialized = true;
|
|
184
|
+
if (this.options.toolInput !== undefined)
|
|
185
|
+
await this.sendToolInput(this.options.toolInput);
|
|
186
|
+
if (this.options.toolResult)
|
|
187
|
+
await this.sendToolResult(this.options.toolResult);
|
|
188
|
+
}
|
|
189
|
+
else if (method === 'ui/notifications/size-changed' && this.hostContext.displayMode === 'inline') {
|
|
190
|
+
const { width, height } = params;
|
|
191
|
+
await this.resize({ ...(width === undefined ? {} : { width }), ...(height === undefined ? {} : { height }) });
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
switch (method) {
|
|
196
|
+
case 'ui/initialize':
|
|
197
|
+
this.initialize = params;
|
|
198
|
+
if (this.options.refuseInitialize)
|
|
199
|
+
return this.refuse(id, 'this host takes no app', -32603);
|
|
200
|
+
return this.answer(id, {
|
|
201
|
+
protocolVersion: params.protocolVersion,
|
|
202
|
+
hostInfo: HOST_INFO[this.options.profile],
|
|
203
|
+
hostCapabilities: this.hostCapabilities,
|
|
204
|
+
hostContext: this.hostContext,
|
|
205
|
+
});
|
|
206
|
+
case 'tools/call': {
|
|
207
|
+
const call = { name: String(params.name), arguments: (params.arguments ?? {}) };
|
|
208
|
+
const send = async () => {
|
|
209
|
+
let result;
|
|
210
|
+
try {
|
|
211
|
+
result = await (this.options.answer ?? (() => undefined))(call.name, call.arguments);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const { code, data } = error;
|
|
215
|
+
return this.refuse(id, String(error?.message ?? error), typeof code === 'number' ? code : -32000, data);
|
|
216
|
+
}
|
|
217
|
+
if (result === undefined) {
|
|
218
|
+
this.unanswered.push(call);
|
|
219
|
+
return this.refuse(id, `the test host has no answer for ${call.name}`, -32603);
|
|
220
|
+
}
|
|
221
|
+
return this.answer(id, result);
|
|
222
|
+
};
|
|
223
|
+
if (this.holds(call.name)) {
|
|
224
|
+
this.held.push(send);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
return send();
|
|
228
|
+
}
|
|
229
|
+
case 'ui/message': {
|
|
230
|
+
const mode = this.options.message ?? 'accept';
|
|
231
|
+
if (mode === 'unsupported')
|
|
232
|
+
return this.refuse(id, 'Method not found', -32601);
|
|
233
|
+
return this.answer(id, mode === 'refuse' ? { isError: true } : {});
|
|
234
|
+
}
|
|
235
|
+
case 'ui/update-model-context': {
|
|
236
|
+
if (this.options.profile === 'claude' || this.options.modelContext === 'reject') {
|
|
237
|
+
return this.refuse(id, 'Method not found', -32601);
|
|
238
|
+
}
|
|
239
|
+
if (this.options.modelContext === 'ignore')
|
|
240
|
+
return;
|
|
241
|
+
return this.answer(id, {});
|
|
242
|
+
}
|
|
243
|
+
case 'ui/open-link': {
|
|
244
|
+
const mode = this.options.openLink ?? 'open';
|
|
245
|
+
if (mode === 'fail')
|
|
246
|
+
return this.refuse(id, 'blocked');
|
|
247
|
+
return this.answer(id, mode === 'refuse' ? { isError: true } : {});
|
|
248
|
+
}
|
|
249
|
+
case 'ui/request-display-mode': {
|
|
250
|
+
const rule = this.options.displayModes ?? 'declared';
|
|
251
|
+
if (rule === 'fail')
|
|
252
|
+
return this.refuse(id, 'no display mode change here');
|
|
253
|
+
const asked = String(params.mode);
|
|
254
|
+
const declared = this.initialize?.appCapabilities?.availableDisplayModes ?? [];
|
|
255
|
+
const listed = this.hostContext.availableDisplayModes ?? [];
|
|
256
|
+
const grant = rule === 'declared' && declared.includes(asked) && listed.includes(asked);
|
|
257
|
+
const mode = grant ? asked : String(this.hostContext.displayMode);
|
|
258
|
+
await this.answer(id, { mode });
|
|
259
|
+
if (grant && mode !== this.hostContext.displayMode) {
|
|
260
|
+
if (mode === 'fullscreen')
|
|
261
|
+
await this.resize({ height: this.options.height ?? 900 });
|
|
262
|
+
await this.changeContext({ displayMode: mode });
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case 'ping':
|
|
267
|
+
return this.answer(id, {});
|
|
268
|
+
default:
|
|
269
|
+
return this.refuse(id, `Method not found: ${method}`, -32601);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# `@catalystiq/test-kit/setup`
|
|
2
|
+
|
|
3
|
+
The shared Vitest setup: one config helper and one worker setup file that every test file runs first.
|
|
4
|
+
|
|
5
|
+
## Use it
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// vitest.config.ts
|
|
9
|
+
import { defineTestKitConfig } from '@catalystiq/test-kit/setup/config';
|
|
10
|
+
|
|
11
|
+
export default defineTestKitConfig(
|
|
12
|
+
{
|
|
13
|
+
productionDatabaseHostsFile: 'db/production-hosts.json', // your production hosts, kept in your repo
|
|
14
|
+
keepEnv: ['TEST_DATABASE_URL'],
|
|
15
|
+
testDatabaseEnv: ['TEST_DATABASE_URL'],
|
|
16
|
+
allowHosts: [],
|
|
17
|
+
reactWarningAllowList: 'react-warnings.allow.json',
|
|
18
|
+
},
|
|
19
|
+
{ test: { setupFiles: ['./vitest.setup.ts'] } }, // your own setup runs after the kit's
|
|
20
|
+
);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A file that needs a DOM says so on its first line: `// @vitest-environment jsdom`.
|
|
24
|
+
|
|
25
|
+
## What it does, for every test file
|
|
26
|
+
|
|
27
|
+
| Rule | What happens |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Defaults | `environment: 'node'`, 30 s test and hook timeouts, `retry: 0` (a retry is refused), `server-only` resolves to an empty module, `*.pw.ts` is excluded, the kit's setup file runs first. |
|
|
30
|
+
| Credentials | Every variable whose name holds `KEY`, `TOKEN`, `SECRET`, `DSN`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `PRIVATE`, `WEBHOOK`, `COOKIE`, `SESSION` or `AUTH`, and every variable whose value is a URL with a password, is deleted. `keepEnv` keeps names; `stripEnv` deletes more. Set the placeholders your code needs in your own setup file. |
|
|
31
|
+
| Network | `fetch` and every TCP socket (`http`, `https`, `tls`, undici, database drivers) are refused unless the host is loopback, on `allowHosts` (`*.example.test` matches subdomains), or the host of a database URL that passed the check below (`allowDatabaseHosts`, default true). A refused request fails the test even when the code swallowed the error. `allowNetworkHosts(host)` adds one for the rest of a file. |
|
|
32
|
+
| Production databases | A Postgres URL in the environment whose host is on your production list (a host, a subdomain of it, or a compute endpoint id `ep-…` with or without `-pooler`) is refused before any test runs, and again before the tests (your own setup file may set the URL). |
|
|
33
|
+
| `.env*` files | A Postgres URL in the environment that names the same database as any `.env*` file at the root is refused: those hold a developer's own connections. Every `postgres://` text counts, comments included, except lines keyed by `testDatabaseEnv`. |
|
|
34
|
+
| React warnings | A React or Radix warning (`console.error` / `console.warn`) fails the test, also when the test muted the console (`vi.spyOn(console, 'error').mockImplementation(() => {})`, `console.error = vi.fn()`, `console.error = () => {}`). |
|
|
35
|
+
| DOM files | Under jsdom: a `matchMedia` stub, `localStorage` / `sessionStorage` re-bound on Node 26, Testing Library's `cleanup` after each test, and jest-dom's matchers when installed. |
|
|
36
|
+
|
|
37
|
+
## Allowing a React warning
|
|
38
|
+
|
|
39
|
+
In one test, while you mean to provoke it:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { allowReactWarnings } from '@catalystiq/test-kit/setup';
|
|
43
|
+
allowReactWarnings(/not wrapped in act\(/);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Across the repo, for warnings not fixed yet, `react-warnings.allow.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "max": 1, "entries": [{ "pattern": "not wrapped in act\\(", "file": "src/cart/", "reason": "cart timers, being fixed" }] }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`max` must equal the number of entries, and the list only shrinks: run `tk-check-allowlist react-warnings.allow.json`
|
|
53
|
+
in CI (it needs the base branch fetched); it fails when an entry was added or `max` rose against `origin/main`
|
|
54
|
+
(`--base` to change it).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface AllowListEntry {
|
|
2
|
+
pattern: string;
|
|
3
|
+
reason: string;
|
|
4
|
+
file?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface AllowList {
|
|
7
|
+
max: number;
|
|
8
|
+
entries: AllowListEntry[];
|
|
9
|
+
}
|
|
10
|
+
/** Validates a parsed allow-list; throws naming the first problem. */
|
|
11
|
+
export declare function parseAllowList(value: unknown, source?: string): AllowList;
|
|
12
|
+
export declare function readAllowList(path: string): AllowList;
|
|
13
|
+
/** The problems with `current` against `base` (the file as the base branch has it); empty when it only shrank. */
|
|
14
|
+
export declare function allowListGrowth(current: AllowList, base: AllowList | null): string[];
|