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.
Files changed (90) hide show
  1. package/README.md +4 -3
  2. package/bin/commands/dev.mjs +120 -8
  3. package/bin/commands/new.mjs +6 -2
  4. package/bin/commands/start.mjs +7 -2
  5. package/bin/lib/get-port.mjs +60 -0
  6. package/bin/lib/live/browser-auth.mjs +161 -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 +158 -0
  13. package/bin/lib/live/host-fixtures.mjs +61 -0
  14. package/bin/lib/live/host-page.mjs +294 -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/lib/sandbox-server.mjs +304 -0
  24. package/bin/sunpeak.js +1 -1
  25. package/dist/chatgpt/chatgpt-conversation.d.ts +3 -7
  26. package/dist/chatgpt/globals.css +18 -0
  27. package/dist/chatgpt/index.cjs +1 -1
  28. package/dist/chatgpt/index.js +1 -1
  29. package/dist/claude/claude-conversation.d.ts +3 -2
  30. package/dist/claude/index.cjs +1 -1
  31. package/dist/claude/index.js +1 -1
  32. package/dist/{index-bKBBCBK6.cjs → index-BEWVLFfB.cjs} +2 -2
  33. package/dist/index-BEWVLFfB.cjs.map +1 -0
  34. package/dist/{index-CX6Z4bED.js → index-C6XYFOmh.js} +2 -2
  35. package/dist/index-C6XYFOmh.js.map +1 -0
  36. package/dist/{index-CKabCJyV.cjs → index-D0FsXP3Y.cjs} +2 -2
  37. package/dist/index-D0FsXP3Y.cjs.map +1 -0
  38. package/dist/{index-B4aC3vjH.js → index-Rg7SWjvl.js} +2 -2
  39. package/dist/index-Rg7SWjvl.js.map +1 -0
  40. package/dist/index.cjs +13 -5
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.js +13 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/mcp/favicon.d.ts +3 -1
  45. package/dist/mcp/index.cjs +90 -49
  46. package/dist/mcp/index.cjs.map +1 -1
  47. package/dist/mcp/index.d.ts +2 -2
  48. package/dist/mcp/index.js +90 -49
  49. package/dist/mcp/index.js.map +1 -1
  50. package/dist/mcp/production-server.d.ts +7 -1
  51. package/dist/mcp/types.d.ts +32 -1
  52. package/dist/simulator/hosts.d.ts +11 -2
  53. package/dist/simulator/iframe-resource.d.ts +8 -1
  54. package/dist/simulator/index.cjs +1 -1
  55. package/dist/simulator/index.js +1 -1
  56. package/dist/simulator/mcp-app-host.d.ts +17 -0
  57. package/dist/simulator/sandbox-proxy.d.ts +38 -0
  58. package/dist/simulator/simple-sidebar.d.ts +3 -1
  59. package/dist/simulator/simulator.d.ts +7 -1
  60. package/dist/simulator/use-simulator-state.d.ts +2 -4
  61. package/dist/{simulator-D8t-r7HH.js → simulator-B-CrMHVs.js} +504 -192
  62. package/dist/simulator-B-CrMHVs.js.map +1 -0
  63. package/dist/{simulator-FFNttkqL.cjs → simulator-Gc6n_fT4.cjs} +503 -191
  64. package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
  65. package/dist/style.css +18 -0
  66. package/package.json +25 -1
  67. package/template/.sunpeak/dev.tsx +9 -3
  68. package/template/README.md +24 -2
  69. package/template/_gitignore +1 -0
  70. package/template/package.json +3 -2
  71. package/template/playwright.config.ts +34 -6
  72. package/template/src/server.ts +16 -2
  73. package/template/src/tools/show-albums.ts +17 -0
  74. package/template/tests/e2e/albums.spec.ts +37 -5
  75. package/template/tests/e2e/carousel.spec.ts +6 -6
  76. package/template/tests/e2e/global-setup.ts +6 -21
  77. package/template/tests/e2e/map.spec.ts +11 -11
  78. package/template/tests/e2e/review.spec.ts +24 -24
  79. package/template/tests/live/albums.spec.ts +53 -0
  80. package/template/tests/live/carousel.spec.ts +52 -0
  81. package/template/tests/live/map.spec.ts +31 -0
  82. package/template/tests/live/playwright.config.ts +3 -0
  83. package/template/tests/live/review.spec.ts +54 -0
  84. package/template/vitest.config.ts +1 -1
  85. package/dist/index-B4aC3vjH.js.map +0 -1
  86. package/dist/index-CKabCJyV.cjs.map +0 -1
  87. package/dist/index-CX6Z4bED.js.map +0 -1
  88. package/dist/index-bKBBCBK6.cjs.map +0 -1
  89. package/dist/simulator-D8t-r7HH.js.map +0 -1
  90. 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,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: true,
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: Number(process.env.SUNPEAK_HMR_PORT || 24679) },
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
- const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
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 || 'Sunpeak',
565
- version: pkg.version || '0.1.0',
670
+ name: serverInfo?.name ?? pkg.name ?? 'Sunpeak',
671
+ version: serverInfo?.version ?? pkg.version ?? '0.1.0',
672
+ serverInfo,
566
673
  simulations,
567
- port: 8000,
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
  });
@@ -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.
@@ -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 };