sunpeak 0.16.24 → 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 (63) hide show
  1. package/bin/commands/dev.mjs +98 -5
  2. package/bin/commands/start.mjs +3 -2
  3. package/bin/lib/live/browser-auth.mjs +53 -17
  4. package/bin/lib/live/global-setup.mjs +107 -99
  5. package/bin/lib/live/host-page.mjs +63 -11
  6. package/bin/lib/live/live-config.mjs +1 -1
  7. package/bin/lib/sandbox-server.mjs +304 -0
  8. package/dist/chatgpt/chatgpt-conversation.d.ts +3 -7
  9. package/dist/chatgpt/globals.css +18 -0
  10. package/dist/chatgpt/index.cjs +1 -1
  11. package/dist/chatgpt/index.js +1 -1
  12. package/dist/claude/claude-conversation.d.ts +3 -2
  13. package/dist/claude/index.cjs +1 -1
  14. package/dist/claude/index.js +1 -1
  15. package/dist/{index-SfudQ9Y_.cjs → index-BEWVLFfB.cjs} +2 -2
  16. package/dist/index-BEWVLFfB.cjs.map +1 -0
  17. package/dist/{index-B7Qw3Vhh.js → index-C6XYFOmh.js} +2 -2
  18. package/dist/index-C6XYFOmh.js.map +1 -0
  19. package/dist/{index-XKHXfBiD.cjs → index-D0FsXP3Y.cjs} +2 -2
  20. package/dist/index-D0FsXP3Y.cjs.map +1 -0
  21. package/dist/{index-BEHP_bM8.js → index-Rg7SWjvl.js} +2 -2
  22. package/dist/index-Rg7SWjvl.js.map +1 -0
  23. package/dist/index.cjs +4 -4
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.js +4 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp/favicon.d.ts +3 -1
  28. package/dist/mcp/index.cjs +52 -36
  29. package/dist/mcp/index.cjs.map +1 -1
  30. package/dist/mcp/index.d.ts +2 -2
  31. package/dist/mcp/index.js +52 -36
  32. package/dist/mcp/index.js.map +1 -1
  33. package/dist/mcp/production-server.d.ts +7 -1
  34. package/dist/mcp/types.d.ts +30 -1
  35. package/dist/simulator/hosts.d.ts +11 -2
  36. package/dist/simulator/iframe-resource.d.ts +8 -1
  37. package/dist/simulator/index.cjs +1 -1
  38. package/dist/simulator/index.js +1 -1
  39. package/dist/simulator/mcp-app-host.d.ts +17 -0
  40. package/dist/simulator/sandbox-proxy.d.ts +38 -0
  41. package/dist/simulator/simulator.d.ts +7 -1
  42. package/dist/simulator/use-simulator-state.d.ts +2 -4
  43. package/dist/{simulator-BCq2iOT-.js → simulator-B-CrMHVs.js} +459 -187
  44. package/dist/simulator-B-CrMHVs.js.map +1 -0
  45. package/dist/{simulator-DRUsm6IZ.cjs → simulator-Gc6n_fT4.cjs} +458 -186
  46. package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
  47. package/dist/style.css +18 -0
  48. package/package.json +1 -1
  49. package/template/.sunpeak/dev.tsx +9 -3
  50. package/template/playwright.config.ts +10 -5
  51. package/template/src/server.ts +16 -2
  52. package/template/src/tools/show-albums.ts +17 -0
  53. package/template/tests/e2e/albums.spec.ts +37 -5
  54. package/template/tests/e2e/carousel.spec.ts +6 -6
  55. package/template/tests/e2e/global-setup.ts +6 -21
  56. package/template/tests/e2e/map.spec.ts +11 -11
  57. package/template/tests/e2e/review.spec.ts +24 -24
  58. package/dist/index-B7Qw3Vhh.js.map +0 -1
  59. package/dist/index-BEHP_bM8.js.map +0 -1
  60. package/dist/index-SfudQ9Y_.cjs.map +0 -1
  61. package/dist/index-XKHXfBiD.cjs.map +0 -1
  62. package/dist/simulator-BCq2iOT-.js.map +0 -1
  63. package/dist/simulator-DRUsm6IZ.cjs.map +0 -1
