mcp-server-diff 2.1.0 ā 2.1.5
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/README.md +108 -1
- package/dist/cli/index.js +205 -9
- package/dist/index.js +32 -5
- package/package.json +6 -1
- package/.github/dependabot.yml +0 -21
- package/.github/workflows/ci.yml +0 -51
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/release.yml +0 -51
- package/.prettierignore +0 -3
- package/.prettierrc +0 -8
- package/CONTRIBUTING.md +0 -81
- package/action.yml +0 -250
- package/eslint.config.mjs +0 -47
- package/jest.config.mjs +0 -26
- package/src/__tests__/fixtures/http-server.ts +0 -103
- package/src/__tests__/fixtures/stdio-server.ts +0 -158
- package/src/__tests__/integration.test.ts +0 -306
- package/src/__tests__/runner.test.ts +0 -430
- package/src/cli.ts +0 -421
- package/src/diff.ts +0 -252
- package/src/git.ts +0 -262
- package/src/index.ts +0 -284
- package/src/logger.ts +0 -93
- package/src/probe.ts +0 -327
- package/src/reporter.ts +0 -214
- package/src/runner.ts +0 -902
- package/src/types.ts +0 -155
- package/tsconfig.json +0 -30
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
|
-
}
|