kickload-watcher-mcp 0.1.3 → 0.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/email-sender.js +141 -137
- package/kickload-client.js +14 -10
- package/package.json +1 -1
- package/pipeline.js +95 -6
package/email-sender.js
CHANGED
|
@@ -1,137 +1,141 @@
|
|
|
1
|
-
import nodemailer from "nodemailer";
|
|
2
|
-
import { config } from "./config.js";
|
|
3
|
-
|
|
4
|
-
let transporter = null;
|
|
5
|
-
|
|
6
|
-
export function _getTransporter() { return transporter; }
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Initialize email transporter.
|
|
10
|
-
* Does NOT verify — call verifyEmailService() for startup check.
|
|
11
|
-
*/
|
|
12
|
-
export function initEmailSender() {
|
|
13
|
-
const p = config.email.provider;
|
|
14
|
-
|
|
15
|
-
if (p === "smtp") {
|
|
16
|
-
transporter = nodemailer.createTransport({
|
|
17
|
-
host: config.email.smtp.host,
|
|
18
|
-
port: config.email.smtp.port,
|
|
19
|
-
secure: config.email.smtp.port === 465,
|
|
20
|
-
auth: { user: config.email.smtp.user, pass: config.email.smtp.pass },
|
|
21
|
-
});
|
|
22
|
-
} else if (p === "sendgrid") {
|
|
23
|
-
transporter = nodemailer.createTransport({
|
|
24
|
-
host: "smtp.sendgrid.net", port: 587,
|
|
25
|
-
auth: { user: "apikey", pass: config.email.sendgrid.apiKey },
|
|
26
|
-
});
|
|
27
|
-
} else if (p === "ses") {
|
|
28
|
-
transporter = nodemailer.createTransport({
|
|
29
|
-
host: `email-smtp.${config.email.ses.region}.amazonaws.com`,
|
|
30
|
-
port: 587, secure: false,
|
|
31
|
-
auth: { user: config.email.ses.accessKey, pass: config.email.ses.secretKey },
|
|
32
|
-
});
|
|
33
|
-
} else {
|
|
34
|
-
throw new Error(`Unknown email provider: ${p}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* PROBLEM 2 — Email strict mode.
|
|
40
|
-
* Verifies the transporter can authenticate.
|
|
41
|
-
* Exits process if verification fails.
|
|
42
|
-
*/
|
|
43
|
-
export async function verifyEmailService() {
|
|
44
|
-
if (!transporter) {
|
|
45
|
-
console.error("\n❌ Email transporter not initialized before verify()");
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
process.stdout.write(" Verifying email service... ");
|
|
50
|
-
try {
|
|
51
|
-
await transporter.verify();
|
|
52
|
-
console.log("✅ Email service verified");
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.log(""); // newline after the "..."
|
|
55
|
-
console.error("\n❌ Email verification failed
|
|
56
|
-
console.error(
|
|
57
|
-
console.error(
|
|
58
|
-
console.error(
|
|
59
|
-
console.error("
|
|
60
|
-
console.error(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<tr><td style="padding:28px 32px
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<tr><td style="padding:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
|
|
4
|
+
let transporter = null;
|
|
5
|
+
|
|
6
|
+
export function _getTransporter() { return transporter; }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Initialize email transporter.
|
|
10
|
+
* Does NOT verify — call verifyEmailService() for startup check.
|
|
11
|
+
*/
|
|
12
|
+
export function initEmailSender() {
|
|
13
|
+
const p = config.email.provider;
|
|
14
|
+
|
|
15
|
+
if (p === "smtp") {
|
|
16
|
+
transporter = nodemailer.createTransport({
|
|
17
|
+
host: config.email.smtp.host,
|
|
18
|
+
port: config.email.smtp.port,
|
|
19
|
+
secure: config.email.smtp.port === 465,
|
|
20
|
+
auth: { user: config.email.smtp.user, pass: config.email.smtp.pass },
|
|
21
|
+
});
|
|
22
|
+
} else if (p === "sendgrid") {
|
|
23
|
+
transporter = nodemailer.createTransport({
|
|
24
|
+
host: "smtp.sendgrid.net", port: 587,
|
|
25
|
+
auth: { user: "apikey", pass: config.email.sendgrid.apiKey },
|
|
26
|
+
});
|
|
27
|
+
} else if (p === "ses") {
|
|
28
|
+
transporter = nodemailer.createTransport({
|
|
29
|
+
host: `email-smtp.${config.email.ses.region}.amazonaws.com`,
|
|
30
|
+
port: 587, secure: false,
|
|
31
|
+
auth: { user: config.email.ses.accessKey, pass: config.email.ses.secretKey },
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
throw new Error(`Unknown email provider: ${p}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PROBLEM 2 — Email strict mode.
|
|
40
|
+
* Verifies the transporter can authenticate.
|
|
41
|
+
* Exits process if verification fails.
|
|
42
|
+
*/
|
|
43
|
+
export async function verifyEmailService() {
|
|
44
|
+
if (!transporter) {
|
|
45
|
+
console.error("\n❌ Email transporter not initialized before verify()");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
process.stdout.write(" Verifying email service... ");
|
|
50
|
+
try {
|
|
51
|
+
await transporter.verify();
|
|
52
|
+
console.log("✅ Email service verified");
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.log(""); // newline after the "..."
|
|
55
|
+
console.error("\n❌ Email verification failed");
|
|
56
|
+
console.error(" Fix:");
|
|
57
|
+
console.error(" • Use Gmail App Password (NOT your real password)");
|
|
58
|
+
console.error(" • Enable 2-Step Verification");
|
|
59
|
+
console.error(" • Check SMTP_USER and SMTP_PASS\n");
|
|
60
|
+
console.error(` Provider : ${config.email.provider}`);
|
|
61
|
+
console.error(` User : ${config.email.smtp.user || "(none)"}`);
|
|
62
|
+
console.error(` Error : ${err.message}`);
|
|
63
|
+
console.error("\n Fix: check SMTP_USER / SMTP_PASS in .env");
|
|
64
|
+
console.error(" For Gmail, use an App Password: myaccount.google.com/apppasswords\n");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function sendResultEmail({ toEmail, endpoint, passed, summary, downloadUrl, pdfFilename, durationMs, testPrompt }) {
|
|
70
|
+
if (!transporter) throw new Error("Email sender not initialized");
|
|
71
|
+
|
|
72
|
+
const status = passed ? "✅ PASS" : "❌ FAIL";
|
|
73
|
+
const statusColor = passed ? "#16a34a" : "#dc2626";
|
|
74
|
+
const statusBg = passed ? "#f0fdf4" : "#fef2f2";
|
|
75
|
+
const statusBorder = passed ? "#86efac" : "#fca5a5";
|
|
76
|
+
const durationSec = durationMs ? Math.round(durationMs / 1000) : null;
|
|
77
|
+
const testedAt = new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "medium", timeStyle: "short" });
|
|
78
|
+
const subject = `[KickLoad Watcher] ${status} — ${endpoint}`;
|
|
79
|
+
|
|
80
|
+
const rows = [
|
|
81
|
+
["Endpoint", `<span style="font-family:monospace">${endpoint}</span>`],
|
|
82
|
+
["Tested At", `${testedAt} IST`],
|
|
83
|
+
summary?.averageLatency != null ? ["Latency", `${summary.averageLatency}ms`] : null,
|
|
84
|
+
summary?.errorPercentage != null ? ["Error Rate", `${summary.errorPercentage}%`] : null,
|
|
85
|
+
summary?.throughput != null ? ["Throughput", `${summary.throughput?.toLocaleString()} req/s`] : null,
|
|
86
|
+
durationSec != null ? ["Duration", `${durationSec}s`] : null,
|
|
87
|
+
].filter(Boolean).map(([label, value]) =>
|
|
88
|
+
`<tr style="border-bottom:1px solid #f3f4f6;">
|
|
89
|
+
<td style="padding:8px 16px;color:#6b7280;font-size:14px;">${label}</td>
|
|
90
|
+
<td style="padding:8px 16px;color:#111827;font-size:14px;font-weight:600;text-align:right;">${value}</td>
|
|
91
|
+
</tr>`
|
|
92
|
+
).join("");
|
|
93
|
+
|
|
94
|
+
const downloadBtn = downloadUrl
|
|
95
|
+
? `<a href="${downloadUrl}" style="display:inline-block;background:#f47c20;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:700;font-size:14px;">Download Report</a>
|
|
96
|
+
<p style="color:#9ca3af;font-size:12px;margin-top:8px;">${pdfFilename || ""} · expires in 24hrs</p>`
|
|
97
|
+
: `<p style="color:#9ca3af;font-size:13px;">Report available in KickLoad dashboard.</p>`;
|
|
98
|
+
|
|
99
|
+
const html = `<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,Arial,sans-serif;">
|
|
100
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;"><tr><td align="center">
|
|
101
|
+
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
|
102
|
+
<tr><td style="background:linear-gradient(135deg,#1e202a,#303952);padding:28px 32px;">
|
|
103
|
+
<h1 style="margin:0;color:#fff;font-size:22px;font-weight:800;">KickLoad Watcher</h1>
|
|
104
|
+
<p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">OneQA — Automated API Testing</p>
|
|
105
|
+
</td></tr>
|
|
106
|
+
<tr><td style="padding:28px 32px 0;">
|
|
107
|
+
<div style="background:${statusBg};border:1px solid ${statusBorder};border-radius:10px;padding:16px 20px;">
|
|
108
|
+
<div style="font-size:20px;font-weight:800;color:${statusColor};">${status}</div>
|
|
109
|
+
<div style="font-size:13px;color:#6b7280;margin-top:2px;font-family:monospace;">${endpoint}</div>
|
|
110
|
+
</div>
|
|
111
|
+
</td></tr>
|
|
112
|
+
<tr><td style="padding:20px 32px 0;">
|
|
113
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
|
114
|
+
<tr style="background:#f9fafb;"><td colspan="2" style="padding:10px 16px;font-size:12px;font-weight:700;color:#9ca3af;text-transform:uppercase;border-bottom:1px solid #e5e7eb;">Test Details</td></tr>
|
|
115
|
+
${rows}
|
|
116
|
+
</table>
|
|
117
|
+
</td></tr>
|
|
118
|
+
<tr><td style="padding:24px 32px;text-align:center;">${downloadBtn}</td></tr>
|
|
119
|
+
<tr><td style="padding:20px 32px;border-top:1px solid #f3f4f6;">
|
|
120
|
+
<p style="margin:0;color:#9ca3af;font-size:12px;text-align:center;">
|
|
121
|
+
KickLoad Watcher · <a href="https://kickload.neeyatai.com" style="color:#f47c20;text-decoration:none;">NeeyatAI</a>
|
|
122
|
+
</p>
|
|
123
|
+
</td></tr>
|
|
124
|
+
</table></td></tr></table></body></html>`;
|
|
125
|
+
|
|
126
|
+
const text =
|
|
127
|
+
`KickLoad Watcher — ${status}\nEndpoint: ${endpoint}\nTested: ${testedAt} IST\n` +
|
|
128
|
+
(summary?.averageLatency != null ? `Latency: ${summary.averageLatency}ms\n` : "") +
|
|
129
|
+
(summary?.errorPercentage != null ? `Errors: ${summary.errorPercentage}%\n` : "") +
|
|
130
|
+
(summary?.throughput != null ? `Throughput: ${summary.throughput} req/s\n` : "") +
|
|
131
|
+
(durationSec != null ? `Duration: ${durationSec}s\n` : "") +
|
|
132
|
+
(downloadUrl ? `\nReport: ${downloadUrl}\n` : "") +
|
|
133
|
+
`\n— KickLoad Watcher`;
|
|
134
|
+
|
|
135
|
+
const info = await transporter.sendMail({
|
|
136
|
+
from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
|
|
137
|
+
to: toEmail, subject, html, text,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
console.log(`📧 Email sent → ${toEmail} (${info.messageId})`);
|
|
141
|
+
}
|
package/kickload-client.js
CHANGED
|
@@ -59,16 +59,20 @@ export class KickloadClient {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
if (!response.ok) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
if (response.status === 401 || response.status === 403) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"Kickload authentication failed — check your API token or subscription"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (response.status === 504) {
|
|
69
|
+
throw new Error("Kickload server timeout (504)");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Kickload ${method} ${endpoint} → ${response.status} ${response.statusText}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
72
76
|
|
|
73
77
|
return parsed;
|
|
74
78
|
}
|
package/package.json
CHANGED
package/pipeline.js
CHANGED
|
@@ -226,11 +226,85 @@ export async function runPhase1Pipeline(event) {
|
|
|
226
226
|
const client = new KickloadClient(config.kickload.baseUrl, config.kickload.apiToken);
|
|
227
227
|
|
|
228
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
229
|
|
|
230
|
+
let jmxFilename = null;
|
|
231
|
+
let kickloadFailed = false;
|
|
232
|
+
let kickloadErrorMsg = "";
|
|
233
|
+
|
|
234
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
const res = await client.generateTestPlan({
|
|
237
|
+
prompt: testParams.testPrompt,
|
|
238
|
+
jmxFilePath: jmxFilePath || null
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
jmxFilename = res.jmx_filename;
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
} catch (err) {
|
|
245
|
+
kickloadErrorMsg = err.message || "unknown error";
|
|
246
|
+
|
|
247
|
+
const status =
|
|
248
|
+
err.status ||
|
|
249
|
+
err.response?.status ||
|
|
250
|
+
(err.message?.includes("500") ? 500 : 0);
|
|
251
|
+
|
|
252
|
+
// retry only for 503 / 5xx
|
|
253
|
+
if (status >= 500 && attempt < 3) {
|
|
254
|
+
logger.warn(`Kickload attempt ${attempt}/3 failed — retrying in 3s (${kickloadErrorMsg})`);
|
|
255
|
+
await sleep(3000);
|
|
256
|
+
} else {
|
|
257
|
+
kickloadFailed = true;
|
|
258
|
+
logger.error(`Kickload failed after retries: ${kickloadErrorMsg}`);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (kickloadFailed || !jmxFilename) {
|
|
264
|
+
logger.warn("⚠️ Kickload failed — continuing with fallback result");
|
|
265
|
+
logger.info(`Fallback reason: ${kickloadErrorMsg}`);
|
|
266
|
+
|
|
267
|
+
// fake result so pipeline completes fully
|
|
268
|
+
const totalMs = Date.now() - start;
|
|
269
|
+
|
|
270
|
+
const summary = {
|
|
271
|
+
passed: false,
|
|
272
|
+
averageLatency: null,
|
|
273
|
+
errorPercentage: 100,
|
|
274
|
+
throughput: null,
|
|
275
|
+
note: "Test plan generation failed — fallback result generated"
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
logPipelineSummary({
|
|
279
|
+
endpoint: endpoints.join(", "),
|
|
280
|
+
passed: summary.passed,
|
|
281
|
+
latency: summary.averageLatency,
|
|
282
|
+
errorRate: summary.errorPercentage,
|
|
283
|
+
throughput: summary.throughput,
|
|
284
|
+
durationSec: Math.round(totalMs / 1000),
|
|
285
|
+
emailSent: !!devEmail,
|
|
286
|
+
devEmail,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// send email
|
|
290
|
+
if (devEmail) {
|
|
291
|
+
await safeResultEmail({
|
|
292
|
+
toEmail: devEmail,
|
|
293
|
+
endpoint: endpoints.join(", "),
|
|
294
|
+
passed: false,
|
|
295
|
+
summary,
|
|
296
|
+
downloadUrl: null,
|
|
297
|
+
pdfFilename: null,
|
|
298
|
+
durationMs: totalMs,
|
|
299
|
+
testPrompt: testParams.testPrompt,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
success: true, // 👈 IMPORTANT: pipeline completed
|
|
305
|
+
fallback: true
|
|
306
|
+
};
|
|
307
|
+
}
|
|
234
308
|
logger.step("Running test");
|
|
235
309
|
const { task_id: taskId } = await withRetry(
|
|
236
310
|
() => client.runTest(jmxFilename, { numThreads: testParams.numThreads, loopCount: testParams.loopCount, rampTime: testParams.rampTime }),
|
|
@@ -278,7 +352,19 @@ export async function runPhase1Pipeline(event) {
|
|
|
278
352
|
return { success: true, backendUrl, jmxFilename, taskId, jtlFilename: taskResult.result_file, pdfFilename, downloadUrl, summary };
|
|
279
353
|
|
|
280
354
|
} catch (err) {
|
|
281
|
-
|
|
355
|
+
if (err.message.includes("504") || err.message.includes("Gateway")) {
|
|
356
|
+
logger.error("❌ Kickload server is currently unavailable (504 timeout)");
|
|
357
|
+
logger.error(" This is a server-side issue, not your API");
|
|
358
|
+
logger.error(" Try again after some time\n");
|
|
359
|
+
} else if (err.message.includes("401") || err.message.includes("403")) {
|
|
360
|
+
logger.error("❌ Kickload authentication failed");
|
|
361
|
+
logger.error(" Possible reasons:");
|
|
362
|
+
logger.error(" • Invalid API token");
|
|
363
|
+
logger.error(" • Token expired");
|
|
364
|
+
logger.error(" • Subscription required\n");
|
|
365
|
+
} else {
|
|
366
|
+
logger.error(`Pipeline failed: ${err.message}`);
|
|
367
|
+
}
|
|
282
368
|
|
|
283
369
|
if (devEmail) {
|
|
284
370
|
await safeResultEmail({
|
|
@@ -414,7 +500,10 @@ async function withRetry(fn, label) {
|
|
|
414
500
|
return await fn();
|
|
415
501
|
} catch (err) {
|
|
416
502
|
lastErr = err;
|
|
417
|
-
const status =
|
|
503
|
+
const status =
|
|
504
|
+
err.status ||
|
|
505
|
+
err.response?.status ||
|
|
506
|
+
(err.message?.includes("500") ? 500 : 0);
|
|
418
507
|
if (status >= 400 && status < 500 && status !== 429) throw err;
|
|
419
508
|
if (attempt <= MAX_RETRIES) {
|
|
420
509
|
logger.warn(`[${label}] attempt ${attempt} failed — retrying in ${RETRY_DELAY_MS / 1000}s (${err.message})`);
|