@@ -7,6 +7,7 @@ import { createRequire } from 'module';
7
7
  import { pathToFileURL } from 'url';
8
8
  import { spawn } from 'child_process';
9
9
  import { getPort } from '../lib/get-port.mjs';
10
+ import { startSandboxServer } from '../lib/sandbox-server.mjs';
10
11
 
11
12
  /**
12
13
  * Import a module from the project's node_modules using ESM resolution
@@ -187,7 +188,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
187
188
  sunpeakMcp = await import(pathToFileURL(join(sunpeakBase, 'dist/mcp/index.js')).href);
188
189
  sunpeakDiscovery = await import(pathToFileURL(join(sunpeakBase, 'dist/lib/discovery-cli.js')).href);
189
190
  }
190
- const { FAVICON_BUFFER: faviconBuffer, runMCPServer } = sunpeakMcp;
191
+ const { FAVICON_BUFFER: faviconBuffer, FAVICON_DATA_URI: faviconDataUri, runMCPServer } = sunpeakMcp;
191
192
  const { findResourceDirs, findSimulationFilesFlat, findToolFiles, extractResourceExport, extractToolExport } = sunpeakDiscovery;
192
193
 
193
194
  // Vite plugin to serve the sunpeak favicon
@@ -283,15 +284,73 @@ export async function dev(projectRoot = process.cwd(), args = []) {
283
284
  },
284
285
  });
285
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
+
286
327
  // Create and start Vite dev server programmatically
287
328
  const server = await createServer({
288
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
+ },
289
336
  plugins: [
290
337
  react(),
291
338
  tailwindcss(),
292
339
  sunpeakFaviconPlugin(),
293
340
  sunpeakCallToolPlugin(),
294
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
+ },
295
354
  // Health endpoint for Playwright webServer readiness check
296
355
  {
297
356
  name: 'sunpeak-health',
@@ -306,6 +365,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
306
365
  define: {
307
366
  '__SUNPEAK_PROD_TOOLS__': JSON.stringify(isProdTools),
308
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),
309
372
  },
310
373
  resolve: {
311
374
  alias: {
@@ -407,7 +470,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
407
470
  try {
408
471
  const mod = await toolLoaderServer.ssrLoadModule(`./${relativePath}`);
409
472
  if (typeof mod.default === 'function') {
410
- toolHandlerMap.set(toolName, { handler: mod.default });
473
+ toolHandlerMap.set(toolName, { handler: mod.default, outputSchema: mod.outputSchema });
411
474
  }
412
475
  } catch (err) {
413
476
  console.warn(`Warning: Could not load handler for tool "${toolName}": ${err.message}`);
@@ -456,6 +519,10 @@ export async function dev(projectRoot = process.cwd(), args = []) {
456
519
  srcPath,
457
520
  resource: resourceMap.get(resourceKey),
458
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
+ } : {}),
459
526
  // Attach real handler for tools consumed by the MCP server.
460
527
  // Backend-only tools (no resource) always need handlers for callServerTool.
461
528
  // UI tools only get handlers in --prod-tools mode (otherwise simulation mock data is used).
@@ -479,6 +546,7 @@ export async function dev(projectRoot = process.cwd(), args = []) {
479
546
  simulations.push({
480
547
  name: `__tool_${toolName}`,
481
548
  tool: { name: toolName, ...tool },
549
+ ...(handlerInfo?.outputSchema ? { outputSchema: handlerInfo.outputSchema } : {}),
482
550
  ...(handlerInfo ? { handler: handlerInfo.handler } : {}),
483
551
  });
484
552
  }
@@ -571,16 +639,37 @@ if (import.meta.hot) {
571
639
  },
572
640
  },
573
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
+ ],
574
651
  include: ['react', 'react-dom/client'],
575
652
  },
576
653
  appType: 'custom',
577
654
  });
578
655
 
579
- 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
+ }
580
668
 
581
669
  const mcpHandle = runMCPServer({
582
- name: pkg.name || 'Sunpeak',
583
- version: pkg.version || '0.1.0',
670
+ name: serverInfo?.name ?? pkg.name ?? 'Sunpeak',
671
+ version: serverInfo?.version ?? pkg.version ?? '0.1.0',
672
+ serverInfo,
584
673
  simulations,
585
674
  port: mcpPort,
586
675
  hmrPort,
@@ -601,6 +690,7 @@ if (import.meta.hot) {
601
690
  await mcpViteServer.close();
602
691
  await toolLoaderServer.close();
603
692
  if (loaderServer) await loaderServer.close();
693
+ await sandbox.close();
604
694
  await server.close();
605
695
  process.exit(0);
606
696
  });
@@ -609,6 +699,7 @@ if (import.meta.hot) {
609
699
  await mcpViteServer.close();
610
700
  await toolLoaderServer.close();
611
701
  if (loaderServer) await loaderServer.close();
702
+ await sandbox.close();
612
703
  await server.close();
613
704
  process.exit(0);
614
705
  });
@@ -617,6 +708,7 @@ if (import.meta.hot) {
617
708
  process.on('SIGINT', async () => {
618
709
  await toolLoaderServer.close();
619
710
  if (loaderServer) await loaderServer.close();
711
+ await sandbox.close();
620
712
  await server.close();
621
713
  process.exit(0);
622
714
  });
@@ -624,6 +716,7 @@ if (import.meta.hot) {
624
716
  process.on('SIGTERM', async () => {
625
717
  await toolLoaderServer.close();
626
718
  if (loaderServer) await loaderServer.close();
719
+ await sandbox.close();
627
720
  await server.close();
628
721
  process.exit(0);
629
722
  });
@@ -125,6 +125,7 @@ export async function start(projectRoot = process.cwd(), args = []) {
125
125
  const mod = await import(toolPath);
126
126
  const tool = mod.tool;
127
127
  const schema = mod.schema;
128
+ const outputSchema = mod.outputSchema;
128
129
  const handler = mod.default;
129
130
 
130
131
  if (!tool) {
@@ -136,7 +137,7 @@ export async function start(projectRoot = process.cwd(), args = []) {
136
137
  continue;
137
138
  }
138
139
 
139
- tools.push({ name: toolName, tool, schema, handler });
140
+ tools.push({ name: toolName, tool, schema, outputSchema, handler });
140
141
  } catch (err) {
141
142
  console.error(`Failed to load tool ${toolName}:`, err.message);
142
143
  process.exit(1);
@@ -190,7 +191,7 @@ export async function start(projectRoot = process.cwd(), args = []) {
190
191
  console.log(`\nStarting ${name} v${version} on ${host}:${port}...`);
191
192
 
192
193
  startProductionHttpServer(
193
- { name, version, tools, resources, auth },
194
+ { name, version, serverInfo: serverConfig, tools, resources, auth },
194
195
  { port, host }
195
196
  );
196
197
  }
@@ -14,24 +14,39 @@ const BROWSER_PROFILES = {
14
14
  edge: join(homedir(), 'Library/Application Support/Microsoft Edge'),
15
15
  };
16
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
+
17
26
  /**
18
27
  * Subdirectories/files to copy from the browser profile.
19
28
  * These contain session cookies and local storage — enough for authenticated browsing.
20
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.
21
33
  */
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
- ];
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
+ }
35
50
 
