k6lab-agent 1.0.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/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "k6lab-agent",
3
+ "version": "1.0.0",
4
+ "description": "Local agent for K6 Lab load testing",
5
+ "type": "module",
6
+ "bin": {
7
+ "k6lab-agent": "src/index.js"
8
+ },
9
+ "dependencies": {
10
+ "axios": "^1.6.0",
11
+ "commander": "^11.1.0",
12
+ "fs-extra": "^11.1.1"
13
+ }
14
+ }
@@ -0,0 +1,46 @@
1
+ import axios from "axios";
2
+ import { saveConfig } from "../services/configStore.js";
3
+
4
+ export async function login(token) {
5
+ const apiUrl = process.env.K6LAB_API_URL || "http://localhost:8000";
6
+
7
+ try {
8
+ const res = await axios.post(
9
+ `${apiUrl}/api/agent/verify-token`,
10
+ {},
11
+ {
12
+ headers: {
13
+ Authorization: `Bearer ${token}`
14
+ },
15
+ timeout: 10000
16
+ }
17
+ );
18
+
19
+ await saveConfig({
20
+ apiUrl,
21
+ agentToken: token,
22
+ agentId: res.data.agent.id,
23
+ agentName: res.data.agent.name
24
+ });
25
+
26
+ console.log("");
27
+ console.log("K6 Lab Agent connected successfully.");
28
+ console.log("");
29
+ console.log(`Agent: ${res.data.agent.name}`);
30
+ console.log(`API: ${apiUrl}`);
31
+ console.log("");
32
+ console.log("Now start the agent:");
33
+ console.log("");
34
+ console.log("k6lab-agent start");
35
+ console.log("");
36
+ } catch (err) {
37
+ console.error("");
38
+ console.error("Agent login failed.");
39
+ console.error("Reason: invalid token or backend not reachable.");
40
+ if (err.response?.data?.error) {
41
+ console.error(`Details: ${err.response.data.error}`);
42
+ }
43
+ console.error("");
44
+ process.exit(1);
45
+ }
46
+ }
@@ -0,0 +1,12 @@
1
+ import { clearConfig } from "../services/configStore.js";
2
+
3
+ export async function logout() {
4
+ try {
5
+ await clearConfig();
6
+ console.log("");
7
+ console.log("Logged out successfully. Local agent configuration wiped.");
8
+ console.log("");
9
+ } catch (err) {
10
+ console.error("Logout action failed:", err.message);
11
+ }
12
+ }
@@ -0,0 +1,173 @@
1
+ import fs from "fs-extra";
2
+ import { checkK6Installed } from "../services/k6Checker.js";
3
+ import {
4
+ sendHeartbeat,
5
+ getNextJob,
6
+ uploadResult,
7
+ uploadLogs,
8
+ failJob,
9
+ cancelJob,
10
+ getJobStatus
11
+ } from "../services/api.js";
12
+ import { createK6Script } from "../services/scriptGenerator.js";
13
+ import { runK6, stopCurrentK6Process } from "../services/runner.js";
14
+ import { sleep } from "../utils/sleep.js";
15
+
16
+ let isShuttingDown = false;
17
+
18
+ process.on("SIGINT", async () => {
19
+ console.log("");
20
+ console.log("Stopping K6 Lab Agent...");
21
+
22
+ isShuttingDown = true;
23
+ stopCurrentK6Process();
24
+
25
+ console.log("Agent stopped.");
26
+ process.exit(0);
27
+ });
28
+
29
+ export async function start() {
30
+ console.log("");
31
+ console.log("Starting K6 Lab Agent...");
32
+ console.log("");
33
+
34
+ try {
35
+ console.log("Checking k6 installation...");
36
+ await checkK6Installed();
37
+
38
+ console.log("k6 is installed and ready.");
39
+ console.log("");
40
+ console.log("Connected to K6 Lab dashboard.");
41
+ console.log("Agent is online.");
42
+ console.log("");
43
+ console.log("You can now create a test from your dashboard.");
44
+ console.log("Waiting for jobs...");
45
+ console.log("");
46
+
47
+ while (!isShuttingDown) {
48
+ try {
49
+ await sendHeartbeat();
50
+
51
+ const job = await getNextJob();
52
+
53
+ if (!job) {
54
+ await sleep(3000);
55
+ continue;
56
+ }
57
+
58
+ console.log("");
59
+ console.log(`New test received: ${job.name || job.id}`);
60
+ console.log("");
61
+ console.log(`${job.config.method} ${job.config.url}`);
62
+ console.log(`VUs: ${job.config.vus}`);
63
+ console.log(`Duration: ${job.config.duration}`);
64
+ console.log("");
65
+ console.log("Running k6 locally...");
66
+ console.log("Please keep this terminal open.");
67
+ console.log("You can monitor progress in your K6 Lab dashboard.");
68
+ console.log("");
69
+
70
+ let cancelCheckInterval = null;
71
+ let localCancelled = false;
72
+
73
+ // 🔹 Background dashboard cancellation monitor
74
+ cancelCheckInterval = setInterval(async () => {
75
+ try {
76
+ const statusData = await getJobStatus(job.id);
77
+ if (statusData && statusData.status === "cancel_requested") {
78
+ console.log("");
79
+ console.log("--> Cancellation requested from dashboard. Aborting run...");
80
+ localCancelled = true;
81
+ stopCurrentK6Process();
82
+ clearInterval(cancelCheckInterval);
83
+ }
84
+ } catch (err) {
85
+ // Ignore status polling errors
86
+ }
87
+ }, 2000);
88
+
89
+ try {
90
+ const { scriptPath, summaryPath, logsPath } = await createK6Script(job);
91
+
92
+ const runResult = await runK6(scriptPath, job, logsPath);
93
+
94
+ if (cancelCheckInterval) clearInterval(cancelCheckInterval);
95
+
96
+ let summary = null;
97
+ if (await fs.pathExists(summaryPath)) {
98
+ summary = await fs.readJson(summaryPath);
99
+ }
100
+
101
+ await uploadLogs(job.id, runResult.logs);
102
+
103
+ await uploadResult(job.id, {
104
+ status: "completed",
105
+ summary,
106
+ logs: runResult.logs
107
+ });
108
+
109
+ console.log("");
110
+ console.log("Test completed successfully.");
111
+ console.log("");
112
+ console.log("Results uploaded to your K6 Lab dashboard.");
113
+ console.log("Open the dashboard to view the full performance report.");
114
+ console.log("");
115
+ console.log("Waiting for jobs...");
116
+ console.log("");
117
+ } catch (err) {
118
+ if (cancelCheckInterval) clearInterval(cancelCheckInterval);
119
+
120
+ if (localCancelled) {
121
+ await cancelJob(job.id, "Test cancelled by user from dashboard");
122
+ console.log("");
123
+ console.log("Test execution successfully cancelled.");
124
+ console.log("");
125
+ } else {
126
+ // Check if backend cancellation requested in middle of execution
127
+ try {
128
+ const currentStatus = await getJobStatus(job.id);
129
+ if (currentStatus && (currentStatus.status === "cancel_requested" || currentStatus.status === "cancelled")) {
130
+ await cancelJob(job.id, "Test cancelled by user from dashboard");
131
+ console.log("");
132
+ console.log("Test execution successfully cancelled.");
133
+ console.log("");
134
+ console.log("Waiting for jobs...");
135
+ console.log("");
136
+ continue;
137
+ }
138
+ } catch (statusErr) {
139
+ // fallback to failure
140
+ }
141
+
142
+ await failJob(job.id, err.message);
143
+
144
+ console.log("");
145
+ console.log("Test failed.");
146
+ console.log("");
147
+ console.log("Reason:");
148
+ console.log(err.message);
149
+ console.log("");
150
+ console.log("The failure details were uploaded to your dashboard.");
151
+ console.log("Please check your local API and try again.");
152
+ console.log("");
153
+ }
154
+
155
+ console.log("Waiting for jobs...");
156
+ console.log("");
157
+ }
158
+ } catch (err) {
159
+ console.error("Agent connection error:", err.message);
160
+ await sleep(5000);
161
+ }
162
+ }
163
+ } catch (err) {
164
+ console.error("");
165
+ console.error(err.message);
166
+ console.error("");
167
+ console.error("Fix the issue and run:");
168
+ console.error("");
169
+ console.error("k6lab-agent start");
170
+ console.error("");
171
+ process.exit(1);
172
+ }
173
+ }
@@ -0,0 +1,19 @@
1
+ import { getConfig } from "../services/configStore.js";
2
+
3
+ export async function status() {
4
+ try {
5
+ const config = await getConfig();
6
+ console.log("");
7
+ console.log("K6 Lab Agent Status: Connected");
8
+ console.log("------------------------------");
9
+ console.log(`Agent Name: ${config.agentName}`);
10
+ console.log(`Agent ID: ${config.agentId}`);
11
+ console.log(`API URL: ${config.apiUrl}`);
12
+ console.log("");
13
+ } catch (err) {
14
+ console.log("");
15
+ console.log("K6 Lab Agent Status: Disconnected / Not Logged In");
16
+ console.log("Run: k6lab-agent login <token> to connect your local environment.");
17
+ console.log("");
18
+ }
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { login } from "./commands/login.js";
5
+ import { start } from "./commands/start.js";
6
+ import { status } from "./commands/status.js";
7
+ import { logout } from "./commands/logout.js";
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name("k6lab-agent")
13
+ .description("Local agent for K6 Lab")
14
+ .version("1.0.0");
15
+
16
+ program
17
+ .command("login")
18
+ .description("Login with your K6 Lab agent token")
19
+ .argument("<token>", "Agent token from K6 Lab dashboard")
20
+ .action(login);
21
+
22
+ program
23
+ .command("start")
24
+ .description("Start agent and wait for dashboard jobs")
25
+ .action(start);
26
+
27
+ program
28
+ .command("status")
29
+ .description("Show local agent login status")
30
+ .action(status);
31
+
32
+ program
33
+ .command("logout")
34
+ .description("Remove local agent token")
35
+ .action(logout);
36
+
37
+ program.parse();
@@ -0,0 +1,62 @@
1
+ import axios from "axios";
2
+ import { getConfig } from "./configStore.js";
3
+
4
+ async function createClient() {
5
+ const config = await getConfig();
6
+
7
+ return axios.create({
8
+ baseURL: config.apiUrl,
9
+ headers: {
10
+ Authorization: `Bearer ${config.agentToken}`
11
+ },
12
+ timeout: 10000
13
+ });
14
+ }
15
+
16
+ export async function sendHeartbeat() {
17
+ const api = await createClient();
18
+ const res = await api.post("/api/agent/heartbeat");
19
+ return res.data;
20
+ }
21
+
22
+ export async function getNextJob() {
23
+ const api = await createClient();
24
+ const res = await api.get("/api/agent/jobs/next");
25
+ return res.data.job;
26
+ }
27
+
28
+ export async function getJobStatus(jobId) {
29
+ const api = await createClient();
30
+ const res = await api.get(`/api/agent/jobs/${jobId}/status`);
31
+ return res.data.job;
32
+ }
33
+
34
+ export async function uploadLogs(jobId, logs) {
35
+ const api = await createClient();
36
+ const res = await api.post(`/api/agent/jobs/${jobId}/logs`, {
37
+ logs
38
+ });
39
+ return res.data;
40
+ }
41
+
42
+ export async function uploadResult(jobId, payload) {
43
+ const api = await createClient();
44
+ const res = await api.post(`/api/agent/jobs/${jobId}/result`, payload);
45
+ return res.data;
46
+ }
47
+
48
+ export async function failJob(jobId, error) {
49
+ const api = await createClient();
50
+ const res = await api.post(`/api/agent/jobs/${jobId}/fail`, {
51
+ error
52
+ });
53
+ return res.data;
54
+ }
55
+
56
+ export async function cancelJob(jobId, message) {
57
+ const api = await createClient();
58
+ const res = await api.post(`/api/agent/jobs/${jobId}/cancelled`, {
59
+ message
60
+ });
61
+ return res.data;
62
+ }
@@ -0,0 +1,29 @@
1
+ import fs from "fs-extra";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ const configDir = path.join(os.homedir(), ".k6lab");
6
+ const configPath = path.join(configDir, "config.json");
7
+
8
+ export async function saveConfig(config) {
9
+ await fs.ensureDir(configDir);
10
+ await fs.writeJson(configPath, config, { spaces: 2 });
11
+ }
12
+
13
+ export async function getConfig() {
14
+ const exists = await fs.pathExists(configPath);
15
+
16
+ if (!exists) {
17
+ throw new Error("Agent is not logged in. Run: k6lab-agent login <token>");
18
+ }
19
+
20
+ return fs.readJson(configPath);
21
+ }
22
+
23
+ export async function clearConfig() {
24
+ await fs.remove(configPath);
25
+ }
26
+
27
+ export function getK6LabDir() {
28
+ return configDir;
29
+ }
@@ -0,0 +1,25 @@
1
+ import { spawn } from "child_process";
2
+
3
+ export function checkK6Installed() {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn("k6", ["version"], {
6
+ shell: false
7
+ });
8
+
9
+ child.on("error", () => {
10
+ reject(
11
+ new Error(
12
+ "k6 is not installed. Please install k6 first, then run the agent again."
13
+ )
14
+ );
15
+ });
16
+
17
+ child.on("close", (code) => {
18
+ if (code === 0) {
19
+ resolve(true);
20
+ } else {
21
+ reject(new Error("k6 is installed but not working correctly."));
22
+ }
23
+ });
24
+ });
25
+ }
@@ -0,0 +1,73 @@
1
+ import { spawn } from "child_process";
2
+ import fs from "fs-extra";
3
+
4
+ let currentProcess = null;
5
+
6
+ export function stopCurrentK6Process() {
7
+ if (currentProcess) {
8
+ currentProcess.kill("SIGTERM");
9
+ }
10
+ }
11
+
12
+ export function runK6(scriptPath, job, logsPath) {
13
+ return new Promise((resolve, reject) => {
14
+ currentProcess = spawn("k6", ["run", scriptPath], {
15
+ shell: false,
16
+ env: {
17
+ ...process.env,
18
+
19
+ K6LAB_URL: job.config.url,
20
+ K6LAB_METHOD: job.config.method,
21
+ K6LAB_VUS: String(job.config.vus),
22
+ K6LAB_DURATION: job.config.duration,
23
+
24
+ K6LAB_HEADERS: JSON.stringify(job.config.headers || {}),
25
+ K6LAB_BODY:
26
+ job.config.body === null || job.config.body === undefined
27
+ ? ""
28
+ : typeof job.config.body === "string"
29
+ ? job.config.body
30
+ : JSON.stringify(job.config.body),
31
+
32
+ K6LAB_EXPECTED_STATUS: String(job.config.expectedStatus || 200),
33
+ K6LAB_MAX_RESPONSE_TIME_MS: String(job.config.maxResponseTimeMs || 1000),
34
+ K6LAB_SLEEP_SECONDS: String(job.config.sleepSeconds ?? 1),
35
+ K6LAB_TIMEOUT: job.config.timeout || "30s"
36
+ }
37
+ });
38
+
39
+ let stdout = "";
40
+ let stderr = "";
41
+
42
+ currentProcess.stdout.on("data", async (data) => {
43
+ const text = data.toString();
44
+ stdout += text;
45
+ await fs.appendFile(logsPath, text);
46
+ });
47
+
48
+ currentProcess.stderr.on("data", async (data) => {
49
+ const text = data.toString();
50
+ stderr += text;
51
+ await fs.appendFile(logsPath, text);
52
+ });
53
+
54
+ currentProcess.on("error", (err) => {
55
+ currentProcess = null;
56
+ reject(err);
57
+ });
58
+
59
+ currentProcess.on("close", (code) => {
60
+ currentProcess = null;
61
+
62
+ if (code === 0) {
63
+ resolve({
64
+ stdout,
65
+ stderr,
66
+ logs: stdout + stderr
67
+ });
68
+ } else {
69
+ reject(new Error(`k6 exited with code ${code}\n${stderr || stdout}`));
70
+ }
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,82 @@
1
+ import fs from "fs-extra";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ export async function createK6Script(job) {
6
+ const jobDir = path.join(os.homedir(), ".k6lab", "jobs", job.id);
7
+
8
+ await fs.ensureDir(jobDir);
9
+
10
+ const scriptPath = path.join(jobDir, "script.js");
11
+ const summaryPath = path.join(jobDir, "summary.json");
12
+ const logsPath = path.join(jobDir, "logs.txt");
13
+ const metadataPath = path.join(jobDir, "metadata.json");
14
+
15
+ await fs.writeJson(
16
+ metadataPath,
17
+ {
18
+ jobId: job.id,
19
+ name: job.name,
20
+ config: job.config,
21
+ createdAt: new Date().toISOString()
22
+ },
23
+ { spaces: 2 }
24
+ );
25
+
26
+ const escapedSummaryPath = summaryPath.replaceAll("\\", "\\\\");
27
+
28
+ const script = `
29
+ import http from "k6/http";
30
+ import { check, sleep } from "k6";
31
+
32
+ export const options = {
33
+ vus: Number(__ENV.K6LAB_VUS || 1),
34
+ duration: __ENV.K6LAB_DURATION || "10s",
35
+ };
36
+
37
+ export default function () {
38
+ const method = __ENV.K6LAB_METHOD || "GET";
39
+ const url = __ENV.K6LAB_URL;
40
+
41
+ const headers = JSON.parse(__ENV.K6LAB_HEADERS || "{}");
42
+ const body = __ENV.K6LAB_BODY || null;
43
+
44
+ const params = {
45
+ headers,
46
+ timeout: __ENV.K6LAB_TIMEOUT || "30s"
47
+ };
48
+
49
+ const res = http.request(method, url, body, params);
50
+
51
+ check(res, {
52
+ "status is expected": (r) => {
53
+ const expectedStatus = Number(__ENV.K6LAB_EXPECTED_STATUS || 200);
54
+ return r.status === expectedStatus;
55
+ },
56
+ "response time is acceptable": (r) => {
57
+ const maxMs = Number(__ENV.K6LAB_MAX_RESPONSE_TIME_MS || 1000);
58
+ return r.timings.duration < maxMs;
59
+ }
60
+ });
61
+
62
+ const sleepSeconds = Number(__ENV.K6LAB_SLEEP_SECONDS || 1);
63
+ sleep(sleepSeconds);
64
+ }
65
+
66
+ export function handleSummary(data) {
67
+ return {
68
+ "${escapedSummaryPath}": JSON.stringify(data)
69
+ };
70
+ }
71
+ `.trim();
72
+
73
+ await fs.writeFile(scriptPath, script, "utf8");
74
+
75
+ return {
76
+ jobDir,
77
+ scriptPath,
78
+ summaryPath,
79
+ logsPath,
80
+ metadataPath
81
+ };
82
+ }
@@ -0,0 +1 @@
1
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));