kickload-watcher-mcp 0.1.0
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 +233 -0
- package/compliance-poller.js +215 -0
- package/config.js +103 -0
- package/email-cooldown.js +23 -0
- package/email-sender.js +137 -0
- package/env-check.js +117 -0
- package/file-watcher.js +230 -0
- package/github-webhook.js +321 -0
- package/identity-map.js +75 -0
- package/index.js +270 -0
- package/kickload-client.js +254 -0
- package/logger.js +118 -0
- package/ngrok-manager.js +313 -0
- package/package.json +51 -0
- package/pipeline.js +448 -0
- package/server-detector.js +109 -0
- package/setup.js +201 -0
- package/test-generator.js +66 -0
- package/users.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kickload-watcher-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Automated API performance testing for Claude Code teams via KickLoad",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"kickload-watcher-mcp": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node index.js",
|
|
12
|
+
"dev": "node --watch index.js",
|
|
13
|
+
"setup": "node setup.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
18
|
+
"chokidar": "^3.6.0",
|
|
19
|
+
"nodemailer": "^6.9.0",
|
|
20
|
+
"node-fetch": "^3.3.0",
|
|
21
|
+
"form-data": "^4.0.0",
|
|
22
|
+
"dotenv": "^16.4.0",
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"unzipper": "^0.10.14",
|
|
25
|
+
"tar": "^6.2.0"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"kickload",
|
|
32
|
+
"mcp",
|
|
33
|
+
"claude",
|
|
34
|
+
"testing",
|
|
35
|
+
"watcher",
|
|
36
|
+
"oneqa"
|
|
37
|
+
],
|
|
38
|
+
"author": "NeeyatAI",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/KickLoad/kickload-watcher-mcp.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/KickLoad/kickload-watcher-mcp",
|
|
45
|
+
"files": [
|
|
46
|
+
"**/*.js",
|
|
47
|
+
"users.json",
|
|
48
|
+
"README.md"
|
|
49
|
+
],
|
|
50
|
+
"preferGlobal": true
|
|
51
|
+
}
|
package/pipeline.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { KickloadClient } from "./kickload-client.js";
|
|
2
|
+
import { generateTestPrompt } from "./test-generator.js";
|
|
3
|
+
import { sendResultEmail } from "./email-sender.js";
|
|
4
|
+
import { findRunningServer, isServerReachable } from "./server-detector.js";
|
|
5
|
+
import { isEmailAllowed, getCooldownRemaining,
|
|
6
|
+
recordEmailSent } from "./email-cooldown.js";
|
|
7
|
+
import { createLogger, logPipelineSummary } from "./logger.js";
|
|
8
|
+
import { getEmailForUser } from "./identity-map.js";
|
|
9
|
+
import { getPublicUrl } from "./ngrok-manager.js";
|
|
10
|
+
import { config } from "./config.js";
|
|
11
|
+
|
|
12
|
+
const logger = createLogger("pipeline");
|
|
13
|
+
|
|
14
|
+
const DEDUP_WINDOW_MS = 10_000;
|
|
15
|
+
const MAX_RETRIES = 2;
|
|
16
|
+
const RETRY_DELAY_MS = 3_000;
|
|
17
|
+
const NGROK_MAX_RETRIES = 3;
|
|
18
|
+
const NGROK_RETRY_DELAY = 5_000;
|
|
19
|
+
|
|
20
|
+
let pipelineRunning = false;
|
|
21
|
+
// PROBLEM 4 — Single pending batch instead of a queue
|
|
22
|
+
let pendingBatch = null; // { endpoints: Set, events: [], devEmail, ... }
|
|
23
|
+
const recentTriggers = new Map();
|
|
24
|
+
|
|
25
|
+
// ── Public event handlers ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export async function handleFileEvent(fileEvent) {
|
|
28
|
+
const userId = fileEvent.userId || config.identity.defaultUserId || null;
|
|
29
|
+
const devEmail = getEmailForUser(userId);
|
|
30
|
+
if (!devEmail) logger.warn("No developer email — notifications disabled");
|
|
31
|
+
enqueue({ ...fileEvent, userId, devEmail, originalPrompt: "Code saved via Claude Code" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function handleComplianceSession(session) {
|
|
35
|
+
const { userId, generatedCode, prompt, sessionId, timestamp } = session;
|
|
36
|
+
const devEmail = getEmailForUser(userId);
|
|
37
|
+
if (!devEmail) {
|
|
38
|
+
logger.warn(`No email for user "${userId}" — skipping session ${sessionId}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const detectedEndpoints = extractEndpointsFromText(generatedCode);
|
|
42
|
+
if (!detectedEndpoints.length) { logger.info(`No endpoints in session ${sessionId}`); return; }
|
|
43
|
+
enqueue({
|
|
44
|
+
filePath: `compliance-session-${sessionId}`, fileName: `session_${sessionId}.js`,
|
|
45
|
+
fileContent: generatedCode, detectedEndpoints, timestamp,
|
|
46
|
+
source: "compliance_api", userId, devEmail, originalPrompt: prompt,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleGithubEvent(prEvent) {
|
|
51
|
+
const { prNumber, prTitle, author, authorEmail, files, combinedPatch, repoFullName, source } = prEvent;
|
|
52
|
+
const devEmail = authorEmail || getEmailForUser(null);
|
|
53
|
+
if (!devEmail) logger.warn(`PR #${prNumber} — no email resolved`);
|
|
54
|
+
|
|
55
|
+
const allEndpoints = new Set();
|
|
56
|
+
for (const f of files) extractEndpointsFromText(f.patch || "").forEach(ep => allEndpoints.add(ep));
|
|
57
|
+
if (!allEndpoints.size) { logger.info(`PR #${prNumber} — no endpoints in diff`); return; }
|
|
58
|
+
|
|
59
|
+
enqueue({
|
|
60
|
+
filePath: `github-pr-${prNumber}`, fileName: files[0]?.filename || `PR #${prNumber}`,
|
|
61
|
+
fileContent: combinedPatch, detectedEndpoints: [...allEndpoints],
|
|
62
|
+
timestamp: prEvent.timestamp, source, userId: null, devEmail,
|
|
63
|
+
originalPrompt: `GitHub PR #${prNumber}: ${prTitle} by ${author} on ${repoFullName}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── PROBLEM 4 — Merged batch queue ───────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function enqueue(event) {
|
|
70
|
+
const key = `${event.devEmail}:${event.detectedEndpoints?.[0] || "?"}`;
|
|
71
|
+
const last = recentTriggers.get(key);
|
|
72
|
+
|
|
73
|
+
if (last && Date.now() - last < DEDUP_WINDOW_MS) {
|
|
74
|
+
logger.info(`Duplicate suppressed (${DEDUP_WINDOW_MS / 1000}s window): ${key}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
recentTriggers.set(key, Date.now());
|
|
78
|
+
for (const [k, ts] of recentTriggers) {
|
|
79
|
+
if (Date.now() - ts > DEDUP_WINDOW_MS * 3) recentTriggers.delete(k);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (pipelineRunning) {
|
|
83
|
+
// Merge into pending batch — no duplicates, no separate executions
|
|
84
|
+
if (!pendingBatch) {
|
|
85
|
+
pendingBatch = {
|
|
86
|
+
...event,
|
|
87
|
+
detectedEndpoints: new Set(event.detectedEndpoints || []),
|
|
88
|
+
fileNames: [event.fileName],
|
|
89
|
+
};
|
|
90
|
+
logger.info(`Pipeline busy — created pending batch for: ${event.fileName}`);
|
|
91
|
+
} else {
|
|
92
|
+
// Merge endpoints using Set
|
|
93
|
+
(event.detectedEndpoints || []).forEach(ep => pendingBatch.detectedEndpoints.add(ep));
|
|
94
|
+
pendingBatch.fileNames.push(event.fileName);
|
|
95
|
+
// Use most recent prompt/content
|
|
96
|
+
pendingBatch.fileContent = event.fileContent;
|
|
97
|
+
pendingBatch.originalPrompt = event.originalPrompt;
|
|
98
|
+
logger.info(`Pipeline busy — merged into batch: ${event.fileName} (${pendingBatch.detectedEndpoints.size} total endpoints)`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
runNext(event);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function runNext(event) {
|
|
107
|
+
pipelineRunning = true;
|
|
108
|
+
try {
|
|
109
|
+
// Normalize event: convert Set → Array if needed
|
|
110
|
+
const normalized = {
|
|
111
|
+
...event,
|
|
112
|
+
detectedEndpoints: event.detectedEndpoints instanceof Set
|
|
113
|
+
? [...event.detectedEndpoints]
|
|
114
|
+
: (event.detectedEndpoints || []),
|
|
115
|
+
};
|
|
116
|
+
await runPhase1Pipeline(normalized);
|
|
117
|
+
} finally {
|
|
118
|
+
pipelineRunning = false;
|
|
119
|
+
if (pendingBatch) {
|
|
120
|
+
const batch = {
|
|
121
|
+
...pendingBatch,
|
|
122
|
+
detectedEndpoints: [...pendingBatch.detectedEndpoints],
|
|
123
|
+
fileName: pendingBatch.fileNames.join(", "),
|
|
124
|
+
};
|
|
125
|
+
pendingBatch = null;
|
|
126
|
+
logger.info(`Running merged batch: ${batch.detectedEndpoints.length} endpoints from ${batch.fileNames?.length || 1} file(s)`);
|
|
127
|
+
runNext(batch);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Main pipeline ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export async function runPhase1Pipeline(event) {
|
|
135
|
+
const { fileName, fileContent, detectedEndpoints, originalPrompt, devEmail, source, jmxFilePath } = event;
|
|
136
|
+
const primaryEndpoint = detectedEndpoints?.[0] || "unknown";
|
|
137
|
+
|
|
138
|
+
console.log(`\n${"═".repeat(60)}`);
|
|
139
|
+
console.log(`OneQA — Pipeline triggered`);
|
|
140
|
+
console.log(` Source : ${source}`);
|
|
141
|
+
console.log(` File : ${fileName}`);
|
|
142
|
+
console.log(` Endpoints : ${detectedEndpoints?.join(", ") || "none"}`);
|
|
143
|
+
console.log(` Developer : ${devEmail || "none"}`);
|
|
144
|
+
console.log(`${"═".repeat(60)}\n`);
|
|
145
|
+
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// 1. Validate endpoints
|
|
150
|
+
const endpoints = validateEndpoints(detectedEndpoints);
|
|
151
|
+
if (!endpoints) {
|
|
152
|
+
logger.warn("No valid endpoints — skipping pipeline.");
|
|
153
|
+
return { success: false, skipped: true, reason: "invalid_endpoints" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Detect backend
|
|
157
|
+
logger.step("Detecting backend");
|
|
158
|
+
let backendUrl = await resolveBackendUrl();
|
|
159
|
+
|
|
160
|
+
if (!backendUrl) {
|
|
161
|
+
logger.warn("No running backend detected.");
|
|
162
|
+
logger.warn("Start your server (e.g. npm start / python app.py / go run main.go) then save any API file.");
|
|
163
|
+
await safeNotify({ devEmail, endpoints, fileName, source, reason: "No backend server found on common ports (3000, 5000, 8000, 8080, 4000, 8888). Start your server and save an API file to trigger again." });
|
|
164
|
+
return { success: false, skipped: true, reason: "backend_not_running" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
logger.info(`✅ Backend detected: ${backendUrl}`);
|
|
168
|
+
|
|
169
|
+
// 3. PROBLEM 1 — Auto-ngrok: ALWAYS use ngrok for localhost, regardless of config flag
|
|
170
|
+
if (isLocalhost(backendUrl)) {
|
|
171
|
+
logger.info("🔗 Localhost detected → enabling ngrok automatically");
|
|
172
|
+
|
|
173
|
+
if (!config.ngrok.authToken) {
|
|
174
|
+
logger.warn("⚠️ NGROK_AUTHTOKEN not set — tunnel may fail to authenticate");
|
|
175
|
+
logger.warn(" Get a free token at: https://dashboard.ngrok.com");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// PROBLEM 5 — Retry ngrok with clear failure handling
|
|
179
|
+
logger.step("Starting ngrok tunnel (auto-mode)");
|
|
180
|
+
let tunnelUrl = null;
|
|
181
|
+
let ngrokErr = null;
|
|
182
|
+
|
|
183
|
+
for (let attempt = 1; attempt <= NGROK_MAX_RETRIES; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
tunnelUrl = await getPublicUrl(backendUrl);
|
|
186
|
+
break;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
ngrokErr = err;
|
|
189
|
+
if (attempt < NGROK_MAX_RETRIES) {
|
|
190
|
+
logger.warn(`ngrok attempt ${attempt}/${NGROK_MAX_RETRIES} failed — retrying in ${NGROK_RETRY_DELAY / 1000}s (${err.message})`);
|
|
191
|
+
await sleep(NGROK_RETRY_DELAY);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!tunnelUrl) {
|
|
197
|
+
// PROBLEM 5 — Log clearly, notify, continue watcher — do NOT crash
|
|
198
|
+
const reason = `ngrok failed after ${NGROK_MAX_RETRIES} attempts: ${ngrokErr?.message}`;
|
|
199
|
+
logger.error(`ngrok failed after retries — skipping test`);
|
|
200
|
+
logger.error(` Reason : ${ngrokErr?.message}`);
|
|
201
|
+
logger.error(` Fix : Add NGROK_AUTHTOKEN to .env (https://dashboard.ngrok.com)`);
|
|
202
|
+
await safeNotify({ devEmail, endpoints, fileName, source, reason });
|
|
203
|
+
// System stays alive — return without crashing
|
|
204
|
+
return { success: false, skipped: true, reason: "ngrok_failed" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
backendUrl = tunnelUrl;
|
|
208
|
+
logger.info(`✅ ngrok tunnel ready: ${backendUrl}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Hard stop — localhost must never reach KickLoad
|
|
212
|
+
if (isLocalhost(backendUrl)) {
|
|
213
|
+
logger.error("HARD STOP: localhost URL still present after ngrok step. Aborting.");
|
|
214
|
+
return { success: false, skipped: true, reason: "localhost_not_allowed" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
logger.info(`🚀 Test target: ${backendUrl}`);
|
|
218
|
+
|
|
219
|
+
// 4. Generate test prompt
|
|
220
|
+
logger.step("Generating test prompt");
|
|
221
|
+
const testParams = await generateTestPrompt({
|
|
222
|
+
fileContent, detectedEndpoints: endpoints, originalPrompt, fileName, backendBaseUrl: backendUrl,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// 5. KickLoad pipeline
|
|
226
|
+
const client = new KickloadClient(config.kickload.baseUrl, config.kickload.apiToken);
|
|
227
|
+
|
|
228
|
+
logger.step("Generating test plan");
|
|
229
|
+
const { jmx_filename: jmxFilename } = await withRetry(
|
|
230
|
+
() => client.generateTestPlan({ prompt: testParams.testPrompt, jmxFilePath: jmxFilePath || null }),
|
|
231
|
+
"generate-test-plan"
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
logger.step("Running test");
|
|
235
|
+
const { task_id: taskId } = await withRetry(
|
|
236
|
+
() => client.runTest(jmxFilename, { numThreads: testParams.numThreads, loopCount: testParams.loopCount, rampTime: testParams.rampTime }),
|
|
237
|
+
"run-test"
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
logger.step("Waiting for results");
|
|
241
|
+
const taskResult = await client.pollUntilDone(taskId);
|
|
242
|
+
|
|
243
|
+
logger.step("Analyzing results");
|
|
244
|
+
let analysisResult = { filename: taskResult.pdf_file };
|
|
245
|
+
if (taskResult.result_file) {
|
|
246
|
+
try {
|
|
247
|
+
analysisResult = await withRetry(() => client.analyzeJtl(taskResult.result_file), "analyze-jtl");
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger.warn(`JTL analysis failed (non-fatal): ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const pdfFilename = analysisResult.filename || taskResult.pdf_file;
|
|
254
|
+
const downloadUrl = await client.getDownloadUrl(pdfFilename);
|
|
255
|
+
const totalMs = Date.now() - start;
|
|
256
|
+
|
|
257
|
+
const summary = parseSummary(taskResult.summary_output || taskResult.summary);
|
|
258
|
+
|
|
259
|
+
logPipelineSummary({
|
|
260
|
+
endpoint: endpoints.join(", "),
|
|
261
|
+
passed: summary.passed,
|
|
262
|
+
latency: summary.averageLatency,
|
|
263
|
+
errorRate: summary.errorPercentage,
|
|
264
|
+
throughput: summary.throughput,
|
|
265
|
+
durationSec: Math.round(totalMs / 1000),
|
|
266
|
+
emailSent: !!devEmail,
|
|
267
|
+
devEmail,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// 6. Send result email — never let email failure kill the pipeline
|
|
271
|
+
if (devEmail) {
|
|
272
|
+
await safeResultEmail({
|
|
273
|
+
toEmail: devEmail, endpoint: endpoints.join(", "), passed: summary.passed,
|
|
274
|
+
summary, downloadUrl, pdfFilename, durationMs: totalMs, testPrompt: testParams.testPrompt,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { success: true, backendUrl, jmxFilename, taskId, jtlFilename: taskResult.result_file, pdfFilename, downloadUrl, summary };
|
|
279
|
+
|
|
280
|
+
} catch (err) {
|
|
281
|
+
logger.error(`Pipeline failed: ${err.message}`);
|
|
282
|
+
|
|
283
|
+
if (devEmail) {
|
|
284
|
+
await safeResultEmail({
|
|
285
|
+
toEmail: devEmail, endpoint: primaryEndpoint, passed: false,
|
|
286
|
+
summary: { averageLatency: null, errorPercentage: null, throughput: null },
|
|
287
|
+
downloadUrl: null, pdfFilename: null, durationMs: Date.now() - start, testPrompt: "Pipeline failed",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { success: false, error: err.message };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Safe email wrappers (never throw) ────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
async function safeResultEmail(params) {
|
|
298
|
+
const id = params.endpoint || "result";
|
|
299
|
+
if (!isEmailAllowed(params.toEmail, id)) {
|
|
300
|
+
logger.info(`Email cooldown: ${getCooldownRemaining(params.toEmail, id)}s — skipping`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await sendResultEmail(params);
|
|
305
|
+
recordEmailSent(params.toEmail, id);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
logger.error(`Email failed (pipeline continues): ${err.message}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function safeNotify({ devEmail, endpoints, fileName, source, reason }) {
|
|
312
|
+
if (!devEmail) return;
|
|
313
|
+
const id = "backend-not-running";
|
|
314
|
+
if (!isEmailAllowed(devEmail, id)) {
|
|
315
|
+
logger.info(`Notification cooldown: ${getCooldownRemaining(devEmail, id)}s — skipping`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await sendSkippedEmail({ toEmail: devEmail, endpoints, fileName, source, reason });
|
|
320
|
+
recordEmailSent(devEmail, id);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
logger.error(`Notification email failed (non-fatal): ${err.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function sendSkippedEmail({ toEmail, endpoints, fileName, source, reason }) {
|
|
327
|
+
const rows = endpoints.map(ep =>
|
|
328
|
+
`<tr><td style="padding:10px 16px;font-size:13px;color:#374151;font-family:monospace;">${ep}</td></tr>`
|
|
329
|
+
).join("");
|
|
330
|
+
|
|
331
|
+
const html = `<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;">
|
|
332
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;"><tr><td align="center">
|
|
333
|
+
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);">
|
|
334
|
+
<tr><td style="background:linear-gradient(135deg,#1e202a,#303952);padding:28px 32px;">
|
|
335
|
+
<h1 style="margin:0;color:#fff;font-size:20px;font-weight:800;">KickLoad Watcher</h1>
|
|
336
|
+
<p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">OneQA — Automated API Testing</p>
|
|
337
|
+
</td></tr>
|
|
338
|
+
<tr><td style="padding:28px 32px 0;">
|
|
339
|
+
<div style="background:#fefce8;border:1px solid #fde68a;border-radius:10px;padding:16px 20px;">
|
|
340
|
+
<div style="font-size:18px;font-weight:800;color:#b45309;">⚠️ Testing Skipped</div>
|
|
341
|
+
<div style="font-size:13px;color:#92400e;margin-top:4px;">Backend server not reachable</div>
|
|
342
|
+
</div>
|
|
343
|
+
</td></tr>
|
|
344
|
+
<tr><td style="padding:20px 32px 0;"><p style="margin:0;color:#374151;font-size:14px;line-height:1.7;">
|
|
345
|
+
API changes detected in <strong>${fileName}</strong> but no backend was found. <strong>No tests ran. No KickLoad credits used.</strong>
|
|
346
|
+
</p></td></tr>
|
|
347
|
+
<tr><td style="padding:12px 32px 0;"><p style="margin:0;color:#dc2626;font-size:13px;">${reason || ""}</p></td></tr>
|
|
348
|
+
<tr><td style="padding:20px 32px 0;">
|
|
349
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
|
350
|
+
<tr style="background:#f9fafb;"><td style="padding:10px 16px;font-size:12px;font-weight:700;color:#9ca3af;text-transform:uppercase;border-bottom:1px solid #e5e7eb;">Detected endpoints</td></tr>
|
|
351
|
+
${rows}
|
|
352
|
+
</table>
|
|
353
|
+
</td></tr>
|
|
354
|
+
<tr><td style="padding:20px 32px;">
|
|
355
|
+
<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:14px 18px;">
|
|
356
|
+
<div style="font-size:13px;color:#166534;line-height:1.8;">
|
|
357
|
+
<strong>To trigger a test:</strong><br>
|
|
358
|
+
1. Start your backend (e.g. <code>npm start</code> / <code>python app.py</code> / <code>go run main.go</code>)<br>
|
|
359
|
+
2. Save any API file — the watcher will detect and test it automatically.
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</td></tr>
|
|
363
|
+
<tr><td style="padding:16px 32px;"><p style="margin:0;color:#9ca3af;font-size:12px;text-align:center;">KickLoad Watcher · <a href="https://kickload.neeyatai.com" style="color:#f47c20;text-decoration:none;">NeeyatAI</a></p></td></tr>
|
|
364
|
+
</table></td></tr></table></body></html>`;
|
|
365
|
+
|
|
366
|
+
const text =
|
|
367
|
+
`Testing Skipped — backend not reachable\n` +
|
|
368
|
+
`File: ${fileName}\nSource: ${source}\n` +
|
|
369
|
+
`Reason: ${reason || "unknown"}\n` +
|
|
370
|
+
`Endpoints: ${endpoints.join(", ")}\n\n` +
|
|
371
|
+
`To trigger a test:\n` +
|
|
372
|
+
` 1. Start your backend (npm start / python app.py / go run main.go)\n` +
|
|
373
|
+
` 2. Save any API file — the watcher will detect and run automatically.\n`;
|
|
374
|
+
|
|
375
|
+
const { _getTransporter } = await import("./email-sender.js");
|
|
376
|
+
const transporter = _getTransporter();
|
|
377
|
+
if (!transporter) { logger.warn("Email transporter not ready — skipping notification"); return; }
|
|
378
|
+
|
|
379
|
+
await transporter.sendMail({
|
|
380
|
+
from: `"KickLoad Watcher" <${config.email.fromAddress}>`,
|
|
381
|
+
to: toEmail,
|
|
382
|
+
subject: "API Testing Skipped — Backend Not Running",
|
|
383
|
+
html,
|
|
384
|
+
text,
|
|
385
|
+
});
|
|
386
|
+
logger.info(`📧 Notification → ${toEmail}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function isLocalhost(url) {
|
|
392
|
+
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function validateEndpoints(endpoints) {
|
|
396
|
+
if (!Array.isArray(endpoints) || !endpoints.length) return null;
|
|
397
|
+
const valid = endpoints.filter(ep => typeof ep === "string" && ep.startsWith("/") && ep.length > 1);
|
|
398
|
+
return valid.length ? valid : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function resolveBackendUrl() {
|
|
402
|
+
if (config.targetApiBaseUrl) {
|
|
403
|
+
logger.info(`Checking TARGET_API_BASE_URL: ${config.targetApiBaseUrl}`);
|
|
404
|
+
if (await isServerReachable(config.targetApiBaseUrl)) return config.targetApiBaseUrl;
|
|
405
|
+
logger.warn("TARGET_API_BASE_URL not reachable — scanning localhost");
|
|
406
|
+
}
|
|
407
|
+
return findRunningServer();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function withRetry(fn, label) {
|
|
411
|
+
let lastErr;
|
|
412
|
+
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
|
|
413
|
+
try {
|
|
414
|
+
return await fn();
|
|
415
|
+
} catch (err) {
|
|
416
|
+
lastErr = err;
|
|
417
|
+
const status = err.status || 0;
|
|
418
|
+
if (status >= 400 && status < 500 && status !== 429) throw err;
|
|
419
|
+
if (attempt <= MAX_RETRIES) {
|
|
420
|
+
logger.warn(`[${label}] attempt ${attempt} failed — retrying in ${RETRY_DELAY_MS / 1000}s (${err.message})`);
|
|
421
|
+
await sleep(RETRY_DELAY_MS);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
throw lastErr;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseSummary(raw = {}) {
|
|
429
|
+
const avgLatency = raw?.averageLatency ?? raw?.average_latency ?? null;
|
|
430
|
+
const errorPct = raw?.errorPercentage ?? raw?.error_rate ?? null;
|
|
431
|
+
const throughput = raw?.throughput ?? null;
|
|
432
|
+
const isFail = (errorPct !== null && errorPct > 5) || (avgLatency !== null && avgLatency > 2000);
|
|
433
|
+
return { passed: !isFail, averageLatency: avgLatency, errorPercentage: errorPct, throughput };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function extractEndpointsFromText(content) {
|
|
437
|
+
if (!content) return [];
|
|
438
|
+
const endpoints = new Set();
|
|
439
|
+
const pattern = /['"`](\/[a-z0-9/_:.-]{2,})['"`]/gi;
|
|
440
|
+
let m;
|
|
441
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
442
|
+
const ep = m[1];
|
|
443
|
+
if (ep.startsWith("/api") || ep.includes("/v1") || ep.includes("/v2")) endpoints.add(ep);
|
|
444
|
+
}
|
|
445
|
+
return [...endpoints];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const logger = createLogger("server-detector");
|
|
6
|
+
const CANDIDATE_PORTS = [3000, 5000, 8000, 8080, 4000, 8888];
|
|
7
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
8
|
+
|
|
9
|
+
export async function findRunningServer() {
|
|
10
|
+
const envPort = process.env.BACKEND_PORT ? [parseInt(process.env.BACKEND_PORT)] : [];
|
|
11
|
+
const allPorts = [...new Set([...envPort, ...CANDIDATE_PORTS])];
|
|
12
|
+
|
|
13
|
+
logger.info(`Probing ${allPorts.length} ports for a running backend...`);
|
|
14
|
+
|
|
15
|
+
for (const port of allPorts) {
|
|
16
|
+
const url = `http://localhost:${port}`;
|
|
17
|
+
const result = await probeBackend(url);
|
|
18
|
+
if (result.confirmed) {
|
|
19
|
+
logger.info(`Backend confirmed at ${url} — ${result.detail}`);
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
logger.debug(`Port ${port}: ${result.detail}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
logger.warn(`No backend found on ports: ${allPorts.join(", ")}`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function isServerReachable(baseUrl) {
|
|
30
|
+
const result = await probeBackend(baseUrl.replace(/\/$/, ""));
|
|
31
|
+
if (result.confirmed) {
|
|
32
|
+
logger.info(`Server reachable: ${baseUrl}`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
logger.warn(`Server not reachable: ${baseUrl} — ${result.detail}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function probeBackend(baseUrl) {
|
|
40
|
+
const health = await httpProbe(`${baseUrl}/health`);
|
|
41
|
+
|
|
42
|
+
if (health.error === "ECONNREFUSED" || health.error === "timeout") {
|
|
43
|
+
return { confirmed: false, detail: `port closed (${health.error})` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (health.statusCode === 200) {
|
|
47
|
+
return { confirmed: true, detail: `/health → 200` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const root = await httpProbe(`${baseUrl}/`);
|
|
51
|
+
|
|
52
|
+
if (root.error === "ECONNREFUSED" || root.error === "timeout") {
|
|
53
|
+
return { confirmed: false, detail: `/ unreachable (${root.error})` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (root.statusCode !== null) {
|
|
57
|
+
if (root.isHtml) {
|
|
58
|
+
logger.debug(`${baseUrl}/ returned HTML — likely frontend dev server`);
|
|
59
|
+
} else if (root.statusCode >= 200 && root.statusCode < 500) {
|
|
60
|
+
return { confirmed: true, detail: `/ → ${root.statusCode}` };
|
|
61
|
+
} else if (root.statusCode >= 500) {
|
|
62
|
+
return { confirmed: true, detail: `/ → ${root.statusCode} (server error)` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const api = await httpProbe(`${baseUrl}/api`);
|
|
67
|
+
if (api.statusCode !== null && !api.isHtml && api.statusCode < 500) {
|
|
68
|
+
return { confirmed: true, detail: `/api → ${api.statusCode}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { confirmed: false, detail: "all responses were HTML or empty" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function httpProbe(url) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const lib = url.startsWith("https") ? https : http;
|
|
77
|
+
let settled = false;
|
|
78
|
+
|
|
79
|
+
const settle = (result) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
resolve(result);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
req.destroy();
|
|
88
|
+
settle({ statusCode: null, isHtml: false, error: "timeout" });
|
|
89
|
+
}, PROBE_TIMEOUT_MS);
|
|
90
|
+
|
|
91
|
+
const req = lib.get(url, { timeout: PROBE_TIMEOUT_MS }, (res) => {
|
|
92
|
+
const isHtml = (res.headers["content-type"] || "").toLowerCase().includes("text/html");
|
|
93
|
+
res.resume();
|
|
94
|
+
settle({ statusCode: res.statusCode, isHtml, error: null });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
req.on("error", (err) => {
|
|
98
|
+
const code = err.code === "ECONNREFUSED" ? "ECONNREFUSED"
|
|
99
|
+
: err.code === "ENOTFOUND" ? "ENOTFOUND"
|
|
100
|
+
: err.code || "unknown";
|
|
101
|
+
settle({ statusCode: null, isHtml: false, error: code });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
req.on("timeout", () => {
|
|
105
|
+
req.destroy();
|
|
106
|
+
settle({ statusCode: null, isHtml: false, error: "timeout" });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|