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.
- package/bin/commands/inspect.mjs +45 -11
- package/bin/commands/test-init.mjs +90 -79
- package/bin/commands/test.mjs +7 -2
- package/bin/lib/eval/eval-runner.mjs +7 -1
- package/bin/lib/inspect/inspect-config.mjs +1 -1
- package/bin/lib/live/live-config.d.mts +10 -0
- package/bin/lib/live/live-config.mjs +34 -2
- package/bin/lib/test/base-config.mjs +3 -1
- package/bin/lib/test/test-config.mjs +1 -1
- package/package.json +1 -1
- package/template/dist/albums/albums.json +1 -1
- package/template/dist/carousel/carousel.json +1 -1
- package/template/dist/map/map.json +1 -1
- package/template/dist/review/review.json +1 -1
package/bin/commands/inspect.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
505
|
-
|
|
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(
|
|
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) &&
|
|
1276
|
+
if (isAuthError(err) && resolvedServerUrl.startsWith('http')) {
|
|
1248
1277
|
console.log('Server requires authentication. Negotiating OAuth...');
|
|
1249
1278
|
try {
|
|
1250
|
-
const authProvider = await negotiateOAuth(
|
|
1279
|
+
const authProvider = await negotiateOAuth(resolvedServerUrl);
|
|
1251
1280
|
console.log('OAuth authorized. Reconnecting...');
|
|
1252
|
-
mcpConnection = await createMcpConnection(
|
|
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 =
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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 });
|
package/bin/commands/test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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 {
|
|
@@ -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:
|
|
95
|
-
url: `http://
|
|
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
|
-
|
|
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
|
},
|
package/package.json
CHANGED