sunpeak 0.18.14 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +37 -134
  2. package/bin/commands/new.mjs +3 -1
  3. package/bin/commands/test-init.mjs +305 -0
  4. package/bin/commands/test.mjs +144 -0
  5. package/bin/lib/inspect/inspect-config.d.mts +4 -0
  6. package/bin/lib/inspect/inspect-config.mjs +18 -24
  7. package/bin/lib/test/base-config.mjs +75 -0
  8. package/bin/lib/test/matchers.mjs +99 -0
  9. package/bin/lib/test/test-config.d.mts +66 -0
  10. package/bin/lib/test/test-config.mjs +125 -0
  11. package/bin/lib/test/test-fixtures.d.mts +129 -0
  12. package/bin/lib/test/test-fixtures.mjs +232 -0
  13. package/bin/sunpeak.js +18 -5
  14. package/package.json +22 -10
  15. package/template/README.md +18 -8
  16. package/template/dist/albums/albums.json +1 -1
  17. package/template/dist/carousel/carousel.json +1 -1
  18. package/template/dist/map/map.html +468 -280
  19. package/template/dist/map/map.json +1 -1
  20. package/template/dist/review/review.json +1 -1
  21. package/template/node_modules/.bin/playwright +2 -2
  22. package/template/node_modules/.bin/vite +2 -2
  23. package/template/node_modules/.bin/vitest +2 -2
  24. package/template/node_modules/.vite/deps/_metadata.json +4 -4
  25. package/template/node_modules/.vite-mcp/deps/_metadata.json +22 -22
  26. package/template/node_modules/.vite-mcp/deps/mapbox-gl.js +15924 -14588
  27. package/template/node_modules/.vite-mcp/deps/mapbox-gl.js.map +1 -1
  28. package/template/node_modules/.vite-mcp/deps/vitest.js +8 -8
  29. package/template/node_modules/.vite-mcp/deps/vitest.js.map +1 -1
  30. package/template/package.json +9 -7
  31. package/template/playwright.config.ts +2 -40
  32. package/template/test-results/.last-run.json +4 -0
  33. package/template/tests/e2e/albums.spec.ts +114 -245
  34. package/template/tests/e2e/carousel.spec.ts +189 -313
  35. package/template/tests/e2e/map.spec.ts +177 -300
  36. package/template/tests/e2e/review.spec.ts +232 -423
  37. package/template/tests/e2e/visual.spec.ts +36 -0
  38. package/template/tests/e2e/visual.spec.ts-snapshots/albums-dark-chatgpt-linux.png +0 -0
  39. package/template/tests/e2e/visual.spec.ts-snapshots/albums-dark-claude-linux.png +0 -0
  40. package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-chatgpt-linux.png +0 -0
  41. package/template/tests/e2e/visual.spec.ts-snapshots/albums-fullscreen-claude-linux.png +0 -0
  42. package/template/tests/e2e/visual.spec.ts-snapshots/albums-light-chatgpt-linux.png +0 -0
  43. package/template/tests/e2e/visual.spec.ts-snapshots/albums-light-claude-linux.png +0 -0
  44. package/template/tests/e2e/visual.spec.ts-snapshots/albums-page-light-chatgpt-linux.png +0 -0
  45. package/template/tests/e2e/visual.spec.ts-snapshots/albums-page-light-claude-linux.png +0 -0
  46. package/template/tests/live/albums.spec.ts +1 -1
  47. package/template/tests/live/carousel.spec.ts +1 -1
  48. package/template/tests/live/map.spec.ts +1 -1
  49. package/template/tests/live/playwright.config.ts +1 -1
  50. package/template/tests/live/review.spec.ts +1 -1
  51. package/template/vitest.config.ts +1 -1
  52. package/template/tests/e2e/global-setup.ts +0 -10
  53. package/template/tests/e2e/helpers.ts +0 -13
