sunpeak 0.16.21 → 0.16.24

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 (68) hide show
  1. package/README.md +4 -3
  2. package/bin/commands/dev.mjs +22 -3
  3. package/bin/commands/new.mjs +6 -2
  4. package/bin/commands/start.mjs +4 -0
  5. package/bin/lib/get-port.mjs +60 -0
  6. package/bin/lib/live/browser-auth.mjs +125 -0
  7. package/bin/lib/live/chatgpt-config.d.mts +5 -0
  8. package/bin/lib/live/chatgpt-config.mjs +12 -0
  9. package/bin/lib/live/chatgpt-fixtures.d.mts +12 -0
  10. package/bin/lib/live/chatgpt-fixtures.mjs +25 -0
  11. package/bin/lib/live/chatgpt-page.mjs +210 -0
  12. package/bin/lib/live/global-setup.mjs +150 -0
  13. package/bin/lib/live/host-fixtures.mjs +61 -0
  14. package/bin/lib/live/host-page.mjs +242 -0
  15. package/bin/lib/live/live-config.d.mts +38 -0
  16. package/bin/lib/live/live-config.mjs +98 -0
  17. package/bin/lib/live/live-fixtures.d.mts +11 -0
  18. package/bin/lib/live/live-fixtures.mjs +102 -0
  19. package/bin/lib/live/test-config.d.mts +10 -0
  20. package/bin/lib/live/test-config.mjs +35 -0
  21. package/bin/lib/live/types.d.mts +54 -0
  22. package/bin/lib/live/utils.mjs +70 -0
  23. package/bin/sunpeak.js +1 -1
  24. package/dist/chatgpt/index.cjs +1 -1
  25. package/dist/chatgpt/index.js +1 -1
  26. package/dist/claude/index.cjs +1 -1
  27. package/dist/claude/index.js +1 -1
  28. package/dist/{index-CX6Z4bED.js → index-B7Qw3Vhh.js} +2 -2
  29. package/dist/index-B7Qw3Vhh.js.map +1 -0
  30. package/dist/{index-B4aC3vjH.js → index-BEHP_bM8.js} +2 -2
  31. package/dist/index-BEHP_bM8.js.map +1 -0
  32. package/dist/{index-bKBBCBK6.cjs → index-SfudQ9Y_.cjs} +2 -2
  33. package/dist/index-SfudQ9Y_.cjs.map +1 -0
  34. package/dist/{index-CKabCJyV.cjs → index-XKHXfBiD.cjs} +2 -2
  35. package/dist/index-XKHXfBiD.cjs.map +1 -0
  36. package/dist/index.cjs +13 -5
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.js +13 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/mcp/index.cjs +38 -13
  41. package/dist/mcp/index.cjs.map +1 -1
  42. package/dist/mcp/index.js +38 -13
  43. package/dist/mcp/index.js.map +1 -1
  44. package/dist/mcp/types.d.ts +2 -0
  45. package/dist/simulator/index.cjs +1 -1
  46. package/dist/simulator/index.js +1 -1
  47. package/dist/simulator/simple-sidebar.d.ts +3 -1
  48. package/dist/{simulator-D8t-r7HH.js → simulator-BCq2iOT-.js} +67 -27
  49. package/dist/simulator-BCq2iOT-.js.map +1 -0
  50. package/dist/{simulator-FFNttkqL.cjs → simulator-DRUsm6IZ.cjs} +67 -27
  51. package/dist/simulator-DRUsm6IZ.cjs.map +1 -0
  52. package/package.json +25 -1
  53. package/template/README.md +24 -2
  54. package/template/_gitignore +1 -0
  55. package/template/package.json +3 -2
  56. package/template/playwright.config.ts +24 -1
  57. package/template/tests/live/albums.spec.ts +53 -0
  58. package/template/tests/live/carousel.spec.ts +52 -0
  59. package/template/tests/live/map.spec.ts +31 -0
  60. package/template/tests/live/playwright.config.ts +3 -0
  61. package/template/tests/live/review.spec.ts +54 -0
  62. package/template/vitest.config.ts +1 -1
  63. package/dist/index-B4aC3vjH.js.map +0 -1
  64. package/dist/index-CKabCJyV.cjs.map +0 -1
  65. package/dist/index-CX6Z4bED.js.map +0 -1
  66. package/dist/index-bKBBCBK6.cjs.map +0 -1
  67. package/dist/simulator-D8t-r7HH.js.map +0 -1
  68. package/dist/simulator-FFNttkqL.cjs.map +0 -1
