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.
- package/bin/commands/dev.mjs +98 -5
- package/bin/commands/start.mjs +3 -2
- package/bin/lib/live/browser-auth.mjs +53 -17
- package/bin/lib/live/global-setup.mjs +107 -99
- package/bin/lib/live/host-page.mjs +63 -11
- package/bin/lib/live/live-config.mjs +1 -1
- package/bin/lib/sandbox-server.mjs +304 -0
- 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-SfudQ9Y_.cjs → index-BEWVLFfB.cjs} +2 -2
- package/dist/index-BEWVLFfB.cjs.map +1 -0
- package/dist/{index-B7Qw3Vhh.js → index-C6XYFOmh.js} +2 -2
- package/dist/index-C6XYFOmh.js.map +1 -0
- package/dist/{index-XKHXfBiD.cjs → index-D0FsXP3Y.cjs} +2 -2
- package/dist/index-D0FsXP3Y.cjs.map +1 -0
- package/dist/{index-BEHP_bM8.js → index-Rg7SWjvl.js} +2 -2
- package/dist/index-Rg7SWjvl.js.map +1 -0
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/mcp/favicon.d.ts +3 -1
- package/dist/mcp/index.cjs +52 -36
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.d.ts +2 -2
- package/dist/mcp/index.js +52 -36
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/production-server.d.ts +7 -1
- package/dist/mcp/types.d.ts +30 -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/simulator.d.ts +7 -1
- package/dist/simulator/use-simulator-state.d.ts +2 -4
- package/dist/{simulator-BCq2iOT-.js → simulator-B-CrMHVs.js} +459 -187
- package/dist/simulator-B-CrMHVs.js.map +1 -0
- package/dist/{simulator-DRUsm6IZ.cjs → simulator-Gc6n_fT4.cjs} +458 -186
- package/dist/simulator-Gc6n_fT4.cjs.map +1 -0
- package/dist/style.css +18 -0
- package/package.json +1 -1
- package/template/.sunpeak/dev.tsx +9 -3
- package/template/playwright.config.ts +10 -5
- 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/dist/index-B7Qw3Vhh.js.map +0 -1
- package/dist/index-BEHP_bM8.js.map +0 -1
- package/dist/index-SfudQ9Y_.cjs.map +0 -1
- package/dist/index-XKHXfBiD.cjs.map +0 -1
- package/dist/simulator-BCq2iOT-.js.map +0 -1
- package/dist/simulator-DRUsm6IZ.cjs.map +0 -1
package/bin/commands/dev.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
583
|
-
version: pkg.version
|
|
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
|
});
|
package/bin/commands/start.mjs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
85
|
+
for (const relativePath of essentialPaths) {
|
|
69
86
|
const src = join(profileDir, relativePath);
|
|
70
87
|
if (!existsSync(src)) continue;
|
|
71
88
|
|
|
72
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|