sunpeak 0.16.21 → 0.16.27
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 +120 -8
- package/bin/commands/new.mjs +6 -2
- package/bin/commands/start.mjs +7 -2
- package/bin/lib/get-port.mjs +60 -0
- package/bin/lib/live/browser-auth.mjs +161 -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 +158 -0
- package/bin/lib/live/host-fixtures.mjs +61 -0
- package/bin/lib/live/host-page.mjs +294 -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/lib/sandbox-server.mjs +304 -0
- package/bin/sunpeak.js +1 -1
- package/dist/chatgpt/chatgpt-conversation.d.ts +3 -7
- package/dist/chatgpt/globals.css +18 -0
- package/dist/chatgpt/index.cjs +1 -1
- package/dist/chatgpt/index.js +1 -1
- package/dist/claude/claude-conversation.d.ts +3 -2
- package/dist/claude/index.cjs +1 -1
- package/dist/claude/index.js +1 -1
- package/dist/{index-bKBBCBK6.cjs → index-BEWVLFfB.cjs} +2 -2
- package/dist/index-BEWVLFfB.cjs.map +1 -0
- package/dist/{index-CX6Z4bED.js → index-C6XYFOmh.js} +2 -2
- package/dist/index-C6XYFOmh.js.map +1 -0
- package/dist/{index-CKabCJyV.cjs → index-D0FsXP3Y.cjs} +2 -2
- package/dist/index-D0FsXP3Y.cjs.map +1 -0
- package/dist/{index-B4aC3vjH.js → index-Rg7SWjvl.js} +2 -2
- package/dist/index-Rg7SWjvl.js.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/favicon.d.ts +3 -1
- package/dist/mcp/index.cjs +90 -49
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.d.ts +2 -2
- package/dist/mcp/index.js +90 -49
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/production-server.d.ts +7 -1
- package/dist/mcp/types.d.ts +32 -1
- package/dist/simulator/hosts.d.ts +11 -2
- package/dist/simulator/iframe-resource.d.ts +8 -1
- package/dist/simulator/index.cjs +1 -1
- package/dist/simulator/index.js +1 -1
- package/dist/simulator/mcp-app-host.d.ts +17 -0
- package/dist/simulator/sandbox-proxy.d.ts +38 -0
- package/dist/simulator/simple-sidebar.d.ts +3 -1
- package/dist/simulator/simulator.d.ts +7 -1
- package/dist/simulator/use-simulator-state.d.ts +2 -4
- package/dist/{simulator-D8t-r7HH.js → simulator-B-CrMHVs.js} +504 -192
- package/dist/simulator-B-CrMHVs.js.map +1 -0
- package/dist/{simulator-FFNttkqL.cjs → simulator-Gc6n_fT4.cjs} +503 -191
- package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
- package/dist/style.css +18 -0
- package/package.json +25 -1
- package/template/.sunpeak/dev.tsx +9 -3
- package/template/README.md +24 -2
- package/template/_gitignore +1 -0
- package/template/package.json +3 -2
- package/template/playwright.config.ts +34 -6
- package/template/src/server.ts +16 -2
- package/template/src/tools/show-albums.ts +17 -0
- package/template/tests/e2e/albums.spec.ts +37 -5
- package/template/tests/e2e/carousel.spec.ts +6 -6
- package/template/tests/e2e/global-setup.ts +6 -21
- package/template/tests/e2e/map.spec.ts +11 -11
- package/template/tests/e2e/review.spec.ts +24 -24
- 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,8 @@ 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';
|
|
10
|
+
import { startSandboxServer } from '../lib/sandbox-server.mjs';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Import a module from the project's node_modules using ESM resolution
|
|
@@ -186,7 +188,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
186
188
|
sunpeakMcp = await import(pathToFileURL(join(sunpeakBase, 'dist/mcp/index.js')).href);
|
|
187
189
|
sunpeakDiscovery = await import(pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href);
|
|
188
190
|
}
|
|
189
|
-
const { FAVICON_BUFFER: faviconBuffer, runMCPServer } = sunpeakMcp;
|
|
191
|
+
const { FAVICON_BUFFER: faviconBuffer, FAVICON_DATA_URI: faviconDataUri, runMCPServer } = sunpeakMcp;
|
|
190
192
|
const { findResourceDirs, findSimulationFilesFlat, findToolFiles, extractResourceExport, extractToolExport } = sunpeakDiscovery;
|
|
191
193
|
|
|
192
194
|
// Vite plugin to serve the sunpeak favicon
|
|
@@ -282,19 +284,91 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
282
284
|
},
|
|
283
285
|
});
|
|
284
286
|
|
|
287
|
+
// Start the separate-origin sandbox server for cross-origin iframe isolation.
|
|
288
|
+
// This matches how production hosts (ChatGPT, Claude) run app iframes on a
|
|
289
|
+
// separate sandbox origin (e.g., web-sandbox.oaiusercontent.com).
|
|
290
|
+
const sandboxPort = Number(process.env.SUNPEAK_SANDBOX_PORT || 24680);
|
|
291
|
+
const sandbox = await startSandboxServer({ preferredPort: sandboxPort });
|
|
292
|
+
|
|
293
|
+
// Load server config from src/server.ts (if present) for simulator display.
|
|
294
|
+
// Uses a temporary SSR server so the values are available as Vite defines
|
|
295
|
+
// before the main simulator UI server starts.
|
|
296
|
+
// The fallback chain matches the MCP server: serverInfo.name → pkg.name → 'sunpeak-app'.
|
|
297
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
298
|
+
let serverDisplayName = pkg.name ?? null;
|
|
299
|
+
let serverDisplayIcon = undefined;
|
|
300
|
+
const serverEntryPath = join(projectRoot, 'src/server.ts');
|
|
301
|
+
if (existsSync(serverEntryPath)) {
|
|
302
|
+
const configLoader = await createServer({
|
|
303
|
+
root: projectRoot,
|
|
304
|
+
server: { middlewareMode: true, hmr: false },
|
|
305
|
+
resolve: { alias: { '@': path.resolve(projectRoot, 'src'), ...(isTemplate && { sunpeak: parentSrc }) } },
|
|
306
|
+
appType: 'custom',
|
|
307
|
+
logLevel: 'silent',
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
const serverMod = await configLoader.ssrLoadModule('./src/server.ts');
|
|
311
|
+
if (serverMod.server && typeof serverMod.server === 'object') {
|
|
312
|
+
if (serverMod.server.name) serverDisplayName = serverMod.server.name;
|
|
313
|
+
// Extract a display icon from the icons array (first non-dark icon, or first icon)
|
|
314
|
+
const icons = serverMod.server.icons;
|
|
315
|
+
if (Array.isArray(icons) && icons.length > 0) {
|
|
316
|
+
const lightIcon = icons.find(i => !i.theme || i.theme === 'light') ?? icons[0];
|
|
317
|
+
serverDisplayIcon = lightIcon?.src;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
// Non-fatal — simulator will use defaults
|
|
322
|
+
} finally {
|
|
323
|
+
await configLoader.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
285
327
|
// Create and start Vite dev server programmatically
|
|
286
328
|
const server = await createServer({
|
|
287
329
|
root: projectRoot,
|
|
330
|
+
optimizeDeps: {
|
|
331
|
+
// The simulator UI entry (.sunpeak/dev.tsx) imports sunpeak/simulator
|
|
332
|
+
// which pulls in React and the simulator components. Pre-include the
|
|
333
|
+
// dev.tsx entry so its transitive deps are discovered at startup.
|
|
334
|
+
entries: ['.sunpeak/dev.tsx'],
|
|
335
|
+
},
|
|
288
336
|
plugins: [
|
|
289
337
|
react(),
|
|
290
338
|
tailwindcss(),
|
|
291
339
|
sunpeakFaviconPlugin(),
|
|
292
340
|
sunpeakCallToolPlugin(),
|
|
293
341
|
sunpeakDistPlugin(),
|
|
342
|
+
// Inject paint fence responder into all HTML pages served by Vite.
|
|
343
|
+
// When resources are loaded in the cross-origin sandbox proxy's inner
|
|
344
|
+
// iframe, the proxy can't inject scripts (cross-origin). This plugin
|
|
345
|
+
// ensures the fence responder is always present so display mode
|
|
346
|
+
// transitions resolve deterministically.
|
|
347
|
+
{
|
|
348
|
+
name: 'sunpeak-fence-responder',
|
|
349
|
+
transformIndexHtml(html) {
|
|
350
|
+
const fenceScript = `<script>window.addEventListener("message",function(e){if(e.data&&e.data.method==="sunpeak/fence"){var fid=e.data.params&&e.data.params.fenceId;requestAnimationFrame(function(){e.source.postMessage({jsonrpc:"2.0",method:"sunpeak/fence-ack",params:{fenceId:fid}},"*");});}});</script>`;
|
|
351
|
+
return html.replace('</head>', fenceScript + '</head>');
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
// Health endpoint for Playwright webServer readiness check
|
|
355
|
+
{
|
|
356
|
+
name: 'sunpeak-health',
|
|
357
|
+
configureServer(server) {
|
|
358
|
+
server.middlewares.use('/health', (_req, res) => {
|
|
359
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
360
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
},
|
|
294
364
|
],
|
|
295
365
|
define: {
|
|
296
366
|
'__SUNPEAK_PROD_TOOLS__': JSON.stringify(isProdTools),
|
|
297
367
|
'__SUNPEAK_PROD_RESOURCES__': JSON.stringify(isProdResources),
|
|
368
|
+
'__SUNPEAK_SANDBOX_URL__': JSON.stringify(sandbox.url),
|
|
369
|
+
'__SUNPEAK_APP_NAME__': JSON.stringify(serverDisplayName ?? null),
|
|
370
|
+
'__SUNPEAK_APP_ICON__': JSON.stringify(serverDisplayIcon ?? null),
|
|
371
|
+
'__SUNPEAK_DEFAULT_ICON__': JSON.stringify(faviconDataUri),
|
|
298
372
|
},
|
|
299
373
|
resolve: {
|
|
300
374
|
alias: {
|
|
@@ -307,7 +381,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
307
381
|
},
|
|
308
382
|
server: {
|
|
309
383
|
port,
|
|
310
|
-
open
|
|
384
|
+
// Don't auto-open browser when started by Playwright or CI
|
|
385
|
+
open: !process.env.CI && !process.env.SUNPEAK_LIVE_TEST,
|
|
386
|
+
// Allow tunnel hosts (ngrok, cloudflared, etc.) to reach the dev server
|
|
387
|
+
allowedHosts: 'all',
|
|
311
388
|
},
|
|
312
389
|
});
|
|
313
390
|
|
|
@@ -393,7 +470,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
393
470
|
try {
|
|
394
471
|
const mod = await toolLoaderServer.ssrLoadModule(`./${relativePath}`);
|
|
395
472
|
if (typeof mod.default === 'function') {
|
|
396
|
-
toolHandlerMap.set(toolName, { handler: mod.default });
|
|
473
|
+
toolHandlerMap.set(toolName, { handler: mod.default, outputSchema: mod.outputSchema });
|
|
397
474
|
}
|
|
398
475
|
} catch (err) {
|
|
399
476
|
console.warn(`Warning: Could not load handler for tool "${toolName}": ${err.message}`);
|
|
@@ -442,6 +519,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
442
519
|
srcPath,
|
|
443
520
|
resource: resourceMap.get(resourceKey),
|
|
444
521
|
} : {}),
|
|
522
|
+
// Attach output schema from the tool module (if present)
|
|
523
|
+
...(toolHandlerMap.has(toolName) && toolHandlerMap.get(toolName).outputSchema ? {
|
|
524
|
+
outputSchema: toolHandlerMap.get(toolName).outputSchema,
|
|
525
|
+
} : {}),
|
|
445
526
|
// Attach real handler for tools consumed by the MCP server.
|
|
446
527
|
// Backend-only tools (no resource) always need handlers for callServerTool.
|
|
447
528
|
// UI tools only get handlers in --prod-tools mode (otherwise simulation mock data is used).
|
|
@@ -465,12 +546,17 @@ export async function dev(projectRoot = process.cwd(), args = []) {
|
|
|
465
546
|
simulations.push({
|
|
466
547
|
name: `__tool_${toolName}`,
|
|
467
548
|
tool: { name: toolName, ...tool },
|
|
549
|
+
...(handlerInfo?.outputSchema ? { outputSchema: handlerInfo.outputSchema } : {}),
|
|
468
550
|
...(handlerInfo ? { handler: handlerInfo.handler } : {}),
|
|
469
551
|
});
|
|
470
552
|
}
|
|
471
553
|
|
|
472
554
|
// Start MCP server with its own Vite instance for HMR
|
|
473
555
|
if (simulations.length > 0) {
|
|
556
|
+
// Find available ports for the MCP server and HMR WebSocket
|
|
557
|
+
const mcpPort = await getPort(8000);
|
|
558
|
+
const hmrPort = await getPort(Number(process.env.SUNPEAK_HMR_PORT || 24679));
|
|
559
|
+
|
|
474
560
|
console.log(`\nStarting MCP server with ${simulations.length} simulation(s) (Vite HMR)...`);
|
|
475
561
|
|
|
476
562
|
// Virtual entry module plugin for MCP
|
|
@@ -538,7 +624,7 @@ if (import.meta.hot) {
|
|
|
538
624
|
},
|
|
539
625
|
server: {
|
|
540
626
|
middlewareMode: true,
|
|
541
|
-
hmr: { port:
|
|
627
|
+
hmr: { port: hmrPort },
|
|
542
628
|
allowedHosts: true,
|
|
543
629
|
watch: {
|
|
544
630
|
// Only watch files that affect the UI bundle (not JSON, tests, etc.)
|
|
@@ -553,18 +639,40 @@ if (import.meta.hot) {
|
|
|
553
639
|
},
|
|
554
640
|
},
|
|
555
641
|
optimizeDeps: {
|
|
642
|
+
// Pre-scan resource source files so ALL their dependencies are
|
|
643
|
+
// discovered and pre-bundled at startup. Without this, the first
|
|
644
|
+
// resource load discovers new deps (e.g., mapbox-gl, embla-carousel),
|
|
645
|
+
// triggers re-optimization, and reloads all connections — killing
|
|
646
|
+
// any active ChatGPT/Claude iframe connections with ECONNRESET.
|
|
647
|
+
entries: [
|
|
648
|
+
'src/resources/**/*.{ts,tsx}',
|
|
649
|
+
'src/tools/**/*.ts',
|
|
650
|
+
],
|
|
556
651
|
include: ['react', 'react-dom/client'],
|
|
557
652
|
},
|
|
558
653
|
appType: 'custom',
|
|
559
654
|
});
|
|
560
655
|
|
|
561
|
-
|
|
656
|
+
// Load server config from src/server.ts (if present) for server identity
|
|
657
|
+
let serverInfo = undefined;
|
|
658
|
+
if (existsSync(serverEntryPath)) {
|
|
659
|
+
try {
|
|
660
|
+
const serverMod = await toolLoaderServer.ssrLoadModule('./src/server.ts');
|
|
661
|
+
if (serverMod.server && typeof serverMod.server === 'object') {
|
|
662
|
+
serverInfo = serverMod.server;
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
console.warn(`Warning: Could not load server config: ${err.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
562
668
|
|
|
563
669
|
const mcpHandle = runMCPServer({
|
|
564
|
-
name: pkg.name
|
|
565
|
-
version: pkg.version
|
|
670
|
+
name: serverInfo?.name ?? pkg.name ?? 'Sunpeak',
|
|
671
|
+
version: serverInfo?.version ?? pkg.version ?? '0.1.0',
|
|
672
|
+
serverInfo,
|
|
566
673
|
simulations,
|
|
567
|
-
port:
|
|
674
|
+
port: mcpPort,
|
|
675
|
+
hmrPort,
|
|
568
676
|
// In --prod-resources mode, don't pass viteServer so the MCP server serves pre-built HTML.
|
|
569
677
|
// Otherwise, pass it so ChatGPT gets Vite HMR.
|
|
570
678
|
viteServer: isProdResources ? undefined : mcpViteServer,
|
|
@@ -582,6 +690,7 @@ if (import.meta.hot) {
|
|
|
582
690
|
await mcpViteServer.close();
|
|
583
691
|
await toolLoaderServer.close();
|
|
584
692
|
if (loaderServer) await loaderServer.close();
|
|
693
|
+
await sandbox.close();
|
|
585
694
|
await server.close();
|
|
586
695
|
process.exit(0);
|
|
587
696
|
});
|
|
@@ -590,6 +699,7 @@ if (import.meta.hot) {
|
|
|
590
699
|
await mcpViteServer.close();
|
|
591
700
|
await toolLoaderServer.close();
|
|
592
701
|
if (loaderServer) await loaderServer.close();
|
|
702
|
+
await sandbox.close();
|
|
593
703
|
await server.close();
|
|
594
704
|
process.exit(0);
|
|
595
705
|
});
|
|
@@ -598,6 +708,7 @@ if (import.meta.hot) {
|
|
|
598
708
|
process.on('SIGINT', async () => {
|
|
599
709
|
await toolLoaderServer.close();
|
|
600
710
|
if (loaderServer) await loaderServer.close();
|
|
711
|
+
await sandbox.close();
|
|
601
712
|
await server.close();
|
|
602
713
|
process.exit(0);
|
|
603
714
|
});
|
|
@@ -605,6 +716,7 @@ if (import.meta.hot) {
|
|
|
605
716
|
process.on('SIGTERM', async () => {
|
|
606
717
|
await toolLoaderServer.close();
|
|
607
718
|
if (loaderServer) await loaderServer.close();
|
|
719
|
+
await sandbox.close();
|
|
608
720
|
await server.close();
|
|
609
721
|
process.exit(0);
|
|
610
722
|
});
|
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.
|
|
@@ -124,6 +125,7 @@ export async function start(projectRoot = process.cwd(), args = []) {
|
|
|
124
125
|
const mod = await import(toolPath);
|
|
125
126
|
const tool = mod.tool;
|
|
126
127
|
const schema = mod.schema;
|
|
128
|
+
const outputSchema = mod.outputSchema;
|
|
127
129
|
const handler = mod.default;
|
|
128
130
|
|
|
129
131
|
if (!tool) {
|
|
@@ -135,7 +137,7 @@ export async function start(projectRoot = process.cwd(), args = []) {
|
|
|
135
137
|
continue;
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
tools.push({ name: toolName, tool, schema, handler });
|
|
140
|
+
tools.push({ name: toolName, tool, schema, outputSchema, handler });
|
|
139
141
|
} catch (err) {
|
|
140
142
|
console.error(`Failed to load tool ${toolName}:`, err.message);
|
|
141
143
|
process.exit(1);
|
|
@@ -183,10 +185,13 @@ export async function start(projectRoot = process.cwd(), args = []) {
|
|
|
183
185
|
const name = serverConfig.name ?? pkg.name ?? 'sunpeak-app';
|
|
184
186
|
const version = serverConfig.version ?? pkg.version ?? '0.1.0';
|
|
185
187
|
|
|
188
|
+
// Find an available port (prefer the configured one)
|
|
189
|
+
port = await getPort(port);
|
|
190
|
+
|
|
186
191
|
console.log(`\nStarting ${name} v${version} on ${host}:${port}...`);
|
|
187
192
|
|
|
188
193
|
startProductionHttpServer(
|
|
189
|
-
{ name, version, tools, resources, auth },
|
|
194
|
+
{ name, version, serverInfo: serverConfig, tools, resources, auth },
|
|
190
195
|
{ port, host }
|
|
191
196
|
);
|
|
192
197
|
}
|
|
@@ -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,161 @@
|
|
|
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
|
+
* Get the Chrome profile subdirectory name to copy from.
|
|
19
|
+
* Defaults to "Default" but can be overridden via SUNPEAK_CHROME_PROFILE env var
|
|
20
|
+
* (e.g., "Profile 5" for a non-default Chrome profile).
|
|
21
|
+
*/
|
|
22
|
+
function getProfileSubdir() {
|
|
23
|
+
return process.env.SUNPEAK_CHROME_PROFILE || 'Default';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subdirectories/files to copy from the browser profile.
|
|
28
|
+
* These contain session cookies and local storage — enough for authenticated browsing.
|
|
29
|
+
* Copying only these keeps the operation fast (<2s) vs copying the full profile (500MB+).
|
|
30
|
+
*
|
|
31
|
+
* The profile subdirectory (Default, Profile 1, Profile 5, etc.) is determined by
|
|
32
|
+
* getProfileSubdir(). Set SUNPEAK_CHROME_PROFILE env var to use a non-default profile.
|
|
33
|
+
*/
|
|
34
|
+
function getEssentialPaths() {
|
|
35
|
+
const profile = getProfileSubdir();
|
|
36
|
+
return [
|
|
37
|
+
`${profile}/Cookies`,
|
|
38
|
+
`${profile}/Cookies-journal`,
|
|
39
|
+
`${profile}/Local Storage`,
|
|
40
|
+
`${profile}/Session Storage`,
|
|
41
|
+
`${profile}/IndexedDB`,
|
|
42
|
+
`${profile}/Login Data`,
|
|
43
|
+
`${profile}/Login Data-journal`,
|
|
44
|
+
`${profile}/Preferences`,
|
|
45
|
+
`${profile}/Secure Preferences`,
|
|
46
|
+
`${profile}/Web Data`,
|
|
47
|
+
'Local State',
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect which browser the user has installed.
|
|
53
|
+
* Returns the first available browser from the preference order.
|
|
54
|
+
*/
|
|
55
|
+
export function detectBrowser() {
|
|
56
|
+
const order = ['chrome', 'arc', 'brave', 'edge'];
|
|
57
|
+
for (const browser of order) {
|
|
58
|
+
if (existsSync(BROWSER_PROFILES[browser])) {
|
|
59
|
+
return browser;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Copy essential browser profile data to a temp directory.
|
|
67
|
+
* Returns the temp directory path.
|
|
68
|
+
*/
|
|
69
|
+
function copyProfile(browser) {
|
|
70
|
+
const profileDir = BROWSER_PROFILES[browser];
|
|
71
|
+
if (!profileDir || !existsSync(profileDir)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Browser profile not found for "${browser}" at ${profileDir || '(unknown)'}.\n` +
|
|
74
|
+
`Available browsers: ${Object.entries(BROWSER_PROFILES)
|
|
75
|
+
.filter(([, p]) => existsSync(p))
|
|
76
|
+
.map(([name]) => name)
|
|
77
|
+
.join(', ') || 'none detected'}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const profileSubdir = getProfileSubdir();
|
|
82
|
+
const essentialPaths = getEssentialPaths();
|
|
83
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'sunpeak-live-'));
|
|
84
|
+
|
|
85
|
+
for (const relativePath of essentialPaths) {
|
|
86
|
+
const src = join(profileDir, relativePath);
|
|
87
|
+
if (!existsSync(src)) continue;
|
|
88
|
+
|
|
89
|
+
// Remap the source profile subdir to "Default" in the temp dir.
|
|
90
|
+
// Playwright's launchPersistentContext always uses "Default" as the profile name.
|
|
91
|
+
const destRelative = relativePath.startsWith(profileSubdir + '/')
|
|
92
|
+
? 'Default' + relativePath.slice(profileSubdir.length)
|
|
93
|
+
: relativePath;
|
|
94
|
+
const dest = join(tempDir, destRelative);
|
|
95
|
+
try {
|
|
96
|
+
cpSync(src, dest, { recursive: true });
|
|
97
|
+
} catch {
|
|
98
|
+
// Some files may be locked; skip silently
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Ensure Default directory exists even if no essential files were copied.
|
|
103
|
+
const defaultDir = join(tempDir, 'Default');
|
|
104
|
+
if (!existsSync(defaultDir)) {
|
|
105
|
+
mkdirSync(defaultDir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (profileSubdir !== 'Default') {
|
|
109
|
+
console.log(`Using Chrome profile: ${profileSubdir}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return tempDir;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Launch a Playwright Chromium browser authenticated with the user's real browser session.
|
|
117
|
+
*
|
|
118
|
+
* Copies the user's browser profile to a temp directory, then launches Playwright
|
|
119
|
+
* with that profile. The returned cleanup function removes the temp directory.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} options
|
|
122
|
+
* @param {string} [options.browser='chrome'] - Browser to copy profile from
|
|
123
|
+
* @param {boolean} [options.headless=false] - Run headless (usually false for live tests)
|
|
124
|
+
* @returns {Promise<{ context: BrowserContext, page: Page, cleanup: () => void }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function launchAuthenticatedBrowser({ browser = 'chrome', headless = false } = {}) {
|
|
127
|
+
// Resolve chromium from @playwright/test (which re-exports it) rather than
|
|
128
|
+
// the standalone 'playwright' package — pnpm doesn't hoist transitive deps
|
|
129
|
+
// so 'playwright' isn't directly resolvable from user projects.
|
|
130
|
+
let chromium;
|
|
131
|
+
try {
|
|
132
|
+
({ chromium } = await import('playwright'));
|
|
133
|
+
} catch {
|
|
134
|
+
// Fallback: resolve via @playwright/test which is always a direct dependency
|
|
135
|
+
const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
|
|
136
|
+
const { resolvePlaywright } = await import('./utils.mjs');
|
|
137
|
+
({ chromium } = resolvePlaywright(projectRoot));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tempDir = copyProfile(browser);
|
|
141
|
+
|
|
142
|
+
const context = await chromium.launchPersistentContext(tempDir, {
|
|
143
|
+
headless,
|
|
144
|
+
args: ANTI_BOT_ARGS,
|
|
145
|
+
viewport: { width: 1280, height: 900 },
|
|
146
|
+
ignoreDefaultArgs: ['--enable-automation'],
|
|
147
|
+
userAgent: CHROME_USER_AGENT,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const page = context.pages()[0] || await context.newPage();
|
|
151
|
+
|
|
152
|
+
const cleanup = () => {
|
|
153
|
+
try {
|
|
154
|
+
rimrafSync(tempDir);
|
|
155
|
+
} catch {
|
|
156
|
+
// Best effort cleanup
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return { context, page, cleanup };
|
|
161
|
+
}
|
|
@@ -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 };
|