sunpeak 0.18.13 → 0.19.1
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/README.md +34 -134
- package/bin/commands/test-init.mjs +305 -0
- package/bin/commands/test.mjs +83 -0
- package/bin/lib/inspect/inspect-config.mjs +16 -24
- package/bin/lib/test/base-config.mjs +60 -0
- package/bin/lib/test/matchers.mjs +99 -0
- package/bin/lib/test/test-config.d.mts +44 -0
- package/bin/lib/test/test-config.mjs +123 -0
- package/bin/lib/test/test-fixtures.d.mts +96 -0
- package/bin/lib/test/test-fixtures.mjs +189 -0
- package/bin/sunpeak.js +18 -5
- package/dist/mcp/index.cjs +58 -16
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +58 -16
- package/dist/mcp/index.js.map +1 -1
- package/package.json +22 -10
- package/template/README.md +15 -8
- package/template/dist/albums/albums.json +1 -1
- package/template/dist/carousel/carousel.json +1 -1
- package/template/dist/map/map.html +468 -280
- package/template/dist/map/map.json +1 -1
- package/template/dist/review/review.json +1 -1
- package/template/node_modules/.bin/playwright +2 -2
- package/template/node_modules/.bin/vite +2 -2
- package/template/node_modules/.bin/vitest +2 -2
- package/template/node_modules/.vite/deps/_metadata.json +4 -4
- package/template/node_modules/.vite-mcp/deps/_metadata.json +22 -22
- package/template/node_modules/.vite-mcp/deps/mapbox-gl.js +15924 -14588
- package/template/node_modules/.vite-mcp/deps/mapbox-gl.js.map +1 -1
- package/template/node_modules/.vite-mcp/deps/vitest.js +8 -8
- package/template/node_modules/.vite-mcp/deps/vitest.js.map +1 -1
- package/template/package.json +4 -4
- package/template/playwright.config.ts +2 -40
- package/template/tests/e2e/albums.spec.ts +114 -245
- package/template/tests/e2e/carousel.spec.ts +189 -313
- package/template/tests/e2e/map.spec.ts +177 -300
- package/template/tests/e2e/review.spec.ts +232 -423
- package/template/tests/live/albums.spec.ts +1 -1
- package/template/tests/live/carousel.spec.ts +1 -1
- package/template/tests/live/map.spec.ts +1 -1
- package/template/tests/live/playwright.config.ts +1 -1
- package/template/tests/live/review.spec.ts +1 -1
- package/template/vitest.config.ts +1 -1
- package/template/tests/e2e/global-setup.ts +0 -10
- package/template/tests/e2e/helpers.ts +0 -13
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Playwright config builder used by both defineConfig() (sunpeak projects)
|
|
3
|
+
* and defineInspectConfig() (external MCP servers).
|
|
4
|
+
*
|
|
5
|
+
* Produces a config with per-host Playwright projects, sensible defaults for
|
|
6
|
+
* MCP App testing, and a webServer entry to launch the inspector backend.
|
|
7
|
+
*/
|
|
8
|
+
import { getPortSync } from '../get-port.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string[]} options.hosts - Host shells to create projects for
|
|
13
|
+
* @param {string} options.testDir - Test directory
|
|
14
|
+
* @param {Object} options.webServer - { command, healthUrl }
|
|
15
|
+
* @param {number} options.port - Inspector port
|
|
16
|
+
* @param {Object} [options.use] - Additional Playwright `use` options
|
|
17
|
+
* @param {string} [options.globalSetup] - Global setup file path
|
|
18
|
+
* @returns {import('@playwright/test').PlaywrightTestConfig}
|
|
19
|
+
*/
|
|
20
|
+
export function createBaseConfig({ hosts, testDir, webServer, port, use, globalSetup }) {
|
|
21
|
+
return {
|
|
22
|
+
...(globalSetup ? { globalSetup } : {}),
|
|
23
|
+
testDir,
|
|
24
|
+
fullyParallel: true,
|
|
25
|
+
forbidOnly: !!process.env.CI,
|
|
26
|
+
retries: process.env.CI ? 2 : 1,
|
|
27
|
+
// Limit workers to avoid overwhelming the double-iframe sandbox proxy.
|
|
28
|
+
workers: process.env.CI ? 1 : 2,
|
|
29
|
+
reporter: 'list',
|
|
30
|
+
use: {
|
|
31
|
+
baseURL: `http://localhost:${port}`,
|
|
32
|
+
trace: 'on-first-retry',
|
|
33
|
+
...use,
|
|
34
|
+
},
|
|
35
|
+
projects: hosts.map((host) => ({ name: host })),
|
|
36
|
+
webServer: {
|
|
37
|
+
command: webServer.command,
|
|
38
|
+
url: webServer.healthUrl,
|
|
39
|
+
reuseExistingServer: !process.env.CI,
|
|
40
|
+
timeout: 60_000,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve ports for the inspector and sandbox proxy.
|
|
47
|
+
* Respects env vars for CI where validate.mjs assigns unique ports.
|
|
48
|
+
*/
|
|
49
|
+
export function resolvePorts() {
|
|
50
|
+
const port = parsePort(process.env.SUNPEAK_TEST_PORT) ?? getPortSync(6776);
|
|
51
|
+
const sandboxPort = parsePort(process.env.SUNPEAK_SANDBOX_PORT) ?? getPortSync(24680);
|
|
52
|
+
return { port, sandboxPort };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse a port string, returning the number or null if invalid/absent. */
|
|
56
|
+
function parsePort(value) {
|
|
57
|
+
if (value == null) return null;
|
|
58
|
+
const n = Number(value);
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-native custom matchers for Playwright's expect.
|
|
3
|
+
*
|
|
4
|
+
* These matchers operate on ToolResult objects returned by mcp.callTool()
|
|
5
|
+
* and provide MCP-concept-native assertions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register MCP matchers on a Playwright expect instance.
|
|
10
|
+
* @param {import('@playwright/test').Expect} expect
|
|
11
|
+
*/
|
|
12
|
+
export function registerMatchers(expect) {
|
|
13
|
+
expect.extend({
|
|
14
|
+
/**
|
|
15
|
+
* Assert that a tool result is an error.
|
|
16
|
+
* Usage: expect(result).toBeError()
|
|
17
|
+
*/
|
|
18
|
+
toBeError(received) {
|
|
19
|
+
const pass = received?.isError === true;
|
|
20
|
+
return {
|
|
21
|
+
pass,
|
|
22
|
+
message: () =>
|
|
23
|
+
pass
|
|
24
|
+
? `Expected tool result not to be an error, but it was`
|
|
25
|
+
: `Expected tool result to be an error, but isError was ${received?.isError}`,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Assert that any content item's text contains the given string.
|
|
31
|
+
* Usage: expect(result).toHaveTextContent('temperature')
|
|
32
|
+
*/
|
|
33
|
+
toHaveTextContent(received, expected) {
|
|
34
|
+
const content = received?.content || [];
|
|
35
|
+
const texts = content
|
|
36
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
37
|
+
.map((c) => c.text);
|
|
38
|
+
const pass = texts.some((t) => t.includes(expected));
|
|
39
|
+
return {
|
|
40
|
+
pass,
|
|
41
|
+
message: () =>
|
|
42
|
+
pass
|
|
43
|
+
? `Expected tool result not to contain text "${expected}", but found it`
|
|
44
|
+
: `Expected tool result to contain text "${expected}" in content items.\nFound texts: ${JSON.stringify(texts)}`,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Assert that structuredContent matches the expected shape (deep partial match).
|
|
50
|
+
* Usage: expect(result).toHaveStructuredContent({ type: 'weather' })
|
|
51
|
+
*/
|
|
52
|
+
toHaveStructuredContent(received, expected) {
|
|
53
|
+
const sc = received?.structuredContent;
|
|
54
|
+
const pass = sc !== undefined && deepPartialMatch(sc, expected);
|
|
55
|
+
return {
|
|
56
|
+
pass,
|
|
57
|
+
message: () =>
|
|
58
|
+
pass
|
|
59
|
+
? `Expected structuredContent not to match, but it did`
|
|
60
|
+
: `Expected structuredContent to match ${JSON.stringify(expected)}, got ${JSON.stringify(sc)}`,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Assert that content array contains an item of the given type.
|
|
66
|
+
* Usage: expect(result).toHaveContentType('image')
|
|
67
|
+
*/
|
|
68
|
+
toHaveContentType(received, expectedType) {
|
|
69
|
+
const content = received?.content || [];
|
|
70
|
+
const types = content.map((c) => c.type);
|
|
71
|
+
const pass = types.includes(expectedType);
|
|
72
|
+
return {
|
|
73
|
+
pass,
|
|
74
|
+
message: () =>
|
|
75
|
+
pass
|
|
76
|
+
? `Expected content not to include type "${expectedType}", but it did`
|
|
77
|
+
: `Expected content to include type "${expectedType}". Found types: ${JSON.stringify(types)}`,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deep partial match: every key in `expected` must exist in `actual` and match.
|
|
85
|
+
* Extra keys in `actual` are allowed.
|
|
86
|
+
*/
|
|
87
|
+
function deepPartialMatch(actual, expected) {
|
|
88
|
+
if (expected === actual) return true;
|
|
89
|
+
if (expected === null || actual === null) return expected === actual;
|
|
90
|
+
if (typeof expected !== 'object' || typeof actual !== 'object') return expected === actual;
|
|
91
|
+
if (Array.isArray(expected)) {
|
|
92
|
+
if (!Array.isArray(actual)) return false;
|
|
93
|
+
if (expected.length !== actual.length) return false;
|
|
94
|
+
return expected.every((item, i) => deepPartialMatch(actual[i], item));
|
|
95
|
+
}
|
|
96
|
+
return Object.keys(expected).every(
|
|
97
|
+
(key) => key in actual && deepPartialMatch(actual[key], expected[key])
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP server connection configuration.
|
|
5
|
+
*/
|
|
6
|
+
export interface ServerConfig {
|
|
7
|
+
/** Server start command (e.g., 'python'). */
|
|
8
|
+
command?: string;
|
|
9
|
+
/** Command arguments (e.g., ['server.py']). */
|
|
10
|
+
args?: string[];
|
|
11
|
+
/** HTTP server URL (alternative to command/args). */
|
|
12
|
+
url?: string;
|
|
13
|
+
/** Environment variables for the server process. */
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Configuration options for sunpeak test config.
|
|
19
|
+
*/
|
|
20
|
+
export interface TestConfigOptions {
|
|
21
|
+
/**
|
|
22
|
+
* MCP server connection. Omit for sunpeak framework projects (auto-detected).
|
|
23
|
+
* Required for external MCP servers.
|
|
24
|
+
*/
|
|
25
|
+
server?: ServerConfig;
|
|
26
|
+
/** Host shells to test against (default: ['chatgpt', 'claude']). */
|
|
27
|
+
hosts?: string[];
|
|
28
|
+
/** Test directory (default: 'tests/e2e' for sunpeak, '.' for external). */
|
|
29
|
+
testDir?: string;
|
|
30
|
+
/** Simulations directory for mock data. */
|
|
31
|
+
simulationsDir?: string;
|
|
32
|
+
/** Global setup file path. */
|
|
33
|
+
globalSetup?: string;
|
|
34
|
+
/** Additional Playwright `use` options. */
|
|
35
|
+
use?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a Playwright config for testing MCP servers.
|
|
40
|
+
*
|
|
41
|
+
* Auto-detects sunpeak projects and starts `sunpeak dev` as the backend.
|
|
42
|
+
* For external servers, starts `sunpeak inspect` with the provided server config.
|
|
43
|
+
*/
|
|
44
|
+
export declare function defineConfig(options?: TestConfigOptions): PlaywrightTestConfig;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright config factory for MCP server testing.
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects project type:
|
|
5
|
+
* - sunpeak framework projects: starts `sunpeak dev` as the backend
|
|
6
|
+
* - External MCP servers: starts `sunpeak inspect` as the backend
|
|
7
|
+
*
|
|
8
|
+
* Usage (sunpeak project):
|
|
9
|
+
* import { defineConfig } from 'sunpeak/test/config';
|
|
10
|
+
* export default defineConfig();
|
|
11
|
+
*
|
|
12
|
+
* Usage (external server):
|
|
13
|
+
* import { defineConfig } from 'sunpeak/test/config';
|
|
14
|
+
* export default defineConfig({
|
|
15
|
+
* server: { command: 'python', args: ['server.py'] },
|
|
16
|
+
* });
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { createBaseConfig, resolvePorts } from './base-config.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {Object} [options.server] - MCP server connection (omit for sunpeak projects)
|
|
25
|
+
* @param {string} [options.server.command] - Server start command
|
|
26
|
+
* @param {string[]} [options.server.args] - Command arguments
|
|
27
|
+
* @param {string} [options.server.url] - HTTP server URL (alternative to command)
|
|
28
|
+
* @param {Record<string, string>} [options.server.env] - Environment variables
|
|
29
|
+
* @param {string[]} [options.hosts] - Host shells to test (default: ['chatgpt', 'claude'])
|
|
30
|
+
* @param {string} [options.testDir] - Test directory
|
|
31
|
+
* @param {string} [options.simulationsDir] - Simulations directory for mock data
|
|
32
|
+
* @param {string} [options.globalSetup] - Global setup file path
|
|
33
|
+
* @param {Object} [options.use] - Additional Playwright `use` options
|
|
34
|
+
* @returns {import('@playwright/test').PlaywrightTestConfig}
|
|
35
|
+
*/
|
|
36
|
+
export function defineConfig(options = {}) {
|
|
37
|
+
const {
|
|
38
|
+
server,
|
|
39
|
+
hosts = ['chatgpt', 'claude'],
|
|
40
|
+
testDir,
|
|
41
|
+
simulationsDir,
|
|
42
|
+
globalSetup,
|
|
43
|
+
use: userUse,
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const { port, sandboxPort } = resolvePorts();
|
|
47
|
+
const isSunpeakProject = !server && detectSunpeakProject();
|
|
48
|
+
|
|
49
|
+
const resolvedTestDir = testDir || (isSunpeakProject ? 'tests/e2e' : '.');
|
|
50
|
+
|
|
51
|
+
let command;
|
|
52
|
+
if (server) {
|
|
53
|
+
// External MCP server mode
|
|
54
|
+
command = buildInspectCommand({ server, port, sandboxPort, simulationsDir });
|
|
55
|
+
} else if (isSunpeakProject) {
|
|
56
|
+
// sunpeak framework project mode
|
|
57
|
+
command = `PORT=${port} SUNPEAK_SANDBOX_PORT=${sandboxPort} pnpm dev`;
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'defineConfig: either provide a `server` option or run from a sunpeak project directory.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return createBaseConfig({
|
|
65
|
+
hosts,
|
|
66
|
+
testDir: resolvedTestDir,
|
|
67
|
+
port,
|
|
68
|
+
use: userUse,
|
|
69
|
+
globalSetup,
|
|
70
|
+
webServer: {
|
|
71
|
+
command,
|
|
72
|
+
healthUrl: `http://localhost:${port}/health`,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect if the current directory is a sunpeak framework project.
|
|
79
|
+
*/
|
|
80
|
+
function detectSunpeakProject() {
|
|
81
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
82
|
+
if (!existsSync(pkgPath)) return false;
|
|
83
|
+
try {
|
|
84
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
85
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
86
|
+
return 'sunpeak' in deps;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the `sunpeak inspect` command for external MCP servers.
|
|
94
|
+
*/
|
|
95
|
+
function buildInspectCommand({ server, port, sandboxPort, simulationsDir }) {
|
|
96
|
+
const parts = [`SUNPEAK_SANDBOX_PORT=${sandboxPort}`];
|
|
97
|
+
|
|
98
|
+
if (server.env) {
|
|
99
|
+
for (const [key, value] of Object.entries(server.env)) {
|
|
100
|
+
parts.push(`${key}=${value}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
parts.push('npx sunpeak inspect');
|
|
105
|
+
|
|
106
|
+
if (server.url) {
|
|
107
|
+
parts.push(`--server ${server.url}`);
|
|
108
|
+
} else if (server.command) {
|
|
109
|
+
const cmd = server.args
|
|
110
|
+
? `${server.command} ${server.args.join(' ')}`
|
|
111
|
+
: server.command;
|
|
112
|
+
// Quote the command if it contains spaces
|
|
113
|
+
parts.push(`--server "${cmd}"`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (simulationsDir) {
|
|
117
|
+
parts.push(`--simulations ${simulationsDir}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
parts.push(`--port ${port}`);
|
|
121
|
+
|
|
122
|
+
return parts.join(' ');
|
|
123
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Page, FrameLocator, TestType, Expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result from calling an MCP tool via the inspector.
|
|
5
|
+
*/
|
|
6
|
+
export interface ToolResult {
|
|
7
|
+
/** Raw MCP content items from the tool response. */
|
|
8
|
+
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
|
9
|
+
/** Structured content from the tool response. */
|
|
10
|
+
structuredContent?: unknown;
|
|
11
|
+
/** Whether the tool returned an error. */
|
|
12
|
+
isError: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Get a FrameLocator for the rendered resource UI.
|
|
15
|
+
* Handles the double-iframe traversal automatically.
|
|
16
|
+
*/
|
|
17
|
+
app(): FrameLocator;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for callTool().
|
|
22
|
+
*/
|
|
23
|
+
export interface CallToolOptions {
|
|
24
|
+
/** Color theme for the inspector. */
|
|
25
|
+
theme?: 'light' | 'dark';
|
|
26
|
+
/** Display mode for the resource. */
|
|
27
|
+
displayMode?: 'inline' | 'pip' | 'fullscreen';
|
|
28
|
+
/** Use production resource builds instead of HMR. */
|
|
29
|
+
prodResources?: boolean;
|
|
30
|
+
/** Additional inspector URL parameters. */
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* MCP test fixture for testing MCP servers via the inspector.
|
|
36
|
+
*/
|
|
37
|
+
export interface McpFixture {
|
|
38
|
+
/** The underlying Playwright Page. */
|
|
39
|
+
page: Page;
|
|
40
|
+
/** Current host ID (from Playwright project name). */
|
|
41
|
+
host: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Call a tool and get the rendered result.
|
|
45
|
+
* Navigates the inspector, waits for the resource to render,
|
|
46
|
+
* and returns a ToolResult for assertions.
|
|
47
|
+
*/
|
|
48
|
+
callTool(
|
|
49
|
+
name: string,
|
|
50
|
+
input?: Record<string, unknown>,
|
|
51
|
+
options?: CallToolOptions
|
|
52
|
+
): Promise<ToolResult>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Navigate to a tool with no mock data ("Press Run" state).
|
|
56
|
+
*/
|
|
57
|
+
openTool(name: string, options?: { theme?: 'light' | 'dark' }): Promise<void>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Click the Run button and return the rendered result.
|
|
61
|
+
*/
|
|
62
|
+
runTool(): Promise<ToolResult>;
|
|
63
|
+
|
|
64
|
+
/** Change the theme via the sidebar toggle. */
|
|
65
|
+
setTheme(theme: 'light' | 'dark'): Promise<void>;
|
|
66
|
+
|
|
67
|
+
/** Change the display mode via the sidebar buttons. */
|
|
68
|
+
setDisplayMode(mode: 'inline' | 'pip' | 'fullscreen'): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extended Playwright test with `mcp` fixture.
|
|
73
|
+
*/
|
|
74
|
+
export declare const test: TestType<{ mcp: McpFixture }, {}>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extended Playwright expect with MCP-native matchers.
|
|
78
|
+
*/
|
|
79
|
+
export declare const expect: Expect<{
|
|
80
|
+
/**
|
|
81
|
+
* Assert that a tool result is an error.
|
|
82
|
+
*/
|
|
83
|
+
toBeError(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Assert that any content item's text contains the given string.
|
|
86
|
+
*/
|
|
87
|
+
toHaveTextContent(text: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Assert that structuredContent matches the expected shape.
|
|
90
|
+
*/
|
|
91
|
+
toHaveStructuredContent(shape: unknown): void;
|
|
92
|
+
/**
|
|
93
|
+
* Assert that content array contains an item of the given type.
|
|
94
|
+
*/
|
|
95
|
+
toHaveContentType(type: string): void;
|
|
96
|
+
}>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-first Playwright fixtures for testing MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* Provides an `mcp` fixture that abstracts the inspector, double-iframe
|
|
5
|
+
* traversal, URL construction, and host selection. Tests read like MCP
|
|
6
|
+
* operations, not browser automation.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { test, expect } from 'sunpeak/test';
|
|
10
|
+
*
|
|
11
|
+
* test('weather tool', async ({ mcp }) => {
|
|
12
|
+
* const result = await mcp.callTool('get-weather', { city: 'SF' });
|
|
13
|
+
* expect(result).not.toBeError();
|
|
14
|
+
* expect(result).toHaveTextContent('temperature');
|
|
15
|
+
*
|
|
16
|
+
* const app = result.app();
|
|
17
|
+
* await expect(app.getByText('San Francisco')).toBeVisible();
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
import { resolvePlaywrightESM } from '../live/utils.mjs';
|
|
21
|
+
import { registerMatchers } from './matchers.mjs';
|
|
22
|
+
|
|
23
|
+
const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
|
|
24
|
+
const { test: base, expect } = await resolvePlaywrightESM(projectRoot);
|
|
25
|
+
|
|
26
|
+
// Register MCP-native matchers
|
|
27
|
+
registerMatchers(expect);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build an inspector URL path with query parameters.
|
|
31
|
+
* Inlined to avoid importing from dist (which pulls in React).
|
|
32
|
+
*/
|
|
33
|
+
function buildInspectorUrl(params) {
|
|
34
|
+
const sp = new URLSearchParams();
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
if (value !== undefined && value !== null) {
|
|
37
|
+
sp.set(key, String(value));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Always disable dev overlay in tests
|
|
41
|
+
sp.set('devOverlay', 'false');
|
|
42
|
+
const qs = sp.toString();
|
|
43
|
+
return qs ? `/?${qs}` : '/';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the host ID from the Playwright project name.
|
|
48
|
+
*/
|
|
49
|
+
function resolveHostId(projectName) {
|
|
50
|
+
if (!projectName) return 'chatgpt';
|
|
51
|
+
if (projectName.startsWith('chatgpt')) return 'chatgpt';
|
|
52
|
+
if (projectName.startsWith('claude')) return 'claude';
|
|
53
|
+
return projectName;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a ToolResult wrapper around the inspector's rendered state.
|
|
58
|
+
*/
|
|
59
|
+
function createToolResult(page, resultData) {
|
|
60
|
+
return {
|
|
61
|
+
content: resultData?.content || [],
|
|
62
|
+
structuredContent: resultData?.structuredContent,
|
|
63
|
+
isError: resultData?.isError || false,
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get a FrameLocator for the rendered resource UI.
|
|
67
|
+
* Handles the double-iframe traversal (outer sandbox proxy + inner app).
|
|
68
|
+
* Returns the locator regardless — Playwright will throw with a clear
|
|
69
|
+
* error if no iframe exists when you interact with it.
|
|
70
|
+
*/
|
|
71
|
+
app() {
|
|
72
|
+
return page.frameLocator('iframe').frameLocator('iframe');
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const test = base.extend({
|
|
78
|
+
mcp: async ({ page }, use, testInfo) => {
|
|
79
|
+
const host = resolveHostId(testInfo.project.name);
|
|
80
|
+
|
|
81
|
+
const fixture = {
|
|
82
|
+
page,
|
|
83
|
+
host,
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Call a tool and get the rendered result.
|
|
87
|
+
*
|
|
88
|
+
* For sunpeak projects: navigates to the matching simulation (simulation
|
|
89
|
+
* fixture data including toolInput is served by sunpeak dev).
|
|
90
|
+
* For external servers: navigates to the matching simulation created by
|
|
91
|
+
* inspectServer from discovered tools.
|
|
92
|
+
*
|
|
93
|
+
* Note: The `input` parameter is accepted for API consistency and future
|
|
94
|
+
* use but is not currently passed to the inspector. Simulation fixture
|
|
95
|
+
* data provides the tool input for rendering.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} name - Tool/simulation name (e.g., 'show-albums')
|
|
98
|
+
* @param {Record<string, unknown>} [_input] - Reserved for future use
|
|
99
|
+
* @param {Object} [options] - Display options
|
|
100
|
+
* @returns {Promise<ToolResult>}
|
|
101
|
+
*/
|
|
102
|
+
async callTool(name, _input, options = {}) {
|
|
103
|
+
const { theme, displayMode, ...rest } = options;
|
|
104
|
+
|
|
105
|
+
const params = {
|
|
106
|
+
simulation: name,
|
|
107
|
+
host,
|
|
108
|
+
...(theme && { theme }),
|
|
109
|
+
...(displayMode && { displayMode }),
|
|
110
|
+
...rest,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await page.goto(buildInspectorUrl(params));
|
|
114
|
+
|
|
115
|
+
// Wait for the resource iframe to have content
|
|
116
|
+
try {
|
|
117
|
+
const frame = page.frameLocator('iframe').frameLocator('iframe');
|
|
118
|
+
await frame.locator('body').waitFor({ state: 'attached', timeout: 15_000 });
|
|
119
|
+
} catch {
|
|
120
|
+
// Tool may not have a resource (no UI)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return createToolResult(page, {
|
|
124
|
+
content: [],
|
|
125
|
+
structuredContent: undefined,
|
|
126
|
+
isError: false,
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Navigate to a tool with no mock data ("Press Run" state).
|
|
132
|
+
* Use for testing the empty/loading state before a tool is executed.
|
|
133
|
+
*/
|
|
134
|
+
async openTool(name, options = {}) {
|
|
135
|
+
const { theme, ...rest } = options;
|
|
136
|
+
const params = {
|
|
137
|
+
tool: name,
|
|
138
|
+
host,
|
|
139
|
+
...(theme && { theme }),
|
|
140
|
+
...rest,
|
|
141
|
+
};
|
|
142
|
+
await page.goto(buildInspectorUrl(params));
|
|
143
|
+
await page.locator('#root').waitFor({ state: 'attached' });
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Click the Run button and wait for the resource to render.
|
|
148
|
+
* Use after openTool() in Prod Tools mode.
|
|
149
|
+
*/
|
|
150
|
+
async runTool() {
|
|
151
|
+
await page.locator('button:has-text("Run")').click();
|
|
152
|
+
await page.locator('iframe').waitFor({ state: 'attached', timeout: 30_000 });
|
|
153
|
+
return createToolResult(page, {
|
|
154
|
+
content: [],
|
|
155
|
+
structuredContent: undefined,
|
|
156
|
+
isError: false,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Change the theme via the sidebar toggle.
|
|
162
|
+
*/
|
|
163
|
+
async setTheme(theme) {
|
|
164
|
+
const label = theme === 'light' ? 'Light' : 'Dark';
|
|
165
|
+
const button = page.locator(`button:has-text("${label}")`);
|
|
166
|
+
if (await button.isVisible().catch(() => false)) {
|
|
167
|
+
await button.click();
|
|
168
|
+
// Wait for theme to propagate to the iframe
|
|
169
|
+
await page.waitForTimeout(300);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Change the display mode via the sidebar buttons.
|
|
175
|
+
*/
|
|
176
|
+
async setDisplayMode(mode) {
|
|
177
|
+
const labels = { inline: 'Inline', pip: 'PiP', fullscreen: 'Full' };
|
|
178
|
+
const label = labels[mode] || mode;
|
|
179
|
+
await page.locator(`button:has-text("${label}")`).click();
|
|
180
|
+
// Wait for display mode transition
|
|
181
|
+
await page.waitForTimeout(500);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
await use(fixture);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export { test, expect };
|
package/bin/sunpeak.js
CHANGED
|
@@ -37,7 +37,7 @@ function getVersion() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Commands that don't require a package.json
|
|
40
|
-
const standaloneCommands = ['new', 'upgrade', 'inspect', 'help', undefined];
|
|
40
|
+
const standaloneCommands = ['new', 'upgrade', 'inspect', 'test', 'help', undefined];
|
|
41
41
|
|
|
42
42
|
if (command && !standaloneCommands.includes(command)) {
|
|
43
43
|
checkPackageJson();
|
|
@@ -79,6 +79,13 @@ function getVersion() {
|
|
|
79
79
|
}
|
|
80
80
|
break;
|
|
81
81
|
|
|
82
|
+
case 'test':
|
|
83
|
+
{
|
|
84
|
+
const { runTest } = await import(join(COMMANDS_DIR, 'test.mjs'));
|
|
85
|
+
await runTest(args);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
|
|
82
89
|
case 'upgrade':
|
|
83
90
|
{
|
|
84
91
|
const { upgrade } = await import(join(COMMANDS_DIR, 'upgrade.mjs'));
|
|
@@ -100,16 +107,22 @@ function getVersion() {
|
|
|
100
107
|
Install:
|
|
101
108
|
pnpm add -g sunpeak
|
|
102
109
|
|
|
103
|
-
|
|
110
|
+
Testing (works with any MCP server):
|
|
111
|
+
sunpeak inspect Inspect any MCP server in the inspector
|
|
112
|
+
--server, -s <url|cmd> MCP server URL or stdio command (required)
|
|
113
|
+
--simulations <dir> Simulation JSON directory
|
|
114
|
+
sunpeak test Run e2e tests against the inspector
|
|
115
|
+
init Scaffold test infrastructure into a project
|
|
116
|
+
--unit Run unit tests (vitest)
|
|
117
|
+
--live Run live tests against real hosts
|
|
118
|
+
|
|
119
|
+
App framework (for sunpeak projects):
|
|
104
120
|
sunpeak new [name] [resources] Create a new project
|
|
105
121
|
sunpeak dev Start dev server + inspector + MCP endpoint
|
|
106
122
|
--no-begging Suppress GitHub star message
|
|
107
123
|
sunpeak build Build resources + tools for production
|
|
108
124
|
sunpeak start Start production MCP server
|
|
109
125
|
--port, -p Server port (default: 8000, or PORT env)
|
|
110
|
-
sunpeak inspect Inspect any MCP server in the inspector
|
|
111
|
-
--server, -s <url|cmd> MCP server URL or stdio command (required)
|
|
112
|
-
--simulations <dir> Simulation JSON directory
|
|
113
126
|
sunpeak upgrade Upgrade sunpeak to latest version
|
|
114
127
|
sunpeak --version Show version number
|
|
115
128
|
|