36
51
  /**
37
52
  * Detect which browser the user has installed.
@@ -63,13 +78,20 @@ function copyProfile(browser) {
63
78
  );
64
79
  }
65
80
 
81
+ const profileSubdir = getProfileSubdir();
82
+ const essentialPaths = getEssentialPaths();
66
83
  const tempDir = mkdtempSync(join(tmpdir(), 'sunpeak-live-'));
67
84
 
68
- for (const relativePath of ESSENTIAL_PATHS) {
85
+ for (const relativePath of essentialPaths) {
69
86
  const src = join(profileDir, relativePath);
70
87
  if (!existsSync(src)) continue;
71
88
 
72
- const dest = join(tempDir, relativePath);
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);
73
95
  try {
74
96
  cpSync(src, dest, { recursive: true });
75
97
  } catch {
@@ -78,12 +100,15 @@ function copyProfile(browser) {
78
100
  }
79
101
 
80
102
  // Ensure Default directory exists even if no essential files were copied.
81
- // Use an allowlist to avoid copying large cache/media directories.
82
103
  const defaultDir = join(tempDir, 'Default');
83
104
  if (!existsSync(defaultDir)) {
84
105
  mkdirSync(defaultDir, { recursive: true });
85
106
  }
86
107
 
108
+ if (profileSubdir !== 'Default') {
109
+ console.log(`Using Chrome profile: ${profileSubdir}`);
110
+ }
111
+
87
112
  return tempDir;
88
113
  }
89
114
 
@@ -99,7 +124,18 @@ function copyProfile(browser) {
99
124
  * @returns {Promise<{ context: BrowserContext, page: Page, cleanup: () => void }>}
100
125
  */