@@ -0,0 +1,232 @@
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
+ * Take a screenshot and compare against a baseline.
186
+ * Only performs the comparison when visual testing is enabled
187
+ * (`sunpeak test --visual`). Silently skips otherwise, so tests
188
+ * that include screenshot() calls still pass during normal runs.
189
+ *
190
+ * Accepts all Playwright toHaveScreenshot() options (threshold,
191
+ * maxDiffPixelRatio, maxDiffPixels, mask, animations, caret,
192
+ * fullPage, clip, scale, stylePath, etc.) and passes them through.
193
+ *
194
+ * @param {string} [name] - Snapshot name (auto-generated from test title if omitted)
195
+ * @param {Object} [options] - Screenshot and comparison options
196
+ * @param {'app' | 'page'} [options.target='app'] - What to screenshot
197
+ * @param {import('@playwright/test').Locator} [options.element] - Specific locator to screenshot
198
+ */
199
+ async screenshot(name, options = {}) {
200
+ if (process.env.SUNPEAK_VISUAL !== 'true') return;
201
+
202
+ // Support screenshot(options) without a name
203
+ if (typeof name === 'object' && name !== null) {
204
+ options = name;
205
+ name = undefined;
206
+ }
207
+
208
+ const { target = 'app', element, ...playwrightOptions } = options;
209
+
210
+ let locator;
211
+ if (element) {
212
+ locator = element;
213
+ } else if (target === 'page') {
214
+ locator = page.locator('#root');
215
+ } else {
216
+ locator = page.frameLocator('iframe').frameLocator('iframe').locator('body');
217
+ }
218
+
219
+ const fullName = name && !name.endsWith('.png') ? `${name}.png` : name;
220
+ const args = fullName
221
+ ? [fullName, playwrightOptions]
222
+ : [playwrightOptions];
223
+
224
+ await expect(locator).toHaveScreenshot(...args);
225
+ },
226
+ };
227
+
228
+ await use(fixture);
229
+ },
230
+ });
231
+
232
+ 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
- Usage:
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.18.14",
3
+ "version": "0.19.2",
4
4
  "description": "Inspector, testing framework, and app framework for MCP Apps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -55,24 +55,36 @@
55
55
  }
56
56
  },
57
57
  "./test": {
58
+ "import": {
59
+ "types": "./bin/lib/test/test-fixtures.d.mts",
60
+ "default": "./bin/lib/test/test-fixtures.mjs"
61
+ }
62
+ },
63
+ "./test/config": {
64
+ "import": {
65
+ "types": "./bin/lib/test/test-config.d.mts",
66
+ "default": "./bin/lib/test/test-config.mjs"
67
+ }
68
+ },
69
+ "./test/live": {
58
70
  "import": {
59
71
  "types": "./bin/lib/live/live-fixtures.d.mts",
60
72
  "default": "./bin/lib/live/live-fixtures.mjs"
61
73
  }
62
74
  },
