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.
- package/README.md +4 -3
- package/bin/commands/dev.mjs +22 -3
- package/bin/commands/new.mjs +6 -2
- package/bin/commands/start.mjs +4 -0
- package/bin/lib/get-port.mjs +60 -0
- package/bin/lib/live/browser-auth.mjs +125 -0
- package/bin/lib/live/chatgpt-config.d.mts +5 -0
- package/bin/lib/live/chatgpt-config.mjs +12 -0
- package/bin/lib/live/chatgpt-fixtures.d.mts +12 -0
- package/bin/lib/live/chatgpt-fixtures.mjs +25 -0
- package/bin/lib/live/chatgpt-page.mjs +210 -0
- package/bin/lib/live/global-setup.mjs +150 -0
- package/bin/lib/live/host-fixtures.mjs +61 -0
- package/bin/lib/live/host-page.mjs +242 -0
- package/bin/lib/live/live-config.d.mts +38 -0
- package/bin/lib/live/live-config.mjs +98 -0
- package/bin/lib/live/live-fixtures.d.mts +11 -0
- package/bin/lib/live/live-fixtures.mjs +102 -0
- package/bin/lib/live/test-config.d.mts +10 -0
- package/bin/lib/live/test-config.mjs +35 -0
- package/bin/lib/live/types.d.mts +54 -0
- package/bin/lib/live/utils.mjs +70 -0
- package/bin/sunpeak.js +1 -1
- package/dist/chatgpt/index.cjs +1 -1
- package/dist/chatgpt/index.js +1 -1
- package/dist/claude/index.cjs +1 -1
- package/dist/claude/index.js +1 -1
- package/dist/{index-CX6Z4bED.js → index-B7Qw3Vhh.js} +2 -2
- package/dist/index-B7Qw3Vhh.js.map +1 -0
- package/dist/{index-B4aC3vjH.js → index-BEHP_bM8.js} +2 -2
- package/dist/index-BEHP_bM8.js.map +1 -0
- package/dist/{index-bKBBCBK6.cjs → index-SfudQ9Y_.cjs} +2 -2
- package/dist/index-SfudQ9Y_.cjs.map +1 -0
- package/dist/{index-CKabCJyV.cjs → index-XKHXfBiD.cjs} +2 -2
- package/dist/index-XKHXfBiD.cjs.map +1 -0
- package/dist/index.cjs +13 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.cjs +38 -13
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.js +38 -13
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/types.d.ts +2 -0
- package/dist/simulator/index.cjs +1 -1
- package/dist/simulator/index.js +1 -1
- package/dist/simulator/simple-sidebar.d.ts +3 -1
- package/dist/{simulator-D8t-r7HH.js → simulator-BCq2iOT-.js} +67 -27
- package/dist/simulator-BCq2iOT-.js.map +1 -0
- package/dist/{simulator-FFNttkqL.cjs → simulator-DRUsm6IZ.cjs} +67 -27
- package/dist/simulator-DRUsm6IZ.cjs.map +1 -0
- package/package.json +25 -1
- package/template/README.md +24 -2
- package/template/_gitignore +1 -0
- package/template/package.json +3 -2
- package/template/playwright.config.ts +24 -1
- package/template/tests/live/albums.spec.ts +53 -0
- package/template/tests/live/carousel.spec.ts +52 -0
- package/template/tests/live/map.spec.ts +31 -0
- package/template/tests/live/playwright.config.ts +3 -0
- package/template/tests/live/review.spec.ts +54 -0
- package/template/vitest.config.ts +1 -1
- package/dist/index-B4aC3vjH.js.map +0 -1
- package/dist/index-CKabCJyV.cjs.map +0 -1
- package/dist/index-CX6Z4bED.js.map +0 -1
- package/dist/index-bKBBCBK6.cjs.map +0 -1
- package/dist/simulator-D8t-r7HH.js.map +0 -1
- 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.
|
|
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 `
|
|
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
|
-
|
|
62
|
+
sunpeak-app/
|
|
62
63
|
├── src/
|
|
63
64
|
│ ├── resources/
|
|
64
65
|
│ │ └── review/
|
package/bin/commands/dev.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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,
|
package/bin/commands/new.mjs
CHANGED
|
@@ -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: '
|
|
23
|
-
defaultValue: '
|
|
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;
|
package/bin/commands/start.mjs
CHANGED
|
@@ -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
|
+
}
|