mcp-server-diff 2.1.0 → 2.1.6

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/src/runner.ts DELETED
@@ -1,902 +0,0 @@
1
- /**
2
- * Test runner for MCP server diff
3
- */
4
-
5
- import * as exec from "@actions/exec";
6
- import * as core from "@actions/core";
7
- import * as path from "path";
8
- import * as fs from "fs";
9
- import { spawn, ChildProcess } from "child_process";
10
- import { probeServer, probeResultToFiles } from "./probe.js";
11
- import { createWorktree, removeWorktree, checkout, checkoutPrevious } from "./git.js";
12
- import type {
13
- TestConfiguration,
14
- ActionInputs,
15
- TestResult,
16
- ProbeResult,
17
- CustomMessage,
18
- PrimitiveCounts,
19
- } from "./types.js";
20
-
21
- interface RunContext {
22
- workDir: string;
23
- inputs: ActionInputs;
24
- compareRef: string;
25
- }
26
-
27
- /**
28
- * Parse configurations from input
29
- */
30
- export function parseConfigurations(
31
- input: string | undefined,
32
- defaultTransport: "stdio" | "streamable-http",
33
- defaultCommand: string,
34
- defaultUrl: string
35
- ): TestConfiguration[] {
36
- if (!input || input.trim() === "[]" || input.trim() === "") {
37
- // Return single default configuration
38
- return [
39
- {
40
- name: "default",
41
- transport: defaultTransport,
42
- start_command: defaultTransport === "stdio" ? defaultCommand : undefined,
43
- server_url: defaultTransport === "streamable-http" ? defaultUrl : undefined,
44
- },
45
- ];
46
- }
47
-
48
- try {
49
- const configs = JSON.parse(input) as TestConfiguration[];
50
- if (!Array.isArray(configs) || configs.length === 0) {
51
- throw new Error("Configurations must be a non-empty array");
52
- }
53
-
54
- // Apply defaults to each configuration
55
- return configs.map((config) => {
56
- const transport = config.transport || defaultTransport;
57
- return {
58
- ...config,
59
- transport,
60
- // For stdio, use default command if not specified (appending args if needed)
61
- start_command:
62
- transport === "stdio" ? config.start_command || defaultCommand : config.start_command,
63
- // For HTTP, use default URL if not specified
64
- server_url:
65
- transport === "streamable-http" ? config.server_url || defaultUrl : config.server_url,
66
- };
67
- });
68
- } catch (error) {
69
- core.warning(`Failed to parse configurations: ${error}`);
70
- // Return default
71
- return [
72
- {
73
- name: "default",
74
- transport: defaultTransport,
75
- start_command: defaultTransport === "stdio" ? defaultCommand : undefined,
76
- server_url: defaultTransport === "streamable-http" ? defaultUrl : undefined,
77
- },
78
- ];
79
- }
80
- }
81
-
82
- /**
83
- * Parse custom messages from input
84
- */
85
- export function parseCustomMessages(input: string | undefined): CustomMessage[] {
86
- if (!input || input.trim() === "[]" || input.trim() === "") {
87
- return [];
88
- }
89
-
90
- try {
91
- const messages = JSON.parse(input) as CustomMessage[];
92
- return Array.isArray(messages) ? messages : [];
93
- } catch {
94
- return [];
95
- }
96
- }
97
-
98
- /**
99
- * Parse headers from input (JSON object or KEY=value format, newline separated)
100
- */
101
- export function parseHeaders(input: string | undefined): Record<string, string> {
102
- if (!input || input.trim() === "" || input.trim() === "{}") {
103
- return {};
104
- }
105
-
106
- // Try JSON first
107
- try {
108
- const parsed = JSON.parse(input);
109
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
110
- return parsed as Record<string, string>;
111
- }
112
- } catch {
113
- // Not JSON, try KEY=value format
114
- }
115
-
116
- // Fall back to KEY=value format
117
- const headers: Record<string, string> = {};
118
- const lines = input.split("\n");
119
- for (const line of lines) {
120
- const trimmed = line.trim();
121
- if (!trimmed || trimmed.startsWith("#")) continue;
122
-
123
- const colonIndex = trimmed.indexOf(":");
124
- const eqIndex = trimmed.indexOf("=");
125
-
126
- // Support both "Header: value" and "Header=value" formats
127
- const sepIndex = colonIndex > 0 ? colonIndex : eqIndex;
128
- if (sepIndex > 0) {
129
- const key = trimmed.substring(0, sepIndex).trim();
130
- const value = trimmed.substring(sepIndex + 1).trim();
131
- headers[key] = value;
132
- }
133
- }
134
- return headers;
135
- }
136
-
137
- /**
138
- * Parse environment variables from string (KEY=value format, newline separated)
139
- */
140
- export function parseEnvVars(input?: string): Record<string, string> {
141
- const env: Record<string, string> = {};
142
- if (!input) return env;
143
-
144
- const lines = input.split("\n");
145
- for (const line of lines) {
146
- const trimmed = line.trim();
147
- if (!trimmed || trimmed.startsWith("#")) continue;
148
-
149
- const eqIndex = trimmed.indexOf("=");
150
- if (eqIndex > 0) {
151
- const key = trimmed.substring(0, eqIndex);
152
- const value = trimmed.substring(eqIndex + 1);
153
- env[key] = value;
154
- }
155
- }
156
- return env;
157
- }
158
-
159
- /**
160
- * Run build commands in a directory
161
- */
162
- async function runBuild(dir: string, inputs: ActionInputs): Promise<void> {
163
- const options = { cwd: dir };
164
-
165
- if (inputs.installCommand) {
166
- core.info(` Running install: ${inputs.installCommand}`);
167
- await exec.exec("sh", ["-c", inputs.installCommand], options);
168
- }
169
-
170
- if (inputs.buildCommand) {
171
- core.info(` Running build: ${inputs.buildCommand}`);
172
- await exec.exec("sh", ["-c", inputs.buildCommand], options);
173
- }
174
- }
175
-
176
- /**
177
- * Sleep for a specified number of milliseconds
178
- */
179
- function sleep(ms: number): Promise<void> {
180
- return new Promise((resolve) => setTimeout(resolve, ms));
181
- }
182
-
183
- /**
184
- * Start an HTTP server process for the given configuration.
185
- * Returns the spawned process which should be killed after probing.
186
- */
187
- async function startHttpServer(
188
- config: TestConfiguration,
189
- workDir: string,
190
- envVars: Record<string, string>
191
- ): Promise<ChildProcess | null> {
192
- if (!config.start_command) {
193
- return null;
194
- }
195
-
196
- core.info(` Starting HTTP server: ${config.start_command}`);
197
-
198
- // Merge environment variables
199
- const env: Record<string, string> = {};
200
- for (const [key, value] of Object.entries(process.env)) {
201
- if (value !== undefined) {
202
- env[key] = value;
203
- }
204
- }
205
- for (const [key, value] of Object.entries(envVars)) {
206
- env[key] = value;
207
- }
208
-
209
- const serverProcess = spawn("sh", ["-c", config.start_command], {
210
- cwd: workDir,
211
- env,
212
- detached: true,
213
- stdio: ["ignore", "pipe", "pipe"],
214
- });
215
-
216
- // Log server output for debugging
217
- serverProcess.stdout?.on("data", (data) => {
218
- core.debug(` [server stdout]: ${data.toString().trim()}`);
219
- });
220
- serverProcess.stderr?.on("data", (data) => {
221
- core.debug(` [server stderr]: ${data.toString().trim()}`);
222
- });
223
-
224
- // Wait for server to start up
225
- const waitMs = config.startup_wait_ms ?? config.pre_test_wait_ms ?? 2000;
226
- core.info(` Waiting ${waitMs}ms for server to start...`);
227
- await sleep(waitMs);
228
-
229
- // Check if process is still running
230
- if (serverProcess.exitCode !== null) {
231
- throw new Error(`HTTP server exited prematurely with code ${serverProcess.exitCode}`);
232
- }
233
-
234
- core.info(" HTTP server started");
235
- return serverProcess;
236
- }
237
-
238
- /**
239
- * Stop an HTTP server process
240
- */
241
- function stopHttpServer(serverProcess: ChildProcess | null): void {
242
- if (!serverProcess) {
243
- return;
244
- }
245
-
246
- core.info(" Stopping HTTP server...");
247
- try {
248
- // Kill the process group (negative PID kills the group)
249
- if (serverProcess.pid) {
250
- process.kill(-serverProcess.pid, "SIGTERM");
251
- }
252
- } catch (error) {
253
- // Process might already be dead
254
- core.debug(` Error stopping server: ${error}`);
255
- }
256
- }
257
-
258
- /**
259
- * Run pre-test command if specified
260
- */
261
- async function runPreTestCommand(config: TestConfiguration, workDir: string): Promise<void> {
262
- if (config.pre_test_command) {
263
- core.info(` Running pre-test command: ${config.pre_test_command}`);
264
- await exec.exec("sh", ["-c", config.pre_test_command], { cwd: workDir });
265
-
266
- if (config.pre_test_wait_ms && config.pre_test_wait_ms > 0) {
267
- core.info(` Waiting ${config.pre_test_wait_ms}ms for service to be ready...`);
268
- await sleep(config.pre_test_wait_ms);
269
- }
270
- }
271
- }
272
-
273
- /**
274
- * Run post-test command if specified (cleanup)
275
- */
276
- async function runPostTestCommand(config: TestConfiguration, workDir: string): Promise<void> {
277
- if (config.post_test_command) {
278
- core.info(` Running post-test command: ${config.post_test_command}`);
279
- try {
280
- await exec.exec("sh", ["-c", config.post_test_command], { cwd: workDir });
281
- } catch (error) {
282
- // Log but don't fail - cleanup errors shouldn't break the test
283
- core.warning(` Post-test command failed: ${error}`);
284
- }
285
- }
286
- }
287
-
288
- /**
289
- * Probe a server with a specific configuration
290
- * @param useSharedServer - If true, skip starting per-config HTTP server (shared server is already running)
291
- */
292
- async function probeWithConfig(
293
- config: TestConfiguration,
294
- workDir: string,
295
- globalEnvVars: Record<string, string>,
296
- globalHeaders: Record<string, string>,
297
- globalCustomMessages: CustomMessage[],
298
- useSharedServer: boolean = false
299
- ): Promise<ProbeResult> {
300
- const configEnvVars = parseEnvVars(config.env_vars);
301
- const envVars = { ...globalEnvVars, ...configEnvVars };
302
- const headers = { ...globalHeaders, ...config.headers };
303
- const customMessages = config.custom_messages || globalCustomMessages;
304
-
305
- // Run pre-test command before probing
306
- await runPreTestCommand(config, workDir);
307
-
308
- if (config.transport === "stdio") {
309
- const command = config.start_command || "";
310
- const parts = command.split(/\s+/);
311
- const cmd = parts[0];
312
- const args = parts.slice(1);
313
-
314
- // Also parse additional args if provided
315
- if (config.args) {
316
- args.push(...config.args.split(/\s+/));
317
- }
318
-
319
- return await probeServer({
320
- transport: "stdio",
321
- command: cmd,
322
- args,
323
- workingDir: workDir,
324
- envVars,
325
- customMessages,
326
- });
327
- } else {
328
- // For HTTP transport, optionally start the server if start_command is provided
329
- // Skip if using shared server
330
- let serverProcess: ChildProcess | null = null;
331
- try {
332
- if (config.start_command && !useSharedServer) {
333
- serverProcess = await startHttpServer(config, workDir, envVars);
334
- }
335
-
336
- return await probeServer({
337
- transport: "streamable-http",
338
- url: config.server_url,
339
- headers,
340
- envVars,
341
- customMessages,
342
- });
343
- } finally {
344
- // Always stop the server if we started it
345
- stopHttpServer(serverProcess);
346
- }
347
- }
348
- }
349
-
350
- /**
351
- * Compare two sets of probe result files and return diffs
352
- */
353
- function compareResults(
354
- branchFiles: Map<string, string>,
355
- baseFiles: Map<string, string>
356
- ): Map<string, string> {
357
- const diffs = new Map<string, string>();
358
-
359
- // Check all endpoints
360
- const allEndpoints = new Set([...branchFiles.keys(), ...baseFiles.keys()]);
361
-
362
- for (const endpoint of allEndpoints) {
363
- const branchContent = branchFiles.get(endpoint);
364
- const baseContent = baseFiles.get(endpoint);
365
-
366
- if (!branchContent && baseContent) {
367
- diffs.set(endpoint, `Endpoint removed in current branch (was present in base)`);
368
- } else if (branchContent && !baseContent) {
369
- diffs.set(endpoint, `Endpoint added in current branch (not present in base)`);
370
- } else if (branchContent !== baseContent) {
371
- // Generate a semantic JSON diff
372
- const diff = generateJsonDiff(endpoint, baseContent || "", branchContent || "");
373
- if (diff) {
374
- diffs.set(endpoint, diff);
375
- }
376
- }
377
- }
378
-
379
- return diffs;
380
- }
381
-
382
- /**
383
- * Generate a semantic JSON diff that shows actual changes
384
- * rather than line-by-line text comparison
385
- */
386
- function generateJsonDiff(name: string, base: string, branch: string): string | null {
387
- try {
388
- const baseObj = JSON.parse(base);
389
- const branchObj = JSON.parse(branch);
390
-
391
- const differences = findJsonDifferences(baseObj, branchObj, "");
392
-
393
- if (differences.length === 0) {
394
- return null;
395
- }
396
-
397
- const diffLines = [`--- base/${name}.json`, `+++ branch/${name}.json`, ""];
398
- diffLines.push(...differences);
399
-
400
- return diffLines.join("\n");
401
- } catch {
402
- // Fallback to simple diff if JSON parsing fails
403
- return generateSimpleTextDiff(name, base, branch);
404
- }
405
- }
406
-
407
- /**
408
- * Recursively find differences between two JSON objects
409
- */
410
- function findJsonDifferences(base: unknown, branch: unknown, path: string): string[] {
411
- const diffs: string[] = [];
412
-
413
- // Handle null/undefined
414
- if (base === null || base === undefined) {
415
- if (branch !== null && branch !== undefined) {
416
- diffs.push(`+ ${path || "root"}: ${formatValue(branch)}`);
417
- }
418
- return diffs;
419
- }
420
-
421
- if (branch === null || branch === undefined) {
422
- diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
423
- return diffs;
424
- }
425
-
426
- // Handle type mismatch
427
- if (typeof base !== typeof branch) {
428
- diffs.push(`- ${path || "root"}: ${formatValue(base)}`);
429
- diffs.push(`+ ${path || "root"}: ${formatValue(branch)}`);
430
- return diffs;
431
- }
432
-
433
- // Handle arrays
434
- if (Array.isArray(base) && Array.isArray(branch)) {
435
- return compareArrays(base, branch, path);
436
- }
437
-
438
- // Handle objects
439
- if (typeof base === "object" && typeof branch === "object") {
440
- const baseObj = base as Record<string, unknown>;
441
- const branchObj = branch as Record<string, unknown>;
442
- const allKeys = new Set([...Object.keys(baseObj), ...Object.keys(branchObj)]);
443
-
444
- for (const key of allKeys) {
445
- const newPath = path ? `${path}.${key}` : key;
446
-
447
- if (!(key in baseObj)) {
448
- diffs.push(`+ ${newPath}: ${formatValue(branchObj[key])}`);
449
- } else if (!(key in branchObj)) {
450
- diffs.push(`- ${newPath}: ${formatValue(baseObj[key])}`);
451
- } else {
452
- diffs.push(...findJsonDifferences(baseObj[key], branchObj[key], newPath));
453
- }
454
- }
455
- return diffs;
456
- }
457
-
458
- // Handle primitives
459
- if (base !== branch) {
460
- diffs.push(`- ${path}: ${formatValue(base)}`);
461
- diffs.push(`+ ${path}: ${formatValue(branch)}`);
462
- }
463
-
464
- return diffs;
465
- }
466
-
467
- /**
468
- * Compare arrays by finding items by their identity (name, uri, etc.)
469
- */
470
- function compareArrays(base: unknown[], branch: unknown[], path: string): string[] {
471
- const diffs: string[] = [];
472
-
473
- // Try to identify items by name/uri for better diff
474
- const baseItems = new Map<string, { item: unknown; index: number }>();
475
- const branchItems = new Map<string, { item: unknown; index: number }>();
476
-
477
- base.forEach((item, index) => {
478
- const key = getItemKey(item, index);
479
- baseItems.set(key, { item, index });
480
- });
481
-
482
- branch.forEach((item, index) => {
483
- const key = getItemKey(item, index);
484
- branchItems.set(key, { item, index });
485
- });
486
-
487
- // Find removed items
488
- for (const [key, { item }] of baseItems) {
489
- if (!branchItems.has(key)) {
490
- const itemPath = `${path}[${key}]`;
491
- diffs.push(`- ${itemPath}: ${formatValue(item)}`);
492
- }
493
- }
494
-
495
- // Find added items
496
- for (const [key, { item }] of branchItems) {
497
- if (!baseItems.has(key)) {
498
- const itemPath = `${path}[${key}]`;
499
- diffs.push(`+ ${itemPath}: ${formatValue(item)}`);
500
- }
501
- }
502
-
503
- // Find modified items
504
- for (const [key, { item: baseItem }] of baseItems) {
505
- const branchEntry = branchItems.get(key);
506
- if (branchEntry) {
507
- const itemPath = `${path}[${key}]`;
508
- diffs.push(...findJsonDifferences(baseItem, branchEntry.item, itemPath));
509
- }
510
- }
511
-
512
- return diffs;
513
- }
514
-
515
- /**
516
- * Get a unique key for an array item based on common identifiers
517
- */
518
- function getItemKey(item: unknown, index: number): string {
519
- if (item === null || item === undefined || typeof item !== "object") {
520
- return `#${index}`;
521
- }
522
-
523
- const obj = item as Record<string, unknown>;
524
-
525
- // Try common identifier fields
526
- if (typeof obj.name === "string") return obj.name;
527
- if (typeof obj.uri === "string") return obj.uri;
528
- if (typeof obj.uriTemplate === "string") return obj.uriTemplate;
529
- if (typeof obj.type === "string" && typeof obj.text === "string") {
530
- return `${obj.type}:${String(obj.text).slice(0, 50)}`;
531
- }
532
- if (typeof obj.method === "string") return obj.method;
533
-
534
- return `#${index}`;
535
- }
536
-
537
- /**
538
- * Format a value for display in the diff
539
- */
540
- function formatValue(value: unknown): string {
541
- if (value === null) return "null";
542
- if (value === undefined) return "undefined";
543
-
544
- if (typeof value === "string") {
545
- // Truncate long strings
546
- if (value.length > 100) {
547
- return JSON.stringify(value.slice(0, 100) + "...");
548
- }
549
- return JSON.stringify(value);
550
- }
551
-
552
- if (typeof value === "object") {
553
- const json = JSON.stringify(value);
554
- // Truncate long objects
555
- if (json.length > 200) {
556
- return json.slice(0, 200) + "...";
557
- }
558
- return json;
559
- }
560
-
561
- return String(value);
562
- }
563
-
564
- /**
565
- * Extract primitive counts from a probe result
566
- */
567
- function extractCounts(result: ProbeResult): PrimitiveCounts {
568
- return {
569
- tools: result.tools?.tools?.length || 0,
570
- prompts: result.prompts?.prompts?.length || 0,
571
- resources: result.resources?.resources?.length || 0,
572
- resourceTemplates: result.resourceTemplates?.resourceTemplates?.length || 0,
573
- };
574
- }
575
-
576
- /**
577
- * Generate a simple line-by-line diff (fallback for non-JSON)
578
- */
579
- function generateSimpleTextDiff(name: string, base: string, branch: string): string | null {
580
- const baseLines = base.split("\n");
581
- const branchLines = branch.split("\n");
582
-
583
- const diffLines: string[] = [];
584
- const maxLines = Math.max(baseLines.length, branchLines.length);
585
-
586
- for (let i = 0; i < maxLines; i++) {
587
- const baseLine = baseLines[i];
588
- const branchLine = branchLines[i];
589
-
590
- if (baseLine !== branchLine) {
591
- if (baseLine !== undefined) {
592
- diffLines.push(`- ${baseLine}`);
593
- }
594
- if (branchLine !== undefined) {
595
- diffLines.push(`+ ${branchLine}`);
596
- }
597
- }
598
- }
599
-
600
- if (diffLines.length === 0) {
601
- return null;
602
- }
603
-
604
- return `--- base/${name}.json\n+++ branch/${name}.json\n${diffLines.join("\n")}`;
605
- }
606
-
607
- /**
608
- * Start a shared HTTP server for all HTTP transport configurations
609
- */
610
- async function startSharedHttpServer(
611
- command: string,
612
- workDir: string,
613
- waitMs: number,
614
- envVars: Record<string, string>
615
- ): Promise<ChildProcess> {
616
- core.info(`šŸš€ Starting HTTP server: ${command}`);
617
-
618
- // Merge environment variables
619
- const env: Record<string, string> = {};
620
- for (const [key, value] of Object.entries(process.env)) {
621
- if (value !== undefined) {
622
- env[key] = value;
623
- }
624
- }
625
- for (const [key, value] of Object.entries(envVars)) {
626
- env[key] = value;
627
- }
628
-
629
- const serverProcess = spawn("sh", ["-c", command], {
630
- cwd: workDir,
631
- env,
632
- detached: true,
633
- stdio: ["ignore", "pipe", "pipe"],
634
- });
635
-
636
- // Log server output for debugging
637
- serverProcess.stdout?.on("data", (data) => {
638
- core.debug(` [HTTP server stdout]: ${data.toString().trim()}`);
639
- });
640
- serverProcess.stderr?.on("data", (data) => {
641
- core.debug(` [HTTP server stderr]: ${data.toString().trim()}`);
642
- });
643
-
644
- core.info(` Waiting ${waitMs}ms for HTTP server to start...`);
645
- await sleep(waitMs);
646
-
647
- // Check if process is still running
648
- if (serverProcess.exitCode !== null) {
649
- throw new Error(`HTTP server exited prematurely with code ${serverProcess.exitCode}`);
650
- }
651
-
652
- core.info(" āœ… HTTP server started");
653
- return serverProcess;
654
- }
655
-
656
- /**
657
- * Probe a single configuration (without comparison)
658
- */
659
- async function probeConfig(
660
- config: TestConfiguration,
661
- workDir: string,
662
- envVars: Record<string, string>,
663
- headers: Record<string, string>,
664
- customMessages: CustomMessage[],
665
- useSharedServer: boolean
666
- ): Promise<{ result: ProbeResult; time: number }> {
667
- core.info(` šŸ“‹ Probing: ${config.name} (${config.transport})`);
668
- const start = Date.now();
669
- let result: ProbeResult;
670
- try {
671
- result = await probeWithConfig(
672
- config,
673
- workDir,
674
- envVars,
675
- headers,
676
- customMessages,
677
- useSharedServer
678
- );
679
- } finally {
680
- await runPostTestCommand(config, workDir);
681
- }
682
- return { result, time: Date.now() - start };
683
- }
684
-
685
- /**
686
- * Run all diff tests using the "probe all, then compare" approach
687
- */
688
- export async function runAllTests(ctx: RunContext): Promise<TestResult[]> {
689
- const globalEnvVars = parseEnvVars(ctx.inputs.envVars);
690
- const globalHeaders = ctx.inputs.headers || {};
691
- const globalCustomMessages = ctx.inputs.customMessages || [];
692
-
693
- // Check if we have a shared HTTP server to manage
694
- const httpStartCommand = ctx.inputs.httpStartCommand;
695
- const httpStartupWaitMs = ctx.inputs.httpStartupWaitMs || 2000;
696
- const hasHttpConfigs = ctx.inputs.configurations.some((c) => c.transport === "streamable-http");
697
- const useSharedServer = !!httpStartCommand && hasHttpConfigs;
698
-
699
- // Store probe results for comparison
700
- const branchResults: Map<string, { result: ProbeResult; time: number }> = new Map();
701
- const baseResults: Map<string, { result: ProbeResult; time: number }> = new Map();
702
-
703
- // ========================================
704
- // PHASE 1: Probe all configs on current branch
705
- // ========================================
706
- core.info("\nšŸ”„ Phase 1: Testing current branch...");
707
-
708
- let sharedServerProcess: ChildProcess | null = null;
709
- try {
710
- // Start shared HTTP server if configured
711
- if (useSharedServer) {
712
- sharedServerProcess = await startSharedHttpServer(
713
- httpStartCommand,
714
- ctx.workDir,
715
- httpStartupWaitMs,
716
- globalEnvVars
717
- );
718
- }
719
-
720
- for (const config of ctx.inputs.configurations) {
721
- const configUsesSharedServer = useSharedServer && config.transport === "streamable-http";
722
- try {
723
- const probeData = await probeConfig(
724
- config,
725
- ctx.workDir,
726
- globalEnvVars,
727
- globalHeaders,
728
- globalCustomMessages,
729
- configUsesSharedServer
730
- );
731
- branchResults.set(config.name, probeData);
732
- } catch (error) {
733
- core.error(`Failed to probe ${config.name} on current branch: ${error}`);
734
- branchResults.set(config.name, {
735
- result: {
736
- initialize: null,
737
- tools: null,
738
- prompts: null,
739
- resources: null,
740
- resourceTemplates: null,
741
- customResponses: new Map(),
742
- error: String(error),
743
- },
744
- time: 0,
745
- });
746
- }
747
- }
748
- } finally {
749
- if (sharedServerProcess) {
750
- core.info("šŸ›‘ Stopping current branch HTTP server...");
751
- stopHttpServer(sharedServerProcess);
752
- await sleep(500); // Give time for port to be released
753
- }
754
- }
755
-
756
- // ========================================
757
- // PHASE 2: Probe all configs on base ref
758
- // ========================================
759
- core.info(`\nšŸ”„ Phase 2: Testing comparison ref: ${ctx.compareRef}...`);
760
-
761
- const worktreePath = path.join(ctx.workDir, ".mcp-diff-base");
762
- let useWorktree = false;
763
-
764
- try {
765
- // Set up comparison ref
766
- useWorktree = await createWorktree(ctx.compareRef, worktreePath);
767
- if (!useWorktree) {
768
- core.info(" Worktree not available, using checkout");
769
- await checkout(ctx.compareRef);
770
- }
771
-
772
- const baseWorkDir = useWorktree ? worktreePath : ctx.workDir;
773
-
774
- // Build on base
775
- core.info("šŸ”Ø Building on comparison ref...");
776
- await runBuild(baseWorkDir, ctx.inputs);
777
-
778
- let baseServerProcess: ChildProcess | null = null;
779
- try {
780
- // Start HTTP server for base ref if needed
781
- if (useSharedServer) {
782
- baseServerProcess = await startSharedHttpServer(
783
- httpStartCommand,
784
- baseWorkDir,
785
- httpStartupWaitMs,
786
- globalEnvVars
787
- );
788
- }
789
-
790
- for (const config of ctx.inputs.configurations) {
791
- const configUsesSharedServer = useSharedServer && config.transport === "streamable-http";
792
- try {
793
- const probeData = await probeConfig(
794
- config,
795
- baseWorkDir,
796
- globalEnvVars,
797
- globalHeaders,
798
- globalCustomMessages,
799
- configUsesSharedServer
800
- );
801
- baseResults.set(config.name, probeData);
802
- } catch (error) {
803
- core.error(`Failed to probe ${config.name} on base ref: ${error}`);
804
- baseResults.set(config.name, {
805
- result: {
806
- initialize: null,
807
- tools: null,
808
- prompts: null,
809
- resources: null,
810
- resourceTemplates: null,
811
- customResponses: new Map(),
812
- error: String(error),
813
- },
814
- time: 0,
815
- });
816
- }
817
- }
818
- } finally {
819
- if (baseServerProcess) {
820
- core.info("šŸ›‘ Stopping base ref HTTP server...");
821
- stopHttpServer(baseServerProcess);
822
- }
823
- }
824
- } finally {
825
- // Clean up
826
- if (useWorktree) {
827
- await removeWorktree(worktreePath);
828
- } else {
829
- await checkoutPrevious();
830
- }
831
- }
832
-
833
- // ========================================
834
- // PHASE 3: Compare all results
835
- // ========================================
836
- core.info("\nšŸ“Š Phase 3: Comparing results...");
837
-
838
- const results: TestResult[] = [];
839
-
840
- for (const config of ctx.inputs.configurations) {
841
- const branchData = branchResults.get(config.name);
842
- const baseData = baseResults.get(config.name);
843
-
844
- const result: TestResult = {
845
- configName: config.name,
846
- transport: config.transport,
847
- branchTime: branchData?.time || 0,
848
- baseTime: baseData?.time || 0,
849
- hasDifferences: false,
850
- diffs: new Map(),
851
- branchCounts: branchData?.result ? extractCounts(branchData.result) : undefined,
852
- baseCounts: baseData?.result ? extractCounts(baseData.result) : undefined,
853
- };
854
-
855
- // Handle errors
856
- if (branchData?.result.error) {
857
- result.hasDifferences = true;
858
- result.diffs.set("error", `Current branch probe failed: ${branchData.result.error}`);
859
- results.push(result);
860
- continue;
861
- }
862
-
863
- if (baseData?.result.error) {
864
- result.hasDifferences = true;
865
- result.diffs.set("error", `Base ref probe failed: ${baseData.result.error}`);
866
- results.push(result);
867
- continue;
868
- }
869
-
870
- // Compare results
871
- const branchFiles = probeResultToFiles(branchData!.result);
872
- const baseFiles = probeResultToFiles(baseData!.result);
873
-
874
- result.diffs = compareResults(branchFiles, baseFiles);
875
- result.hasDifferences = result.diffs.size > 0;
876
-
877
- if (result.hasDifferences) {
878
- core.info(`šŸ“‹ Configuration ${config.name}: ${result.diffs.size} change(s) found`);
879
- } else {
880
- core.info(`āœ… Configuration ${config.name}: no changes`);
881
- }
882
-
883
- // Save individual result
884
- const resultPath = path.join(ctx.workDir, ".mcp-diff-results", `${config.name}.json`);
885
- fs.mkdirSync(path.dirname(resultPath), { recursive: true });
886
- fs.writeFileSync(
887
- resultPath,
888
- JSON.stringify(
889
- {
890
- ...result,
891
- diffs: Object.fromEntries(result.diffs),
892
- },
893
- null,
894
- 2
895
- )
896
- );
897
-
898
- results.push(result);
899
- }
900
-
901
- return results;
902
- }