63
- "./test/config": {
75
+ "./test/live/config": {
64
76
  "import": {
65
77
  "types": "./bin/lib/live/test-config.d.mts",
66
78
  "default": "./bin/lib/live/test-config.mjs"
67
79
  }
68
80
  },
69
- "./test/chatgpt": {
81
+ "./test/live/chatgpt": {
70
82
  "import": {
71
83
  "types": "./bin/lib/live/chatgpt-fixtures.d.mts",
72
84
  "default": "./bin/lib/live/chatgpt-fixtures.mjs"
73
85
  }
74
86
  },
75
- "./test/chatgpt/config": {
87
+ "./test/live/chatgpt/config": {
76
88
  "import": {
77
89
  "types": "./bin/lib/live/chatgpt-config.d.mts",
78
90
  "default": "./bin/lib/live/chatgpt-config.mjs"
@@ -122,7 +134,7 @@
122
134
  "react-dom": "^18.0.0 || ^19.0.0"
123
135
  },
124
136
  "dependencies": {
125
- "@clack/prompts": "^1.1.0",
137
+ "@clack/prompts": "^1.2.0",
126
138
  "@modelcontextprotocol/ext-apps": "^1.3.2",
127
139
  "@modelcontextprotocol/sdk": "^1.29.0",
128
140
  "@vitejs/plugin-react": "^6.0.1",
@@ -137,16 +149,16 @@
137
149
  "@testing-library/jest-dom": "^6.9.1",
138
150
  "@testing-library/react": "^16.3.2",
139
151
  "@testing-library/user-event": "^14.6.1",
140
- "@types/node": "^25.5.0",
152
+ "@types/node": "^25.5.2",
141
153
  "@types/react": "^19.2.14",
142
154
  "@types/react-dom": "^19.2.3",
143
- "@typescript-eslint/eslint-plugin": "^8.57.2",
144
- "@typescript-eslint/parser": "^8.57.2",
155
+ "@typescript-eslint/eslint-plugin": "^8.58.0",
156
+ "@typescript-eslint/parser": "^8.58.0",
145
157
  "eslint": "^9.39.4",
146
158
  "eslint-config-prettier": "^10.1.8",
147
159
  "eslint-plugin-react": "^7.37.5",
148
160
  "eslint-plugin-react-hooks": "^7.0.1",
149
- "jsdom": "^29.0.1",
161
+ "happy-dom": "^18.0.1",
150
162
  "prettier": "^3.8.1",
151
163
  "react": "^19.2.4",
152
164
  "react-dom": "^19.2.4",
@@ -155,7 +167,7 @@
155
167
  "tsx": "^4.21.0",
156
168
  "typescript": "^5.9.3",
157
169
  "vite-plugin-dts": "^4.5.4",
158
- "@playwright/test": "^1.52.0",
170
+ "@playwright/test": "^1.59.1",
159
171
  "vitest": "^4.1.2"
160
172
  },
161
173
  "repository": {
@@ -14,17 +14,27 @@ That's it! Edit the resource files in [./src/resources/](./src/resources/) to bu
14
14
 
15
15
  ## Commands
16
16
 
17
+ **Testing:**
18
+
19
+ ```bash
20
+ sunpeak test # Run unit + e2e tests.
21
+ sunpeak test --unit # Run unit tests only (Vitest).
22
+ sunpeak test --e2e # Run e2e tests only (Playwright).
23
+ sunpeak test --visual # Run e2e tests with visual regression.
24
+ sunpeak test --visual --update # Update visual regression baselines.
25
+ sunpeak test --live # Run live tests against real ChatGPT.
26
+ ```
27
+
28
+ **Development and production:**
29
+
17
30
  ```bash
18
- pnpm test # Run tests with Vitest.
19
- pnpm test:e2e # Run end-to-end tests with Playwright.
20
- pnpm test:live # Run live tests against real ChatGPT.
21
- sunpeak dev # Start dev server + MCP endpoint.
22
- sunpeak build # Build resources and compile tools for production.
23
- sunpeak start # Start the production MCP server.
24
- sunpeak upgrade # Upgrade sunpeak to latest version.
31
+ sunpeak dev # Start dev server + MCP endpoint.
32
+ sunpeak build # Build resources and compile tools for production.
33
+ sunpeak start # Start the production MCP server.
34
+ sunpeak upgrade # Upgrade sunpeak to latest version.
25
35
  ```
26
36
 
27
- The template includes a minimal test setup with Vitest. You can add additional tooling (linting, formatting, type-checking) as needed for your project.
37
+ E2e tests use the `mcp` fixture from `sunpeak/test` to call tools and assert against rendered UI across ChatGPT and Claude hosts. Unit tests use Vitest with happy-dom. You can add additional tooling (linting, formatting, type-checking) as needed for your project.
28
38
 
29
39
  ## Project Structure
30
40
 
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "albums",
15
- "uri": "ui://albums-mnhsyu53"
15
+ "uri": "ui://albums-mnnqsot1"
16
16
  }
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "carousel",
15
- "uri": "ui://carousel-mnhsyu53"
15
+ "uri": "ui://carousel-mnnqsot1"
16
16
  }