@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.
- package/LICENSE +21 -0
- package/README.md +23 -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/neon-test-db/README.md +53 -0
- package/dist/neon-test-db/neon-test-db.d.mts +35 -0
- package/dist/neon-test-db/neon-test-db.mjs +381 -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 +53 -0
- package/dist/setup/config.js +88 -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 +107 -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,53 @@
|
|
|
1
|
+
# `tk-neon-test-db`
|
|
2
|
+
|
|
3
|
+
The local database test run, `npm run test:db`: a short-lived Neon branch for one command, deleted afterwards.
|
|
4
|
+
|
|
5
|
+
It moved here from `getcatalystiq/github-actions` (`scripts/neon-test-db.mjs`), which every app vendored unchanged. The
|
|
6
|
+
script is the same; apps now run it from the package.
|
|
7
|
+
|
|
8
|
+
## Use it
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"devDependencies": { "@catalystiq/test-kit": "<exact version>" },
|
|
13
|
+
"scripts": { "test:db": "NEON_TEST_DB_PROJECT=… NEON_TEST_DB_PARENT=… NEON_TEST_DB_REFUSE=… tk-neon-test-db -- npm run ci:db" }
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- It **cuts** a short-lived, **schema-only** branch of `NEON_TEST_DB_PARENT` (the parent's schema, none of its rows, so
|
|
20
|
+
never production data), runs the command after `--` with `DATABASE_URL` set to that branch, and **deletes** the branch
|
|
21
|
+
it made whatever the command did, a Ctrl-C included. It deletes nothing else: the delete names the id this run created.
|
|
22
|
+
- Under the kit's Vitest setup, `DATABASE_URL` holds a password, so the credential strip deletes it in each worker unless
|
|
23
|
+
the config lists it in `keepEnv` (`defineTestKitConfig({ keepEnv: ['DATABASE_URL'] })`), which also lets the tests
|
|
24
|
+
reach its host. Neon's serverless HTTP driver queries the region's gateway (`api.<region>.neon.tech`) instead, so a
|
|
25
|
+
suite that uses it lists that host in `allowHosts` too (the setup README, Network).
|
|
26
|
+
- The branch carries an **expiry** (default 2 hours, at most 6), so a branch whose process died is collected by Neon.
|
|
27
|
+
- It **refuses** a project listed in `NEON_TEST_DB_REFUSE` (name production's) before any call.
|
|
28
|
+
- The connection string is never printed. The branch is named `tmp/test-db-<user>-<UTC yyyymmddhhmmss>-<4 hex>`.
|
|
29
|
+
- `NEON_TEST_DB_INIT=parent-data` asks for a **data** copy instead, only of a parent whose branch id (never a name) is
|
|
30
|
+
listed in `NEON_TEST_DB_DATA_PARENTS`: the parents the repository names as customer-free. The branch must come back as
|
|
31
|
+
a data copy of that parent, or it is deleted unused.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
From the environment (a repository's `.env.local` is not read; export them or use `dotenv --`):
|
|
36
|
+
|
|
37
|
+
| Variable | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `NEON_API_KEY` | A key that reaches the project (a personal key, or `neonctl`'s token for a short run). |
|
|
40
|
+
| `NEON_TEST_DB_PROJECT` | The Neon project id. |
|
|
41
|
+
| `NEON_TEST_DB_PARENT` | The parent branch id (`br-…`) or name whose schema is copied. Never production's. |
|
|
42
|
+
| `NEON_TEST_DB_REFUSE` | Project ids this command refuses outright (name production). |
|
|
43
|
+
| `NEON_TEST_DB_DATABASE` | The database to connect to (default: the branch's one database that is not `postgres`). |
|
|
44
|
+
| `NEON_TEST_DB_EXPIRES` | Minutes until Neon deletes the branch on its own (default 120, at most 360). |
|
|
45
|
+
| `NEON_TEST_DB_KEEP=1` | Keep the branch afterwards (it still expires); prints its id, never its password. |
|
|
46
|
+
| `NEON_TEST_DB_INIT` | `schema-only` (the default) or `parent-data` (above). |
|
|
47
|
+
| `NEON_TEST_DB_DATA_PARENTS` | The branch ids `parent-data` may copy. |
|
|
48
|
+
|
|
49
|
+
Exit codes: the command's own; 1 when the branch could not be made, checked or deleted; 2 for a configuration problem;
|
|
50
|
+
128 + the signal number after a signal (130 for a Ctrl-C).
|
|
51
|
+
|
|
52
|
+
The module also exports `readConfig`, `branchName` and `main` (`@catalystiq/test-kit/neon-test-db`), for a repository
|
|
53
|
+
that wants to test its own `test:db` wiring.
|