101
126
  export async function launchAuthenticatedBrowser({ browser = 'chrome', headless = false } = {}) {
102
- const { chromium } = await import('playwright');
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
+ }
103
139
 
104
140
  const tempDir = copyProfile(browser);
105
141
 
@@ -2,26 +2,31 @@
2
2
  * Global setup for live tests.
3
3
  *
4
4
  * Runs exactly once before all workers. Two responsibilities:
5
- * 1. Authenticate — import cookies from the user's real browser or open a
6
- * manual login window. Session state is saved to disk for 24 hours.
5
+ * 1. Authenticate — launch a browser, verify login, wait for user if needed.
7
6
  * 2. Refresh MCP server — navigate to host settings and click Refresh so
8
7
  * all workers start with pre-loaded resources.
9
8
  *
9
+ * Auth approach:
10
+ * - Opens a browser with storageState if a fresh auth file exists (<24h).
11
+ * - Checks that we're truly logged in (profile button visible AND no "Log in" buttons).
12
+ * - If not logged in, prints a clear message and waits for the user to log in
13
+ * in the open browser window (up to 5 minutes).
14
+ * - Saves storageState after successful login for future runs.
15
+ * - The same browser session is reused for MCP refresh so Cloudflare's
16
+ * HttpOnly cookies (which storageState can't capture) remain valid.
17
+ *
10
18
  * This file is referenced by the Playwright config created by defineLiveConfig().
11
19
  * The auth file path is passed via SUNPEAK_AUTH_FILE env var.
12
20
  */
13
- import { existsSync, mkdirSync, statSync } from 'fs';
21
+ import { existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
14
22
  import { dirname } from 'path';
15
23
  import { ANTI_BOT_ARGS, CHROME_USER_AGENT, resolvePlaywright, getAppName } from './utils.mjs';
16
- import { launchAuthenticatedBrowser, detectBrowser } from './browser-auth.mjs';
17
24
  import { ChatGPTPage, CHATGPT_SELECTORS, CHATGPT_URLS } from './chatgpt-page.mjs';
18
25
 
19
26
  /** Auth state expires after 24 hours — ChatGPT session cookies are short-lived. */
20
27
  const MAX_AGE_MS = 24 * 60 * 60 * 1000;
21
28
 
22
- /** Reuse selectors and URLs from the canonical ChatGPT page object. */
23
29
  const CHATGPT_URL = CHATGPT_URLS.base;
24
- const LOGIN_SELECTOR = CHATGPT_SELECTORS.loggedInIndicator;
25
30
 
26
31
  function isAuthFresh(authFile) {
27
32
  if (!existsSync(authFile)) return false;
@@ -30,121 +35,124 @@ function isAuthFresh(authFile) {
30
35
  }
31
36
 
32
37
  /**
33
- * Refresh the MCP server connection in ChatGPT settings.
34
- * Opens a browser with the saved auth, navigates to settings, clicks Refresh,
35
- * then closes. Runs once so parallel test workers don't each refresh.
38
+ * Check if we're truly logged into ChatGPT.
39
+ * Must have the profile button AND must NOT have any "Log in" buttons.
40
+ * The logged-out ChatGPT page can show some UI elements that look like
41
+ * a logged-in state, so checking just the profile button isn't enough.
36
42
  */
37
- async function refreshMcpServer(authFile) {
43
+ async function isFullyLoggedIn(page) {
44
+ const hasProfile = await page
45
+ .locator(CHATGPT_SELECTORS.loggedInIndicator)
46
+ .first()
47
+ .isVisible()
48
+ .catch(() => false);
49
+
50
+ if (!hasProfile) return false;
51
+
52
+ // Must NOT have "Log in" buttons — these appear on the logged-out page
53
+ const hasLoginButton = await page
54
+ .locator(CHATGPT_SELECTORS.loginPage)
55
+ .first()
56
+ .isVisible()
57
+ .catch(() => false);
58
+
59
+ return !hasLoginButton;
60
+ }
61
+
62
+ export default async function globalSetup() {
63
+ const authFile = process.env.SUNPEAK_AUTH_FILE;
64
+
65
+ if (process.env.SUNPEAK_STORAGE_STATE) {
66
+ return;
67
+ }
68
+
69
+ if (!authFile) {
70
+ console.warn('SUNPEAK_AUTH_FILE not set — skipping auth setup.');
71
+ return;
72
+ }
73
+
38
74
  const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
39
75
  const appName = getAppName(projectRoot);
40
76
  const { chromium } = resolvePlaywright(projectRoot);
41
77
 
78
+ // Launch a browser. Use saved storageState if fresh, otherwise start clean.
79
+ const hasFreshAuth = isAuthFresh(authFile);
42
80
  const browser = await chromium.launch({
43
81
  headless: false,
44
82
  args: ANTI_BOT_ARGS,
45
83
  });
46
84
  const context = await browser.newContext({
47
85
  userAgent: CHROME_USER_AGENT,
48
- storageState: authFile,
86
+ ...(hasFreshAuth ? { storageState: authFile } : {}),
49
87
  });
50
88
  const page = await context.newPage();
51
89
 
52
90
  try {
53
- const hostPage = new ChatGPTPage(page);
54
- await hostPage.refreshMcpServer({ appName });
55
- console.log('MCP server refreshed.');
56
- } finally {
57
- await browser.close();
58
- }
59
- }
91
+ await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
60
92
 
61
- export default async function globalSetup() {
62
- // If storage state was provided externally, skip auth but still refresh.
63
- const authFile = process.env.SUNPEAK_AUTH_FILE;
93
+ // Wait for page to settle — Cloudflare challenge or ChatGPT UI loading
94
+ await page.waitForTimeout(5_000);
64
95
 
65
- if (!process.env.SUNPEAK_STORAGE_STATE) {
66
- if (!authFile) {
67
- console.warn('SUNPEAK_AUTH_FILE not set — skipping auth setup.');
68
- return;
69
- }
70
-
71
- if (!isAuthFresh(authFile)) {
72
- await authenticate(authFile);
73
- }
74
- }
75
-
76
- // Refresh MCP server connection so all workers start with pre-loaded resources.
77
- const resolvedAuth = process.env.SUNPEAK_STORAGE_STATE || authFile;
78
- if (resolvedAuth && existsSync(resolvedAuth)) {
79
- await refreshMcpServer(resolvedAuth);
80
- }
81
- }
96
+ // Check if truly logged in (profile button visible, no "Log in" buttons)
97
+ let loggedIn = await isFullyLoggedIn(page);
82
98
 
83
- /**
84
- * Authenticate by importing cookies from the user's browser or manual login.
85
- */
86
- async function authenticate(authFile) {
87
- // Try importing cookies from the user's real browser profile.
88
- const browserName = process.env.SUNPEAK_LIVE_BROWSER || detectBrowser();
89
- if (browserName) {
90
- let auth;
91
- try {
92
- auth = await launchAuthenticatedBrowser({ browser: browserName, headless: false });
93
- const page = auth.page;
94
-
95
- await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
96
-
97
- const loggedIn = await page
98
- .locator(LOGIN_SELECTOR)
99
- .first()
100
- .waitFor({ timeout: 15_000 })
101
- .then(() => true)
102
- .catch(() => false);
103
-
104
- if (loggedIn) {
105
- mkdirSync(dirname(authFile), { recursive: true });
106
- await auth.context.storageState({ path: authFile });
107
- console.log(`Session imported from ${browserName} browser.`);
99
+ if (loggedIn) {
100
+ console.log('Authenticated (from saved session).');
101
+ } else {
102
+ // If we loaded a stale auth file that didn't work, delete it
103
+ if (hasFreshAuth) {
104
+ try { unlinkSync(authFile); } catch {}
108
105
  }
109
106
 
110
- await auth.context.close();
111
- auth.cleanup();
107
+ console.log(
108
+ `\n` +
109
+ `╔══════════════════════════════════════════════════════════════╗\n` +
110
+ `║ Please log in to ChatGPT ║\n` +
111
+ `║ ║\n` +
112
+ `║ A browser window has opened at chatgpt.com. ║\n` +
113
+ `║ Log in and wait for the chat to load. ║\n` +
114
+ `║ ║\n` +
115
+ `║ Waiting up to 5 minutes... ║\n` +
116
+ `╚══════════════════════════════════════════════════════════════╝\n`
117
+ );
118
+
119
+ // Poll until truly logged in
120
+ const maxWait = 300_000; // 5 minutes
121
+ const pollInterval = 3_000;
122
+ const start = Date.now();
123
+
124
+ while (Date.now() - start < maxWait) {
125
+ loggedIn = await isFullyLoggedIn(page);
126
+ if (loggedIn) break;
127
+ await page.waitForTimeout(pollInterval);
128
+ }
112
129
 
113
- if (loggedIn) return;
114
- // Not logged in — fall through to manual login.
115
- } catch {
116
- // Profile copy failed clean up and fall through to manual login.
117
- if (auth) {
118
- try { await auth.context.close(); } catch {}
119
- auth.cleanup();
130
+ if (!loggedIn) {
131
+ throw new Error(
132
+ 'Login timed out after 5 minutes.\n' +
133
+ 'Please log in to chatgpt.com in the browser window that opened.\n' +
134
+ 'If the session expired, delete the .auth/ directory and try again.'
135
+ );
120
136
  }
137
+ console.log('Logged in!');
121
138
  }
122
- }
123
-
124
- // Fallback: open a bare browser for the user to log in manually.
125
- console.log('\nNo saved ChatGPT session found (or session expired).');
126
- console.log('Opening browser — please log in to chatgpt.com.\n');
127
-
128
- const projectRoot = process.env.SUNPEAK_PROJECT_ROOT || process.cwd();
129
- const { chromium } = resolvePlaywright(projectRoot);
130
-
131
- const browser = await chromium.launch({
132
- headless: false,
133
- args: ANTI_BOT_ARGS,
134
- });
135
139
 
136
- const context = await browser.newContext({
137
- userAgent: CHROME_USER_AGENT,
138
- });
139
-
140
- const page = await context.newPage();
141
- await page.goto(CHATGPT_URL, { waitUntil: 'domcontentloaded' });
142
-
143
- console.log('Waiting for login... (this will timeout after 5 minutes)\n');
144
- await page.locator(LOGIN_SELECTOR).first().waitFor({ timeout: 300_000 });
145
- console.log('Logged in! Saving session...\n');
146
-
147
- mkdirSync(dirname(authFile), { recursive: true });
148
- await context.storageState({ path: authFile });
149
- await browser.close();
140
+ // Save session for future runs (best effort — HttpOnly cookies won't be captured).
141
+ mkdirSync(dirname(authFile), { recursive: true });
142
+ await context.storageState({ path: authFile });
143
+ console.log('Session saved.\n');
144
+
145
+ // Refresh MCP server in the SAME browser session.
146
+ // This is critical — Cloudflare's cf_clearance cookie is HttpOnly and
147
+ // won't be in the saved storageState. By refreshing here, the cookie
148
+ // is still valid for navigating to settings.
149
+ //
150
+ // This MUST succeed — if the MCP server isn't reachable or the refresh
151
+ // fails, tests will fail with confusing iframe/timeout errors.
152
+ const hostPage = new ChatGPTPage(page);
153
+ await hostPage.refreshMcpServer({ appName });
154
+ console.log('MCP server refreshed.');
155
+ } finally {
156
+ await browser.close();
157
+ }
150
158
  }