sunpeak 0.20.1 → 0.20.2

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.
@@ -388,7 +388,7 @@ function isAuthError(err) {
388
388
  * Create an MCP client connection.
389
389
  * @param {string} serverArg - URL or command string
390
390
  * @param {{ type?: 'none' | 'bearer' | 'oauth', bearerToken?: string, authProvider?: import('@modelcontextprotocol/sdk/client/auth.js').OAuthClientProvider, env?: Record<string, string>, cwd?: string }} [authConfig]
391
- * @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport, stderrOutput?: string[] }>}
391
+ * @returns {Promise<{ client: import('@modelcontextprotocol/sdk/client/index.js').Client, transport: import('@modelcontextprotocol/sdk/types.js').Transport, serverUrl?: string, stderrOutput?: string[] }>}
392
392
  */
393
393
  async function createMcpConnection(serverArg, authConfig) {
394
394
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
@@ -400,6 +400,19 @@ async function createMcpConnection(serverArg, authConfig) {
400
400
  '@modelcontextprotocol/sdk/client/streamableHttp.js'
401
401
  );
402
402
 
403
+ // Follow redirects (e.g. /mcp → /mcp/) before creating the transport.
404
+ // The MCP SDK transport doesn't follow redirects on its own.
405
+ let finalUrl = serverArg;
406
+ try {
407
+ const probeResponse = await fetch(serverArg, { method: 'HEAD', redirect: 'follow' });
408
+ if (probeResponse.url && probeResponse.url !== serverArg) {
409
+ finalUrl = probeResponse.url;
410
+ }
411
+ } catch {
412
+ // Probe failed (server down, network error) — use original URL and let
413
+ // the transport handle the error with its own diagnostics.
414
+ }
415
+
403
416
  const transportOpts = {};
404
417
 
405
418
  if (authConfig?.type === 'bearer' && authConfig.bearerToken) {
@@ -410,9 +423,9 @@ async function createMcpConnection(serverArg, authConfig) {
410
423
  transportOpts.authProvider = authConfig.authProvider;
411
424
  }
412
425
 
413
- const transport = new StreamableHTTPClientTransport(new URL(serverArg), transportOpts);
426
+ const transport = new StreamableHTTPClientTransport(new URL(finalUrl), transportOpts);
414
427
  await client.connect(transport);
415
- return { client, transport };
428
+ return { client, transport, serverUrl: finalUrl };
416
429
  } else {
417
430
  // Stdio transport — parse command string
418
431
  const parts = serverArg.split(/\s+/);
@@ -501,8 +514,21 @@ async function discoverSimulations(client) {
501
514
  const uri = tool._meta?.ui?.resourceUri ?? tool._meta?.['ui/resourceUri'];
502
515
  if (uri) {
503
516
  resource = resourceByUri.get(uri);
504
- if (resource) {
505
- resourceUrl = `/__sunpeak/read-resource?uri=${encodeURIComponent(uri)}`;
517
+ // Always create a resource URL when a tool declares a resourceUri,
518
+ // even if it wasn't found in listResources(). The server may use
519
+ // resource templates (e.g., ui://counter/{ui}) that resolve dynamically.
520
+ // The /__sunpeak/read-resource endpoint calls client.readResource()
521
+ // which handles template resolution server-side.
522
+ resourceUrl = `/__sunpeak/read-resource?uri=${encodeURIComponent(uri)}`;
523
+ // Create a synthetic resource object when not found via listResources().
524
+ // The inspector UI needs .resource to include the tool in the simulation list.
525
+ if (!resource) {
526
+ resource = {
527
+ uri,
528
+ name: tool.name,
529
+ title: tool.title || tool.name,
530
+ mimeType: 'text/html',
531
+ };
506
532
  }
507
533
  }
508
534
 
@@ -1229,13 +1255,16 @@ export async function inspectServer(opts) {
1229
1255
  // Connect to the MCP server (with retry for local servers that may still be starting)
1230
1256
  let mcpConnection;
1231
1257
  let lastStderrOutput = [];
1258
+ // Track the resolved URL (after following redirects like /mcp → /mcp/).
1259
+ let resolvedServerUrl = serverArg;
1232
1260
  const maxRetries = 5;
1233
1261
  const connectionOpts = {};
1234
1262
  if (serverEnv) connectionOpts.env = serverEnv;
1235
1263
  if (serverCwd) connectionOpts.cwd = serverCwd;
1236
1264
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1237
1265
  try {
1238
- mcpConnection = await createMcpConnection(serverArg, connectionOpts);
1266
+ mcpConnection = await createMcpConnection(resolvedServerUrl, connectionOpts);
1267
+ if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
1239
1268
  break;
1240
1269
  } catch (err) {
1241
1270
  // Capture stderr from the failed connection attempt for diagnostics.
@@ -1244,16 +1273,17 @@ export async function inspectServer(opts) {
1244
1273
  }
1245
1274
 
1246
1275
  // If the server requires OAuth, negotiate it and retry once.
1247
- if (isAuthError(err) && serverArg.startsWith('http')) {
1276
+ if (isAuthError(err) && resolvedServerUrl.startsWith('http')) {
1248
1277
  console.log('Server requires authentication. Negotiating OAuth...');
1249
1278
  try {
1250
- const authProvider = await negotiateOAuth(serverArg);
1279
+ const authProvider = await negotiateOAuth(resolvedServerUrl);
1251
1280
  console.log('OAuth authorized. Reconnecting...');
1252
- mcpConnection = await createMcpConnection(serverArg, {
1281
+ mcpConnection = await createMcpConnection(resolvedServerUrl, {
1253
1282
  ...connectionOpts,
1254
1283
  type: 'oauth',
1255
1284
  authProvider,
1256
1285
  });
1286
+ if (mcpConnection.serverUrl) resolvedServerUrl = mcpConnection.serverUrl;
1257
1287
  break;
1258
1288
  } catch (oauthErr) {
1259
1289
  console.error(`OAuth negotiation failed: ${oauthErr.message}`);
@@ -1333,7 +1363,7 @@ export async function inspectServer(opts) {
1333
1363
  </body>
1334
1364
  </html>`;
1335
1365
 
1336
- const inspectorServerUrl = serverArg;
1366
+ const inspectorServerUrl = resolvedServerUrl;
1337
1367
 
1338
1368
  // Create the Vite server.
1339
1369
  // Use the sunpeak package dir as root to avoid scanning the user's project
@@ -1441,8 +1471,12 @@ export async function inspectServer(opts) {
1441
1471
  ],
1442
1472
  server: {
1443
1473
  port,
1474
+ // Listen on all interfaces so both 127.0.0.1 (used by Playwright tests)
1475
+ // and localhost (used by interactive browsing) connect successfully.
1476
+ // Without this, Vite defaults to localhost which may resolve to IPv6-only
1477
+ // (::1) on macOS, causing ECONNREFUSED for IPv4 clients.
1478
+ host: '0.0.0.0',
1444
1479
  open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
1445
- allowedHosts: 'all',
1446
1480
  },
1447
1481
  optimizeDeps: {
1448
1482
  // Only pre-bundle React — the virtual entry module imports sunpeak from
@@ -40,6 +40,7 @@ export const defaultDeps = {
40
40
  mkdirSync,
41
41
  execSync,
42
42
  cwd: () => process.cwd(),
43
+ isTTY: () => !!process.stdin.isTTY,
43
44
  intro: p.intro,
44
45
  outro: p.outro,
45
46
  confirm: p.confirm,
@@ -80,6 +81,7 @@ export async function testInit(args = [], deps = defaultDeps) {
80
81
  : undefined;
81
82
 
82
83
  const projectType = detectProjectType(d);
84
+ const interactive = d.isTTY();
83
85
 
84
86
  if (projectType === 'sunpeak') {
85
87
  await initSunpeakProject(d);
@@ -89,74 +91,76 @@ export async function testInit(args = [], deps = defaultDeps) {
89
91
  await initExternalProject(cliServer, d);
90
92
  }
91
93
 
92
- // Offer to configure eval providers
93
- const providers = await d.selectProviders();
94
- if (!d.isCancel(providers) && providers.length > 0) {
95
- const pm = d.detectPackageManager();
96
- const pkgsToInstall = ['ai', ...providers.map((p) => p.pkg)];
97
- const installCmd = `${pm} add -D ${pkgsToInstall.join(' ')}`;
98
- try {
99
- d.execSync(installCmd, { cwd: d.cwd(), stdio: 'inherit' });
100
- } catch {
101
- d.log.info(`Provider install failed. Install manually: ${installCmd}`);
102
- }
94
+ // Offer to configure eval providers (skip without a TTY — prompts can't work)
95
+ if (interactive) {
96
+ const providers = await d.selectProviders();
97
+ if (!d.isCancel(providers) && providers.length > 0) {
98
+ const pm = d.detectPackageManager();
99
+ const pkgsToInstall = ['ai', ...providers.map((p) => p.pkg)];
100
+ const installCmd = `${pm} add -D ${pkgsToInstall.join(' ')}`;
101
+ try {
102
+ d.execSync(installCmd, { cwd: d.cwd(), stdio: 'inherit' });
103
+ } catch {
104
+ d.log.info(`Provider install failed. Install manually: ${installCmd}`);
105
+ }
103
106
 
104
- // Uncomment selected models in eval.config.ts
105
- const evalDir = d.existsSync(join(d.cwd(), 'tests', 'evals'))
106
- ? join(d.cwd(), 'tests', 'evals')
107
- : d.existsSync(join(d.cwd(), 'tests', 'sunpeak', 'evals'))
108
- ? join(d.cwd(), 'tests', 'sunpeak', 'evals')
109
- : null;
110
- if (evalDir) {
111
- const configPath = join(evalDir, 'eval.config.ts');
112
- if (d.existsSync(configPath)) {
113
- let config = d.readFileSync(configPath, 'utf-8');
114
- for (const prov of providers) {
115
- for (const model of prov.models) {
116
- config = config.replace(
117
- new RegExp(`^(\\s*)// ('${model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}',?.*)$`, 'm'),
118
- '$1$2'
119
- );
107
+ // Uncomment selected models in eval.config.ts
108
+ const evalDir = d.existsSync(join(d.cwd(), 'tests', 'evals'))
109
+ ? join(d.cwd(), 'tests', 'evals')
110
+ : d.existsSync(join(d.cwd(), 'tests', 'sunpeak', 'evals'))
111
+ ? join(d.cwd(), 'tests', 'sunpeak', 'evals')
112
+ : null;
113
+ if (evalDir) {
114
+ const configPath = join(evalDir, 'eval.config.ts');
115
+ if (d.existsSync(configPath)) {
116
+ let config = d.readFileSync(configPath, 'utf-8');
117
+ for (const prov of providers) {
118
+ for (const model of prov.models) {
119
+ config = config.replace(
120
+ new RegExp(`^(\\s*)// ('${model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}',?.*)$`, 'm'),
121
+ '$1$2'
122
+ );
123
+ }
120
124
  }
125
+ d.writeFileSync(configPath, config);
121
126
  }
122
- d.writeFileSync(configPath, config);
123
- }
124
127
 
125
- // Prompt for API keys and write .env
126
- const envLines = [];
127
- const seen = new Set();
128
- for (const prov of providers) {
129
- if (seen.has(prov.envVar)) continue;
130
- seen.add(prov.envVar);
131
- const key = await d.password({
132
- message: `${prov.envVar} (enter to skip)`,
133
- mask: '*',
134
- });
135
- if (!d.isCancel(key) && key) {
136
- envLines.push(`${prov.envVar}=${key}`);
128
+ // Prompt for API keys and write .env
129
+ const envLines = [];
130
+ const seen = new Set();
131
+ for (const prov of providers) {
132
+ if (seen.has(prov.envVar)) continue;
133
+ seen.add(prov.envVar);
134
+ const key = await d.password({
135
+ message: `${prov.envVar} (enter to skip)`,
136
+ mask: '*',
137
+ });
138
+ if (!d.isCancel(key) && key) {
139
+ envLines.push(`${prov.envVar}=${key}`);
140
+ }
141
+ }
142
+ if (envLines.length > 0 && evalDir) {
143
+ const relEnvPath = evalDir.startsWith(d.cwd()) ? evalDir.slice(d.cwd().length + 1) : evalDir;
144
+ d.writeFileSync(join(evalDir, '.env'), envLines.join('\n') + '\n');
145
+ d.log.info(`API keys saved to ${relEnvPath}/.env (gitignored)`);
137
146
  }
138
- }
139
- if (envLines.length > 0 && evalDir) {
140
- const relEnvPath = evalDir.startsWith(d.cwd()) ? evalDir.slice(d.cwd().length + 1) : evalDir;
141
- d.writeFileSync(join(evalDir, '.env'), envLines.join('\n') + '\n');
142
- d.log.info(`API keys saved to ${relEnvPath}/.env (gitignored)`);
143
147
  }
144
148
  }
145
- }
146
149
 
147
- // Offer to install the testing skill
148
- const installSkill = await d.confirm({
149
- message: 'Install the test-mcp-server skill? (helps your coding agent write tests)',
150
- initialValue: true,
151
- });
152
- if (!d.isCancel(installSkill) && installSkill) {
153
- try {
154
- d.execSync('pnpm dlx skills add Sunpeak-AI/sunpeak@test-mcp-server', {
155
- cwd: d.cwd(),
156
- stdio: 'inherit',
157
- });
158
- } catch {
159
- d.log.info('Skill install skipped. Install later: pnpm dlx skills add Sunpeak-AI/sunpeak@test-mcp-server');
150
+ // Offer to install the testing skill
151
+ const installSkill = await d.confirm({
152
+ message: 'Install the test-mcp-server skill? (helps your coding agent write tests)',
153
+ initialValue: true,
154
+ });
155
+ if (!d.isCancel(installSkill) && installSkill) {
156
+ try {
157
+ d.execSync('pnpm dlx skills add Sunpeak-AI/sunpeak@test-mcp-server', {
158
+ cwd: d.cwd(),
159
+ stdio: 'inherit',
160
+ });
161
+ } catch {
162
+ d.log.info('Skill install skipped. Install later: pnpm dlx skills add Sunpeak-AI/sunpeak@test-mcp-server');
163
+ }
160
164
  }
161
165
  }
162
166
 
@@ -191,6 +195,11 @@ async function getServerConfig(cliServer, d) {
191
195
  return { type: 'command', value: cliServer };
192
196
  }
193
197
 
198
+ // Without a TTY, interactive prompts can't work — default to "configure later".
199
+ if (!d.isTTY()) {
200
+ return { type: 'later' };
201
+ }
202
+
194
203
  const serverType = await d.select({
195
204
  message: 'How does your MCP server start?',
196
205
  options: [
@@ -437,9 +446,9 @@ function scaffoldVisualTest(filePath, d) {
437
446
  /**
438
447
  * Scaffold live test boilerplate (test against real ChatGPT/Claude).
439
448
  * @param {string} liveDir - Directory to create live test files in
440
- * @param {{ isSunpeak?: boolean, d: object }} options
449
+ * @param {{ isSunpeak?: boolean, server?: object, d: object }} options
441
450
  */
442
- function scaffoldLiveTests(liveDir, { isSunpeak, d } = {}) {
451
+ function scaffoldLiveTests(liveDir, { isSunpeak, server, d } = {}) {
443
452
  if (d.existsSync(join(liveDir, 'playwright.config.ts'))) {
444
453
  d.log.info('Live test config already exists. Skipping live test scaffold.');
445
454
  return;
@@ -459,9 +468,24 @@ function scaffoldLiveTests(liveDir, { isSunpeak, d } = {}) {
459
468
  * 3. Run: sunpeak test --live
460
469
  *
461
470
  * On first run, a browser window opens for you to log in to the host.
462
- * The session is saved for subsequent runs (typically lasts a few hours).`;
471
+ * The session is saved for subsequent runs (typically lasts a few hours).
472
+ */`;
473
+
474
+ // Build the server option for non-sunpeak projects
475
+ let serverOption = '';
476
+ if (!isSunpeak && server?.type === 'url') {
477
+ serverOption = `\n server: { url: '${server.value}' },`;
478
+ } else if (!isSunpeak && server?.type === 'command') {
479
+ const parts = server.value.split(/\s+/);
480
+ const cmd = parts[0];
481
+ const args = parts.slice(1);
482
+ serverOption = args.length > 0
483
+ ? `\n server: { command: '${cmd}', args: [${args.map(a => `'${a}'`).join(', ')}] },`
484
+ : `\n server: { command: '${cmd}' },`;
485
+ }
463
486
 
464
- const liveConfigExport = `export default defineLiveConfig({
487
+ const configContent = `${liveConfigPreamble}
488
+ export default defineLiveConfig({${serverOption}
465
489
  // hosts: ['chatgpt'], // Which hosts to test against
466
490
  // colorScheme: 'light', // Default color scheme
467
491
  // viewport: { width: 1280, height: 720 },
@@ -469,19 +493,6 @@ function scaffoldLiveTests(liveDir, { isSunpeak, d } = {}) {
469
493
  });
470
494
  `;
471
495
 
472
- const configContent = isSunpeak
473
- ? `${liveConfigPreamble}
474
- */
475
- ${liveConfigExport}`
476
- : `${liveConfigPreamble}
477
- *
478
- * NOTE: defineLiveConfig() starts a local sunpeak dev server as its backend.
479
- * If your MCP server is not a sunpeak project, you may need to customize the
480
- * webServer option in the Playwright config below to start your own server,
481
- * or remove webServer entirely if your server is already running.
482
- */
483
- ${liveConfigExport}`;
484
-
485
496
  d.writeFileSync(join(liveDir, 'playwright.config.ts'), configContent);
486
497
 
487
498
  // Live test example
@@ -663,7 +674,7 @@ test('server exposes tools', async ({ mcp }) => {
663
674
  scaffoldVisualTest(join(testDir, 'visual.test.ts'), d);
664
675
 
665
676
  // 3. Live tests
666
- scaffoldLiveTests(join(testDir, 'live'), { isSunpeak: false, d });
677
+ scaffoldLiveTests(join(testDir, 'live'), { isSunpeak: false, server, d });
667
678
 
668
679
  // 4. Eval boilerplate
669
680
  scaffoldEvals(join(testDir, 'evals'), { server, d });
@@ -757,7 +768,7 @@ test('server exposes tools', async ({ mcp }) => {
757
768
  scaffoldVisualTest(join(e2eDir, 'visual.test.ts'), d);
758
769
 
759
770
  // 3. Live tests
760
- scaffoldLiveTests(join(cwd, 'tests', 'live'), { isSunpeak: false, d });
771
+ scaffoldLiveTests(join(cwd, 'tests', 'live'), { isSunpeak: false, server, d });
761
772
 
762
773
  // 4. Eval boilerplate
763
774
  scaffoldEvals(join(cwd, 'tests', 'evals'), { server, d });
@@ -85,12 +85,15 @@ export async function runTest(args) {
85
85
  configCandidates: [
86
86
  'tests/live/playwright.config.ts',
87
87
  'tests/live/playwright.config.js',
88
- // Fallback for non-JS projects: tests/sunpeak/ self-contained directory
88
+ // Non-JS projects: tests/sunpeak/ self-contained directory (run from project root)
89
89
  'tests/sunpeak/live/playwright.config.ts',
90
90
  'tests/sunpeak/live/playwright.config.js',
91
+ // Non-JS projects: run from within tests/sunpeak/ directly
92
+ 'live/playwright.config.ts',
93
+ 'live/playwright.config.js',
91
94
  ],
92
95
  configRequired: true,
93
- configErrorMessage: 'No live test config found at tests/live/playwright.config.ts',
96
+ configErrorMessage: 'No live test config found. Expected at tests/live/playwright.config.ts or live/playwright.config.ts',
94
97
  });
95
98
  results.push({ suite: 'live', code });
96
99
  }
@@ -537,6 +540,8 @@ function findEvalDir() {
537
540
  const candidates = [
538
541
  'tests/evals',
539
542
  'tests/sunpeak/evals',
543
+ // When running from within tests/sunpeak/ directly (non-JS projects)
544
+ 'evals',
540
545
  ];
541
546
 
542
547
  for (const candidate of candidates) {
@@ -77,7 +77,13 @@ export async function createMcpConnection(serverArg) {
77
77
  const { StreamableHTTPClientTransport } = await import(
78
78
  '@modelcontextprotocol/sdk/client/streamableHttp.js'
79
79
  );
80
- const transport = new StreamableHTTPClientTransport(new URL(serverArg));
80
+ // Follow redirects (e.g. /mcp → /mcp/) before creating the transport.
81
+ let finalUrl = serverArg;
82
+ try {
83
+ const resp = await fetch(serverArg, { method: 'HEAD', redirect: 'follow' });
84
+ if (resp.url && resp.url !== serverArg) finalUrl = resp.url;
85
+ } catch { /* use original URL */ }
86
+ const transport = new StreamableHTTPClientTransport(new URL(finalUrl));
81
87
  await client.connect(transport);
82
88
  return { client, transport };
83
89
  } else {
@@ -79,7 +79,7 @@ export function defineInspectConfig(options) {
79
79
  timeout,
80
80
  webServer: {
81
81
  command,
82
- healthUrl: `http://localhost:${port}/health`,
82
+ healthUrl: `http://127.0.0.1:${port}/health`,
83
83
  },
84
84
  });
85
85
  }
@@ -28,6 +28,16 @@ export interface LiveConfigOptions {
28
28
 
29
29
  /** Additional Playwright `use` options, merged with defaults. */
30
30
  use?: Record<string, unknown>;
31
+
32
+ /** External MCP server config. Omit for sunpeak framework projects. */
33
+ server?: {
34
+ /** Server URL (e.g., 'http://localhost:8000/mcp') */
35
+ url?: string;
36
+ /** Server start command */
37
+ command?: string;
38
+ /** Command arguments */
39
+ args?: string[];
40
+ };
31
41
  }
32
42
 
33
43
  export interface HostConfigOptions {
@@ -11,6 +11,7 @@ import { join, dirname } from 'path';
11
11
  import { fileURLToPath } from 'url';
12
12
  import { ANTI_BOT_ARGS, CHROME_USER_AGENT } from './utils.mjs';
13
13
  import { getPortSync } from '../get-port.mjs';
14
+ import { resolveSunpeakBin } from '../resolve-bin.mjs';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const GLOBAL_SETUP_PATH = join(__dirname, 'global-setup.mjs');
@@ -34,6 +35,10 @@ const GLOBAL_SETUP_PATH = join(__dirname, 'global-setup.mjs');
34
35
  * @param {string[]} [options.permissions] - Browser permissions to grant (e.g., ['geolocation'])
35
36
  * @param {boolean} [options.devOverlay=true] - Show the dev overlay (resource timestamp + tool timing) in resources
36
37
  * @param {Object} [options.use] - Additional Playwright `use` options (merged with defaults)
38
+ * @param {Object} [options.server] - External MCP server config (omit for sunpeak projects)
39
+ * @param {string} [options.server.url] - Server URL (e.g., 'http://localhost:8000/mcp')
40
+ * @param {string} [options.server.command] - Server start command
41
+ * @param {string[]} [options.server.args] - Command arguments
37
42
  */
38
43
  export function createLiveConfig(hostOptions, options = {}) {
39
44
  const { hostId, authFileName } = hostOptions;
@@ -49,6 +54,7 @@ export function createLiveConfig(hostOptions, options = {}) {
49
54
  geolocation,
50
55
  permissions,
51
56
  use: userUse,
57
+ server,
52
58
  } = options;
53
59
 
54
60
  const resolvedAuthDir = authDir || join(testDir, '.auth');
@@ -91,10 +97,36 @@ export function createLiveConfig(hostOptions, options = {}) {
91
97
  },
92
98
  ],
93
99
  webServer: {
94
- command: `SUNPEAK_LIVE_TEST=1 SUNPEAK_SANDBOX_PORT=${getPortSync(24680)}${devOverlay ? '' : ' SUNPEAK_DEV_OVERLAY=false'} pnpm dev -- --prod-resources --port ${vitePort}`,
95
- url: `http://localhost:${vitePort}/health`,
100
+ command: buildLiveWebServerCommand({ server, vitePort, devOverlay }),
101
+ url: `http://127.0.0.1:${vitePort}/health`,
96
102
  reuseExistingServer: !process.env.CI,
97
103
  timeout: 60_000,
98
104
  },
99
105
  };
100
106
  }
107
+
108
+ /**
109
+ * Build the webServer command for live tests.
110
+ * Uses `sunpeak inspect` for external servers, `pnpm dev` for sunpeak projects.
111
+ */
112
+ function buildLiveWebServerCommand({ server, vitePort, devOverlay }) {
113
+ const sandboxPort = getPortSync(24680);
114
+ const envPrefix = `SUNPEAK_LIVE_TEST=1 SUNPEAK_SANDBOX_PORT=${sandboxPort}${devOverlay ? '' : ' SUNPEAK_DEV_OVERLAY=false'}`;
115
+
116
+ if (server) {
117
+ // External MCP server — launch sunpeak inspect
118
+ const bin = resolveSunpeakBin();
119
+ if (server.url) {
120
+ return `${envPrefix} ${bin} inspect --server ${server.url} --port ${vitePort}`;
121
+ }
122
+ if (server.command) {
123
+ const cmd = server.args
124
+ ? `${server.command} ${server.args.join(' ')}`
125
+ : server.command;
126
+ return `${envPrefix} ${bin} inspect --server "${cmd}" --port ${vitePort}`;
127
+ }
128
+ }
129
+
130
+ // sunpeak framework project — use pnpm dev
131
+ return `${envPrefix} pnpm dev -- --prod-resources --port ${vitePort}`;
132
+ }
@@ -44,7 +44,9 @@ export function createBaseConfig({ hosts, testDir, webServer, port, use, globalS
44
44
  ? { expect: { toHaveScreenshot: toHaveScreenshotDefaults } }
45
45
  : {}),
46
46
  use: {
47
- baseURL: `http://localhost:${port}`,
47
+ // Use 127.0.0.1 instead of localhost to avoid IPv4/IPv6 resolution
48
+ // ambiguity that causes ECONNREFUSED flakes on macOS.
49
+ baseURL: `http://127.0.0.1:${port}`,
48
50
  trace: 'on-first-retry',
49
51
  ...use,
50
52
  },
@@ -76,7 +76,7 @@ export function defineConfig(options = {}) {
76
76
  timeout,
77
77
  webServer: {
78
78
  command,
79
- healthUrl: `http://localhost:${port}/health`,
79
+ healthUrl: `http://127.0.0.1:${port}/health`,
80
80
  },
81
81
  });
82
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
4
4
  "description": "Inspector, testing framework, and app framework for MCP Apps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "albums",
15
- "uri": "ui://albums-mnz3xvsb"
15
+ "uri": "ui://albums-mnzbuq1s"
16
16
  }
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "carousel",
15
- "uri": "ui://carousel-mnz3xvsb"
15
+ "uri": "ui://carousel-mnzbuq1s"
16
16
  }
@@ -18,5 +18,5 @@
18
18
  }
19
19
  },
20
20
  "name": "map",
21
- "uri": "ui://map-mnz3xvsb"
21
+ "uri": "ui://map-mnzbuq1s"
22
22
  }
@@ -12,5 +12,5 @@
12
12
  }
13
13
  },
14
14
  "name": "review",
15
- "uri": "ui://review-mnz3xvsb"
15
+ "uri": "ui://review-mnzbuq1s"
16
16
  }