package/README.md CHANGED
@@ -51,14 +51,15 @@ sunpeak new
51
51
 
52
52
  1. Runtime APIs: Strongly typed React hooks for interacting with the host runtime (`useApp`, `useToolData`, `useAppState`, `useHostContext`, `useUpdateModelContext`, `useAppTools`), architected to **support generic and platform-specific features** (ChatGPT, Claude, etc.). Host-specific hooks like `useUploadFile`, `useRequestModal`, and `useRequestCheckout` are available via `sunpeak/host/chatgpt`, with `isChatGPT()` / `isClaude()` host detection via `sunpeak/host`.
53
53
  2. Multi-host simulator: React component replicating host runtimes (ChatGPT, Claude) to **test Apps locally and automatically** via UI, props, or URL parameters.
54
- 3. MCP server: Serve Resources with mock data to hosts like ChatGPT and Claude with HMR (**no more cache issues or 5-click manual refreshes**).
54
+ 3. Live testing: **Automated tests against real ChatGPT** opens your browser, sends messages, validates your app renders correctly inside the real host. No more manual testing.
55
+ 4. MCP server: Serve Resources with mock data to hosts like ChatGPT and Claude with HMR (**no more cache issues or 5-click manual refreshes**).
55
56
 
56
57
  ### The `sunpeak` framework
57
58
 
58
- Next.js for MCP Apps. Using an example App `my-app` with a `Review` UI ([MCP resource](https://sunpeak.ai/docs/mcp-apps/mcp/resources)), `sunpeak` projects look like:
59
+ Next.js for MCP Apps. Using an example App `sunpeak-app` with a `Review` UI ([MCP resource](https://sunpeak.ai/docs/mcp-apps/mcp/resources)), `sunpeak` projects look like:
59
60
 
60
61
  ```bash
61
- my-app/
62
+ sunpeak-app/
62
63
  ├── src/
63
64
  │ ├── resources/
64
65
  │ │ └── review/
@@ -6,6 +6,7 @@ const { join, resolve, basename, dirname } = path;
6
6
  import { createRequire } from 'module';
7
7
  import { pathToFileURL } from 'url';
8
8
  import { spawn } from 'child_process';
9
+ import { getPort } from '../lib/get-port.mjs';
9
10
 
10
11
  /**
11
12
  * Import a module from the project's node_modules using ESM resolution
@@ -291,6 +292,16 @@ export async function dev(projectRoot = process.cwd(), args = []) {
291
292
  sunpeakFaviconPlugin(),
292
293
  sunpeakCallToolPlugin(),
293
294
  sunpeakDistPlugin(),
295
+ // Health endpoint for Playwright webServer readiness check
296
+ {
297
+ name: 'sunpeak-health',
298
+ configureServer(server) {
299
+ server.middlewares.use('/health', (_req, res) => {
300
+ res.writeHead(200, { 'Content-Type': 'application/json' });
301
+ res.end(JSON.stringify({ status: 'ok' }));
302
+ });
303
+ },
304
+ },
294
305
  ],
295
306
  define: {
296
307
  '__SUNPEAK_PROD_TOOLS__': JSON.stringify(isProdTools),
@@ -307,7 +318,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
307
318
  },
308
319
  server: {
309
320
  port,
310
- open: true,
321
+ // Don't auto-open browser when started by Playwright or CI
322
+ open: !process.env.CI && !process.env.SUNPEAK_LIVE_TEST,
323
+ // Allow tunnel hosts (ngrok, cloudflared, etc.) to reach the dev server
324
+ allowedHosts: 'all',
311
325
  },
312
326
  });
313
327
 
@@ -471,6 +485,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
471
485
 
472
486
  // Start MCP server with its own Vite instance for HMR
473
487
  if (simulations.length > 0) {
488
+ // Find available ports for the MCP server and HMR WebSocket
489
+ const mcpPort = await getPort(8000);
490
+ const hmrPort = await getPort(Number(process.env.SUNPEAK_HMR_PORT || 24679));
491
+
474
492
  console.log(`\nStarting MCP server with ${simulations.length} simulation(s) (Vite HMR)...`);
475
493
 
476
494
  // Virtual entry module plugin for MCP
@@ -538,7 +556,7 @@ if (import.meta.hot) {
538
556
  },
539
557
  server: {
540
558
  middlewareMode: true,
541
- hmr: { port: Number(process.env.SUNPEAK_HMR_PORT || 24679) },
559
+ hmr: { port: hmrPort },
542
560
  allowedHosts: true,
543
561
  watch: {
544
562
  // Only watch files that affect the UI bundle (not JSON, tests, etc.)
@@ -564,7 +582,8 @@ if (import.meta.hot) {
564
582
  name: pkg.name || 'Sunpeak',
565
583
  version: pkg.version || '0.1.0',
566
584
  simulations,
567
- port: 8000,
585
+ port: mcpPort,
586
+ hmrPort,
568
587
  // In --prod-resources mode, don't pass viteServer so the MCP server serves pre-built HTML.
569
588
  // Otherwise, pass it so ChatGPT gets Vite HMR.
570
589
  viteServer: isProdResources ? undefined : mcpViteServer,
@@ -19,8 +19,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  async function defaultPromptName() {
20
20
  const value = await clack.text({
21
21
  message: 'Project name',
22
- placeholder: 'my-app',
23
- defaultValue: 'my-app',
22
+ placeholder: 'sunpeak-app',
23
+ defaultValue: 'sunpeak-app',
24
24
  validate: (v) => {
25
25
  if (v === 'template') return '"template" is a reserved name';
26
26
  },
@@ -203,6 +203,10 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
203
203
  if (src.includes('/tests/e2e/') && name === `${resource}.spec.ts`) {
204
204
  return false;
205
205
  }
206
+ // Skip live test files for excluded resources
207
+ if (src.includes('/tests/live/') && name === `${resource}.spec.ts`) {
208
+ return false;
209
+ }
206
210
  }
207
211
 
208
212
  return true;
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { createRequire } from 'module';
5
5
  import { pathToFileURL } from 'url';
6
+ import { getPort } from '../lib/get-port.mjs';
6
7
 
7
8
  /**
8
9
  * Start a production MCP server from built artifacts.
@@ -183,6 +184,9 @@ export async function start(projectRoot = process.cwd(), args = []) {
183
184
  const name = serverConfig.name ?? pkg.name ?? 'sunpeak-app';
184
185
  const version = serverConfig.version ?? pkg.version ?? '0.1.0';
185
186
 
187
+ // Find an available port (prefer the configured one)
188
+ port = await getPort(port);
189
+
186
190
  console.log(`\nStarting ${name} v${version} on ${host}:${port}...`);
187
191
 
188
192
  startProductionHttpServer(
@@ -0,0 +1,60 @@
1
+ import { createServer } from 'net';
2
+ import { execSync } from 'child_process';
3
+
4
+ /**
5
+ * Synchronous version of getPort — safe to call at Playwright config load time.
6
+ * Spawns a tiny Node child process that binds, prints the port, and exits.
7
+ *
8
+ * @param {number} preferred - Port to try first
9
+ * @returns {number} The available port number
10
+ */
11
+ export function getPortSync(preferred) {
12
+ const script = `
13
+ const s = require("net").createServer();
14
+ s.listen(${preferred}, () => {
15
+ process.stdout.write(String(s.address().port));
16
+ s.close();
17
+ });
18
+ s.on("error", () => {
19
+ const f = require("net").createServer();
20
+ f.listen(0, () => {
21
+ process.stdout.write(String(f.address().port));
22
+ f.close();
23
+ });
24
+ });
25
+ `;
26
+ return Number(execSync(`node -e '${script}'`, { encoding: 'utf-8' }).trim());
27
+ }
28
+
29
+ /**
30
+ * Find an available TCP port, preferring the given port.
31
+ * If the preferred port is in use, returns a random available port.
32
+ *
33
+ * Listens without specifying a host so Node.js uses dual-stack `::`,
34
+ * which detects conflicts on both IPv4 and IPv6 interfaces.
35
+ *
36
+ * @param {number} preferred - Port to try first (default: 0 = any available)
37
+ * @returns {Promise<number>} The available port number
38
+ */
39
+ export function getPort(preferred = 0) {
40
+ return new Promise((resolve, reject) => {
41
+ const server = createServer();
42
+ server.listen(preferred, () => {
43
+ const { port } = server.address();
44
+ server.close(() => resolve(port));
45
+ });
46
+ server.on('error', (err) => {
47
+ if (err.code === 'EADDRINUSE' && preferred !== 0) {
48
+ // Preferred port is busy — get any available port
49
+ const fallback = createServer();
50
+ fallback.listen(0, () => {
51
+ const { port } = fallback.address();
52
+ fallback.close(() => resolve(port));
53
+ });
54
+ fallback.on('error', reject);
55
+ } else {
56
+ reject(err);
57
+ }
58
+ });
59
+ });
60
+ }
@@ -0,0 +1,125 @@
1
+ import { mkdtempSync, mkdirSync, cpSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { tmpdir, homedir } from 'os';
4
+ import { rimrafSync, ANTI_BOT_ARGS, CHROME_USER_AGENT } from './utils.mjs';
5
+
6
+ /**
7
+ * Browser profile paths on macOS.
8
+ * Each entry maps a browser name to its user data directory.
9
+ */
10
+ const BROWSER_PROFILES = {
11
+ chrome: join(homedir(), 'Library/Application Support/Google/Chrome'),
12
+ arc: join(homedir(), 'Library/Application Support/Arc/User Data'),
13
+ brave: join(homedir(), 'Library/Application Support/BraveSoftware/Brave-Browser'),
14
+ edge: join(homedir(), 'Library/Application Support/Microsoft Edge'),
15
+ };
16
+
17
+ /**
18
+ * Subdirectories/files to copy from the browser profile.
19
+ * These contain session cookies and local storage — enough for authenticated browsing.
20
+ * Copying only these keeps the operation fast (<2s) vs copying the full profile (500MB+).
21
+ */
22
+ const ESSENTIAL_PATHS = [
23
+ 'Default/Cookies',
24
+ 'Default/Cookies-journal',
25
+ 'Default/Local Storage',
26
+ 'Default/Session Storage',
27
+ 'Default/IndexedDB',
28
+ 'Default/Login Data',
29
+ 'Default/Login Data-journal',
30
+ 'Default/Preferences',
31
+ 'Default/Secure Preferences',
32
+ 'Default/Web Data',
33
+ 'Local State',
34
+ ];
35
+
36
+ /**
37
+ * Detect which browser the user has installed.
38
+ * Returns the first available browser from the preference order.
39
+ */
40
+ export function detectBrowser() {
41
+ const order = ['chrome', 'arc', 'brave', 'edge'];
42
+ for (const browser of order) {
43
+ if (existsSync(BROWSER_PROFILES[browser])) {
44
+ return browser;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Copy essential browser profile data to a temp directory.
52
+ * Returns the temp directory path.
53
+ */
54
+ function copyProfile(browser) {
55
+ const profileDir = BROWSER_PROFILES[browser];
56
+ if (!profileDir || !existsSync(profileDir)) {
57
+ throw new Error(
58
+ `Browser profile not found for "${browser}" at ${profileDir || '(unknown)'}.\n` +
59
+ `Available browsers: ${Object.entries(BROWSER_PROFILES)
60
+ .filter(([, p]) => existsSync(p))
61
+ .map(([name]) => name)
62
+ .join(', ') || 'none detected'}`
63
+ );
64
+ }
65
+
66
+ const tempDir = mkdtempSync(join(tmpdir(), 'sunpeak-live-'));
67
+
68
+ for (const relativePath of ESSENTIAL_PATHS) {
69
+ const src = join(profileDir, relativePath);
70
+ if (!existsSync(src)) continue;
71
+
72
+ const dest = join(tempDir, relativePath);
73
+ try {
74
+ cpSync(src, dest, { recursive: true });
75
+ } catch {
76
+ // Some files may be locked; skip silently
77
+ }
78
+ }
79
+
80
+ // Ensure Default directory exists even if no essential files were copied.
81
+ // Use an allowlist to avoid copying large cache/media directories.
82
+ const defaultDir = join(tempDir, 'Default');
83
+ if (!existsSync(defaultDir)) {
84
+ mkdirSync(defaultDir, { recursive: true });
85
+ }
86
+
87
+ return tempDir;
88
+ }
89
+
90
+ /**
91
+ * Launch a Playwright Chromium browser authenticated with the user's real browser session.
92
+ *
93
+ * Copies the user's browser profile to a temp directory, then launches Playwright
94
+ * with that profile. The returned cleanup function removes the temp directory.
95
+ *
96
+ * @param {Object} options
97
+ * @param {string} [options.browser='chrome'] - Browser to copy profile from
98
+ * @param {boolean} [options.headless=false] - Run headless (usually false for live tests)
99
+ * @returns {Promise<{ context: BrowserContext, page: Page, cleanup: () => void }>}
100
+ */
101
+ export async function launchAuthenticatedBrowser({ browser = 'chrome', headless = false } = {}) {
102
+ const { chromium } = await import('playwright');
103
+
104
+ const tempDir = copyProfile(browser);
105
+
106
+ const context = await chromium.launchPersistentContext(tempDir, {
107
+ headless,
108
+ args: ANTI_BOT_ARGS,
109
+ viewport: { width: 1280, height: 900 },
110
+ ignoreDefaultArgs: ['--enable-automation'],
111
+ userAgent: CHROME_USER_AGENT,
112
+ });
113
+
114
+ const page = context.pages()[0] || await context.newPage();
115
+
116
+ const cleanup = () => {
117
+ try {
118
+ rimrafSync(tempDir);
119
+ } catch {
120
+ // Best effort cleanup
121
+ }
122
+ };
123
+
124
+ return { context, page, cleanup };
125
+ }
@@ -0,0 +1,5 @@
1
+ import type { PlaywrightTestConfig } from '@playwright/test';
2
+ import type { LiveConfigOptions } from './live-config.d.mts';
3
+
4
+ /** Create a complete Playwright config for live ChatGPT testing. */
5
+ export declare function defineLiveConfig(options?: LiveConfigOptions): PlaywrightTestConfig;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Playwright config for live ChatGPT tests.
3
+ *
4
+ * Usage in playwright.config.ts:
5
+ * import { defineLiveConfig } from 'sunpeak/test/chatgpt/config';
6
+ * export default defineLiveConfig();
7
+ */
8
+ import { createLiveConfig } from './live-config.mjs';
9
+
10
+ export function defineLiveConfig(options = {}) {
11
+ return createLiveConfig({ hostId: 'chatgpt' }, options);
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { LiveFixture } from './types.d.mts';
2
+
3
+ /** ChatGPT-specific fixture (alias of LiveFixture for backwards compatibility). */
4
+ export type ChatGPTFixture = LiveFixture;
5
+
6
+ export declare const test: {
7
+ (title: string, fn: (args: { chatgpt: ChatGPTFixture } & Record<string, any>) => Promise<void>): void;
8
+ describe: (title: string, fn: () => void) => void;
9
+ skip: (title: string, fn: (args: { chatgpt: ChatGPTFixture } & Record<string, any>) => Promise<void>) => void;
10
+ };
11
+
12
+ export declare const expect: (...args: any[]) => any;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Playwright fixtures for live ChatGPT testing.
3
+ *
4
+ * Provides a `chatgpt` fixture that handles login, MCP server refresh,
5
+ * and app name prefixing — so user test files only need assertions.
6
+ *
7
+ * Usage:
8
+ * import { test, expect } from 'sunpeak/test/chatgpt';
9
+ *
10
+ * test('my resource renders', async ({ chatgpt }) => {
11
+ * const app = await chatgpt.invoke('show me something');
12
+ * await expect(app.locator('img').first()).toBeVisible();
13
+ * });
14
+ */
15
+ import { ChatGPTPage } from './chatgpt-page.mjs';
16
+ import { createHostFixtures } from './host-fixtures.mjs';
17
+
18
+ const { test, expect } = createHostFixtures({
19
+ fixtureName: 'chatgpt',
20
+ HostPageClass: ChatGPTPage,
21
+ /** ChatGPT requires /{appName} prefix to invoke MCP apps. */
22
+ formatMessage: (appName, text) => `/${appName} ${text}`,
23
+ });
24
+
25
+ export { test, expect };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * ChatGPT host page object for live testing.
3
+ *
4
+ * All ChatGPT-specific DOM selectors and interaction logic lives here.
5
+ * When ChatGPT updates their UI, only this file needs updating.
6
+ *
7
+ * Extends HostPage which provides shared behavior (sendMessage, login, etc.).
8
+ */
9
+ import { HostPage } from './host-page.mjs';
10
+
11
+ /**
12
+ * All ChatGPT DOM selectors in one place for easy maintenance.
13
+ *
14
+ * Last verified: 2026-03-17 via live Playwright inspection.
15
+ */
16
+ const SELECTORS = {
17
+ // Chat interface
18
+ chatInput: '#prompt-textarea',
19
+ sendButton: '[data-testid="send-button"]',
20
+ newChatLink: 'a:has-text("New chat")',
21
+
22
+ // Login detection — ChatGPT renders two profile buttons (sidebar compact + expanded); always use .first().
23
+ loggedInIndicator: '[data-testid="accounts-profile-button"]',
24
+ loginPage: 'button:has-text("Log in")',
25
+
26
+ // Settings navigation
27
+ appsTab: '[role="tab"]:has-text("Apps")',
28
+ refreshButton: 'button:has-text("Refresh")',
29
+ reconnectButton: 'button:has-text("Reconnect")',
30
+
31
+ // App iframe — ChatGPT uses a nested iframe structure:
32
+ // outer: iframe[sandbox] (connector sandbox, no direct content)
33
+ // inner: iframe name="root" (actual app React content)
34
+ mcpAppOuterIframe: 'iframe[sandbox*="allow-scripts"]',
35
+ mcpAppInnerFrameName: 'root',
36
+
37
+ // Streaming indicator
38
+ stopButton: 'button[aria-label="Stop streaming"], button:has-text("Stop")',
39
+ };
40
+
41
+ const URLS = {
42
+ base: 'https://chatgpt.com',
43
+ settings: 'https://chatgpt.com/#settings/Connectors',
44
+ };
45
+
46
+ export { SELECTORS as CHATGPT_SELECTORS, URLS as CHATGPT_URLS };
47
+
48
+ export class ChatGPTPage extends HostPage {
49
+ get hostId() { return 'chatgpt'; }
50
+ get hostName() { return 'ChatGPT'; }
51
+ get selectors() { return SELECTORS; }
52
+ get urls() { return URLS; }
53
+
54
+ /**
55
+ * Refresh the MCP server connection in ChatGPT settings.
56
+ * Navigates to Settings > Apps, clicks the app entry, clicks Refresh,
57
+ * and waits for the success/error toast.
58
+ */
59
+ async refreshMcpServer({ tunnelUrl, appName } = {}) {
60
+ await this.page.goto(URLS.settings, { waitUntil: 'domcontentloaded' });
61
+ await this.page.waitForTimeout(3_000);
62
+
63
+ const found = await this._findAndClickRefresh(appName);
64
+
65
+ if (!found) {
66
+ const appsTab = this.page.locator(SELECTORS.appsTab);
67
+ const hasAppsTab = await appsTab.isVisible().catch(() => false);
68
+ if (hasAppsTab) {
69
+ await appsTab.click();
70
+ await this.page.waitForTimeout(2_000);
71
+ const retryFound = await this._findAndClickRefresh(appName);
72
+ if (!retryFound) {
73
+ await this._screenshotAndThrow('refresh-mcp-settings', tunnelUrl);
74
+ }
75
+ } else {
76
+ await this._screenshotAndThrow('no-apps-tab', tunnelUrl);
77
+ }
78
+ }
79
+
80
+ // Wait for the refresh toast
81
+ const { hasError, errorText } = await this._waitForToast();
82
+ if (hasError) {
83
+ throw new Error(
84
+ `MCP server refresh failed in ChatGPT:\n${errorText.trim()}\n\n` +
85
+ `Make sure your MCP dev server is running (pnpm dev) and your tunnel is active.`
86
+ );
87
+ }
88
+
89
+ // Wait for resource preloading to complete
90
+ await this.page.waitForTimeout(3_000);
91
+
92
+ // Navigate back to chat
93
+ await this.page.goto(URLS.base, { waitUntil: 'domcontentloaded' });
94
+ await this.page.locator(SELECTORS.chatInput).waitFor({ timeout: 10_000 });
95
+ }
96
+
97
+ /**
98
+ * Wait for a MCP app iframe to appear in the conversation.
99
+ * ChatGPT renders apps in a nested iframe (outer sandbox > inner #root).
100
+ */
101
+ async waitForAppIframe({ timeout = 90_000 } = {}) {
102
+ // Wait for streaming to finish
103
+ try {
104
+ await this.page.locator(SELECTORS.stopButton).waitFor({ state: 'hidden', timeout });
105
+ } catch {
106
+ // Stop button may never appear if response was instant
107
+ }
108
+
109
+ // Wait for the outer sandbox iframe
110
+ await this.page.locator(SELECTORS.mcpAppOuterIframe).first().waitFor({ state: 'attached', timeout: 30_000 });
111
+
112
+ // Wait for the inner frame to appear inside the sandboxed outer iframe.
113
+ // waitForFunction can't cross the sandbox boundary, so use Playwright's frameLocator instead.
114
+ const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
115
+ await outerFrame
116
+ .locator(`iframe[name="${SELECTORS.mcpAppInnerFrameName}"], iframe#${SELECTORS.mcpAppInnerFrameName}`)
117
+ .waitFor({ state: 'attached', timeout: 15_000 });
118
+
119
+ const appFrame = this.getAppIframe();
120
+
121
+ // Wait for content to render
122
+ try {
123
+ await appFrame.locator('body *').first().waitFor({ state: 'visible', timeout: 15_000 });
124
+ } catch {
125
+ // Caller's assertions will catch missing content
126
+ }
127
+
128
+ return appFrame;
129
+ }
130
+
131
+ getAppIframe() {
132
+ const name = SELECTORS.mcpAppInnerFrameName;
133
+ const outerFrame = this.page.frameLocator(SELECTORS.mcpAppOuterIframe).first();
134
+ return outerFrame.frameLocator(`iframe[name="${name}"], iframe#${name}`);
135
+ }
136
+
137
+ /**
138
+ * Send a message, pausing around the space after the /{appName} prefix.
139
+ * ChatGPT needs a moment to associate the app before the rest of the prompt is typed.
140
+ */
141
+ async sendMessage(text) {
142
+ const input = this.page.locator(SELECTORS.chatInput);
143
+ await input.waitFor({ timeout: 10_000 });
144
+ await input.click();
145
+
146
+ if (text.startsWith('/')) {
147
+ const spaceIdx = text.indexOf(' ');
148
+ if (spaceIdx !== -1) {
149
+ await input.pressSequentially(text.slice(0, spaceIdx), { delay: 10 });
150
+ await this.page.waitForTimeout(500);
151
+ await input.pressSequentially(' ', { delay: 10 });
152
+ await this.page.waitForTimeout(500);
153
+ await input.pressSequentially(text.slice(spaceIdx + 1), { delay: 10 });
154
+ } else {
155
+ await input.pressSequentially(text, { delay: 10 });
156
+ }
157
+ } else {
158
+ await input.pressSequentially(text, { delay: 10 });
159
+ }
160
+
161
+ const sendBtn = this.page.locator(SELECTORS.sendButton);
162
+ await sendBtn.waitFor({ state: 'visible', timeout: 10_000 });
163
+ await sendBtn.click();
164
+ }
165
+
166
+ // --- ChatGPT-specific private methods ---
167
+
168
+ /** @private */
169
+ async _findAndClickRefresh(appName) {
170
+ const refreshBtn = this.page.locator(SELECTORS.refreshButton).first();
171
+ const reconnectBtn = this.page.locator(SELECTORS.reconnectButton).first();
172
+
173
+ const tryClickRefresh = async () => {
174
+ if (await refreshBtn.isVisible().catch(() => false)) {
175
+ await refreshBtn.click();
176
+ return true;
177
+ }
178
+ if (await reconnectBtn.isVisible().catch(() => false)) {
179
+ await reconnectBtn.click();
180
+ return true;
181
+ }
182
+ return false;
183
+ };
184
+
185
+ if (await tryClickRefresh()) return true;
186
+
187
+ if (appName) {
188
+ const strategies = [
189
+ () => this.page.getByText(appName, { exact: true }).first(),
190
+ () => this.page.locator(`text=${appName}`).first(),
191
+ () => this.page.locator(`a:has-text("${appName}"), [role="button"]:has-text("${appName}")`).first(),
192
+ ];
193
+
194
+ for (const getLocator of strategies) {
195
+ try {
196
+ const el = getLocator();
197
+ if (await el.isVisible().catch(() => false)) {
198
+ await el.click();
199
+ await this.page.waitForTimeout(2_000);
200
+ if (await tryClickRefresh()) return true;
201
+ }
202
+ } catch {
203
+ // Strategy didn't work, try next
204
+ }
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+ }