test(performance): implement k6 load testing scenarios and java result parser
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"_comment": "WalkGuide Load Test — 50 VUs, 5 minutes. Normal production load simulation.",
|
||||
"_meta": {
|
||||
"min_vu_requirement": 50,
|
||||
"note": "Pillar 3 ketentuan: load test harus >= 50 concurrent users"
|
||||
},
|
||||
"scenarios": {
|
||||
"load_auth": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "30s", "target": 10 },
|
||||
{ "duration": "60s", "target": 20 },
|
||||
{ "duration": "120s", "target": 20 },
|
||||
{ "duration": "30s", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "15s"
|
||||
},
|
||||
"load_location": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "30s", "target": 15 },
|
||||
{ "duration": "120s", "target": 20 },
|
||||
{ "duration": "30s", "target": 0 }
|
||||
],
|
||||
"startTime": "10s",
|
||||
"gracefulRampDown": "15s"
|
||||
},
|
||||
"load_obstacle": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 10,
|
||||
"duration": "180s",
|
||||
"startTime": "20s"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<500", "p(99)<1000"],
|
||||
"http_req_failed": ["rate<0.01"],
|
||||
"walkguide_error_rate": ["rate<0.01"],
|
||||
"walkguide_location_latency_ms": ["p(95)<300"],
|
||||
"walkguide_obstacle_latency_ms": ["p(95)<400"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<200"],
|
||||
"walkguide_auth_latency_ms": ["p(95)<800"],
|
||||
"walkguide_timeline_latency_ms": ["p(95)<1000"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/load-test-summary.json"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "WalkGuide Smoke Test — 10 VUs, 1 minute. Verifikasi semua endpoint reachable dan basic functionality OK.",
|
||||
"scenarios": {
|
||||
"smoke_auth": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 3,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "auth" }
|
||||
},
|
||||
"smoke_location": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 4,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "location" }
|
||||
},
|
||||
"smoke_analytics": {
|
||||
"executor": "constant-vus",
|
||||
"vus": 3,
|
||||
"duration": "60s",
|
||||
"exec": "default",
|
||||
"env": { "SCENARIO": "analytics" }
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<1000"],
|
||||
"http_req_failed": ["rate<0.01"],
|
||||
"walkguide_error_rate": ["rate<0.01"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<500"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/smoke-test-summary.json"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"_comment": "WalkGuide Spike Test — 0 → 200 VUs sudden spike. Simulasi viral event / banyak user pakai serentak.",
|
||||
"scenarios": {
|
||||
"spike": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "10s", "target": 5 },
|
||||
{ "duration": "1s", "target": 200 },
|
||||
{ "duration": "2m", "target": 200 },
|
||||
{ "duration": "10s", "target": 5 },
|
||||
{ "duration": "3m", "target": 5 },
|
||||
{ "duration": "10s", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "30s"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<3000"],
|
||||
"http_req_failed": ["rate<0.10"],
|
||||
"walkguide_error_rate": ["rate<0.10"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<1000"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/spike-test-summary.json"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"_comment": "WalkGuide Stress Test — 100 VUs, 10 minutes. Find breaking points.",
|
||||
"scenarios": {
|
||||
"stress_all": {
|
||||
"executor": "ramping-vus",
|
||||
"startVUs": 0,
|
||||
"stages": [
|
||||
{ "duration": "1m", "target": 25 },
|
||||
{ "duration": "2m", "target": 50 },
|
||||
{ "duration": "2m", "target": 75 },
|
||||
{ "duration": "2m", "target": 100 },
|
||||
{ "duration": "2m", "target": 100 },
|
||||
{ "duration": "1m", "target": 0 }
|
||||
],
|
||||
"gracefulRampDown": "30s"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration": ["p(95)<1500", "p(99)<3000"],
|
||||
"http_req_failed": ["rate<0.05"],
|
||||
"walkguide_error_rate": ["rate<0.05"],
|
||||
"walkguide_sos_latency_ms": ["p(95)<500"]
|
||||
},
|
||||
"summaryTrendStats": ["min", "med", "avg", "p(90)", "p(95)", "p(99)", "max"],
|
||||
"summary_export": "k6-results/stress-test-summary.json"
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* WalkGuide API Client — Reusable HTTP wrapper for k6 tests
|
||||
* Semua request ke Spring Boot backend melewati modul ini.
|
||||
*/
|
||||
import http from "k6/http";
|
||||
import { check, fail } from "k6";
|
||||
import { metricsHelper } from "./metrics-helper.js";
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://202.46.28.160:8080";
|
||||
const API_BASE = `${BASE_URL}/api/v1`;
|
||||
|
||||
/**
|
||||
* Default headers tanpa auth
|
||||
*/
|
||||
function jsonHeaders(extra = {}) {
|
||||
return Object.assign(
|
||||
{ "Content-Type": "application/json", Accept: "application/json" },
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default headers dengan Bearer token
|
||||
*/
|
||||
function authHeaders(token, extra = {}) {
|
||||
return jsonHeaders(
|
||||
Object.assign({ Authorization: `Bearer ${token}` }, extra),
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// AUTH endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function register(payload) {
|
||||
const res = http.post(`${API_BASE}/auth/register`, JSON.stringify(payload), {
|
||||
headers: jsonHeaders(),
|
||||
tags: { endpoint: "register" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("register", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function login(email, password) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/login`,
|
||||
JSON.stringify({ email, password }),
|
||||
{ headers: jsonHeaders(), tags: { endpoint: "login" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("login", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function refreshToken(refreshTok) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/refresh`,
|
||||
JSON.stringify({ refreshToken: refreshTok }),
|
||||
{ headers: jsonHeaders(), tags: { endpoint: "refresh_token" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("refresh_token", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logout(token) {
|
||||
const res = http.post(`${API_BASE}/auth/logout`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "logout" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("logout", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function ping() {
|
||||
const res = http.get(`${API_BASE}/auth/ping`, { tags: { endpoint: "ping" } });
|
||||
metricsHelper.recordEndpoint("ping", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateFcmToken(token, fcmToken) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/auth/fcm-token`,
|
||||
JSON.stringify({ fcmToken }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "fcm_token" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("fcm_token", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// PAIRING endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function inviteUser(token, uniqueUserId) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/pairing/invite`,
|
||||
JSON.stringify({ uniqueUserId }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "pairing_invite" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("pairing_invite", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function respondPairing(token, pairingId, accept) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/pairing/respond`,
|
||||
JSON.stringify({ pairingId, accept }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "pairing_respond" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("pairing_respond", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getPairingStatus(token) {
|
||||
const res = http.get(`${API_BASE}/pairing/status`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "pairing_status" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("pairing_status", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function unpair(token) {
|
||||
const res = http.del(`${API_BASE}/pairing/unpair`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "pairing_unpair" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("pairing_unpair", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// USER endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function getUserProfile(token) {
|
||||
const res = http.get(`${API_BASE}/user/profile`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_profile" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_profile", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateLocation(
|
||||
token,
|
||||
lat,
|
||||
lng,
|
||||
accuracy = 5.0,
|
||||
speed = 1.4,
|
||||
heading = 90.0,
|
||||
) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/user/location`,
|
||||
JSON.stringify({ lat, lng, accuracy, speed, heading }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "location_update" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("location_update", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logObstacle(token, payload) {
|
||||
const res = http.post(`${API_BASE}/user/obstacle`, JSON.stringify(payload), {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "obstacle_log" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("obstacle_log", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function triggerSos(token, triggerType, lat, lng) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/user/sos`,
|
||||
JSON.stringify({ triggerType, lat, lng }),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "sos_trigger" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("sos_trigger", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getActivityLogs(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/user/activity-logs?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "activity_logs" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("activity_logs", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getNotifications(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/user/notifications?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "notifications" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("notifications", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUnreadCount(token) {
|
||||
const res = http.get(`${API_BASE}/user/notifications/unread-count`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "unread_count" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("unread_count", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function markAllRead(token) {
|
||||
const res = http.put(`${API_BASE}/user/notifications/mark-all-read`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "mark_all_read" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("mark_all_read", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function markOneRead(token, notifId) {
|
||||
const res = http.put(`${API_BASE}/user/notifications/${notifId}/read`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "mark_one_read" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("mark_one_read", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function startWalkguide(token) {
|
||||
const res = http.post(`${API_BASE}/user/walkguide/start`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "walkguide_start" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("walkguide_start", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function stopWalkguide(token) {
|
||||
const res = http.post(`${API_BASE}/user/walkguide/stop`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "walkguide_stop" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("walkguide_stop", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserSettings(token) {
|
||||
const res = http.get(`${API_BASE}/user/settings`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_settings_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_settings_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateUserSettings(token, payload) {
|
||||
const res = http.put(`${API_BASE}/user/settings`, JSON.stringify(payload), {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "user_settings_put" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("user_settings_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserAiConfig(token) {
|
||||
const res = http.get(`${API_BASE}/user/ai-config`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("ai_config_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// GUARDIAN endpoints
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function getGuardianDashboard(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/dashboard`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "guardian_dashboard" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("guardian_dashboard", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getUserLocation(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/user-location`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "guardian_user_location" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("guardian_user_location", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getLocationHistory(token, page = 0, size = 50) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/location-history?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "location_history" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("location_history", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getObstacleLogs(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/obstacle-logs?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "obstacle_logs_get" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("obstacle_logs_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function sendNotification(token, payload) {
|
||||
const res = http.post(
|
||||
`${API_BASE}/guardian/notifications/send`,
|
||||
JSON.stringify(payload),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "send_notification" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("send_notification", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getSosEvents(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/sos-events?page=${page}&size=${size}`,
|
||||
{ headers: authHeaders(token), tags: { endpoint: "sos_events_get" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("sos_events_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function acknowledgeSos(token, sosId) {
|
||||
const res = http.put(`${API_BASE}/guardian/sos/${sosId}/acknowledge`, null, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "sos_acknowledge" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("sos_acknowledge", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getAiConfig(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/ai-config`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_guardian_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("ai_config_guardian_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateAiConfig(token, payload) {
|
||||
const res = http.put(
|
||||
`${API_BASE}/guardian/ai-config`,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "ai_config_guardian_put" },
|
||||
},
|
||||
);
|
||||
metricsHelper.recordEndpoint("ai_config_guardian_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getActivityLogsGuardian(token, page = 0, size = 20) {
|
||||
const res = http.get(
|
||||
`${API_BASE}/guardian/activity-logs?page=${page}&size=${size}`,
|
||||
{
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "activity_logs_guardian" },
|
||||
},
|
||||
);
|
||||
metricsHelper.recordEndpoint("activity_logs_guardian", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function getGeofenceConfig(token) {
|
||||
const res = http.get(`${API_BASE}/guardian/geofence`, {
|
||||
headers: authHeaders(token),
|
||||
tags: { endpoint: "geofence_get" },
|
||||
});
|
||||
metricsHelper.recordEndpoint("geofence_get", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function updateGeofenceConfig(token, payload) {
|
||||
const res = http.put(
|
||||
`${API_BASE}/guardian/geofence`,
|
||||
JSON.stringify(payload),
|
||||
{ headers: authHeaders(token), tags: { endpoint: "geofence_put" } },
|
||||
);
|
||||
metricsHelper.recordEndpoint("geofence_put", res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// SHARED check helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export function checkSuccess(res, label) {
|
||||
check(res, {
|
||||
[`${label}: status 2xx`]: (r) => r.status >= 200 && r.status < 300,
|
||||
[`${label}: has body`]: (r) => r.body && r.body.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBody(res) {
|
||||
try {
|
||||
return JSON.parse(res.body);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* WalkGuide — JWT Token Management Helper
|
||||
* Mengelola login, penyimpanan token, dan auto-refresh untuk k6 VUs.
|
||||
*/
|
||||
import { check, sleep } from "k6";
|
||||
import * as api from "./api-client.js";
|
||||
import { testData } from "./test-data.js";
|
||||
|
||||
// ── Shared token cache (per VU, tidak cross-VU karena k6 isolasi per VU) ────
|
||||
let _cachedTokens = null; // { accessToken, refreshToken, role, userId, uniqueUserId }
|
||||
|
||||
/**
|
||||
* Login dan simpan token di memory VU ini.
|
||||
* Return { accessToken, refreshToken, role, userId }
|
||||
*/
|
||||
export function loginAndStore(email, password) {
|
||||
const res = api.login(email, password);
|
||||
check(res, { "auth-helper: login 200": (r) => r.status === 200 });
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(
|
||||
`[auth-helper] Login failed for ${email}: ${res.status} ${res.body}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = api.parseBody(res);
|
||||
if (!body || !body.data || !body.data.accessToken) {
|
||||
console.error(`[auth-helper] Unexpected login response: ${res.body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
_cachedTokens = {
|
||||
accessToken: body.data.accessToken,
|
||||
refreshToken: body.data.refreshToken,
|
||||
role: body.data.role,
|
||||
userId: body.data.userId,
|
||||
uniqueUserId: body.data.uniqueUserId || null,
|
||||
};
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register user baru, lalu login. Return token set.
|
||||
*/
|
||||
export function registerAndLogin(payload) {
|
||||
const regRes = api.register(payload);
|
||||
check(regRes, {
|
||||
"auth-helper: register 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
if (regRes.status !== 200 && regRes.status !== 201) {
|
||||
// Mungkin email sudah ada — coba login saja
|
||||
console.warn(
|
||||
`[auth-helper] Register failed (${regRes.status}), attempting login.`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = api.parseBody(regRes);
|
||||
// Kalau register langsung return token (sesuai AuthService):
|
||||
if (body && body.data && body.data.accessToken) {
|
||||
_cachedTokens = {
|
||||
accessToken: body.data.accessToken,
|
||||
refreshToken: body.data.refreshToken,
|
||||
role: body.data.role,
|
||||
userId: body.data.userId,
|
||||
uniqueUserId: body.data.uniqueUserId || null,
|
||||
};
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
// Fallback: login manual
|
||||
return loginAndStore(payload.email, payload.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil access token dari cache. Kalau null, throw agar test gagal eksplisit.
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
if (!_cachedTokens || !_cachedTokens.accessToken) {
|
||||
throw new Error(
|
||||
"[auth-helper] No access token in cache. Call loginAndStore() first.",
|
||||
);
|
||||
}
|
||||
return _cachedTokens.accessToken;
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
if (!_cachedTokens || !_cachedTokens.refreshToken) {
|
||||
throw new Error("[auth-helper] No refresh token in cache.");
|
||||
}
|
||||
return _cachedTokens.refreshToken;
|
||||
}
|
||||
|
||||
export function getCachedTokens() {
|
||||
return _cachedTokens;
|
||||
}
|
||||
|
||||
export function getCachedUniqueUserId() {
|
||||
return _cachedTokens ? _cachedTokens.uniqueUserId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token menggunakan refresh token yang tersimpan.
|
||||
* Update cache dengan access token baru.
|
||||
*/
|
||||
export function doRefreshToken() {
|
||||
const rt = getRefreshToken();
|
||||
const res = api.refreshToken(rt);
|
||||
check(res, { "auth-helper: refresh 200": (r) => r.status === 200 });
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error(`[auth-helper] Refresh token failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data && body.data.accessToken) {
|
||||
_cachedTokens.accessToken = body.data.accessToken;
|
||||
}
|
||||
return _cachedTokens.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear semua cached token (simulasi logout).
|
||||
*/
|
||||
export function clearTokens() {
|
||||
_cachedTokens = null;
|
||||
}
|
||||
|
||||
// ── Pre-built credential sets untuk smoke/load test ──────────────────────────
|
||||
// Test users yang sudah ada di DB kampus (V2__seed_users.sql)
|
||||
// Jika tidak ada, gunakan dynamic register.
|
||||
|
||||
export const PREBUILT_GUARDIAN = {
|
||||
email: __ENV.GUARDIAN_EMAIL || "guardian@walkguide.test",
|
||||
password: __ENV.GUARDIAN_PASSWORD || "Guardian123!",
|
||||
};
|
||||
|
||||
export const PREBUILT_USER = {
|
||||
email: __ENV.USER_EMAIL || "user@walkguide.test",
|
||||
password: __ENV.USER_PASSWORD || "User123!",
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup pair: register guardian + user, lalu pairing.
|
||||
* Return { guardianTokens, userTokens }
|
||||
*/
|
||||
export function setupPairedSession() {
|
||||
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// Register guardian
|
||||
const gPayload = testData.guardianRegisterPayload(suffix);
|
||||
const gTokens = registerAndLogin(gPayload);
|
||||
if (!gTokens) return null;
|
||||
|
||||
// Register user
|
||||
const uPayload = testData.userRegisterPayload(suffix);
|
||||
const uTokens = registerAndLogin(uPayload);
|
||||
if (!uTokens) return null;
|
||||
|
||||
return {
|
||||
guardianTokens: gTokens,
|
||||
userTokens: uTokens,
|
||||
guardianPayload: gPayload,
|
||||
userPayload: uPayload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* WalkGuide — Custom Metric Collectors
|
||||
* Menambah metric k6 custom di atas default http_req_* metrics.
|
||||
*
|
||||
* METRIC YANG DIKUMPULKAN (sesuai ketentuan pillar 3):
|
||||
* 1. throughput — req/s (via http_reqs counter bawaan k6 + custom trend)
|
||||
* 2. p95 latency — custom trend per endpoint
|
||||
* 3. error rate — custom rate
|
||||
* 4. db query time — diperkirakan via response time karena tidak ada akses langsung DB
|
||||
* 5. jvm heap — via Spring Actuator /actuator/metrics/jvm.memory.used
|
||||
*/
|
||||
import { Counter, Gauge, Rate, Trend } from "k6/metrics";
|
||||
import http from "k6/http";
|
||||
|
||||
// ── Custom Metrics ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Total request error rate (non-2xx) */
|
||||
export const errorRate = new Rate("walkguide_error_rate");
|
||||
|
||||
/** Request latency per endpoint */
|
||||
export const endpointLatency = new Trend("walkguide_endpoint_latency_ms", true);
|
||||
|
||||
/** Location update latency khusus (endpoint paling kritis) */
|
||||
export const locationLatency = new Trend("walkguide_location_latency_ms", true);
|
||||
|
||||
/** Obstacle log latency khusus */
|
||||
export const obstacleLatency = new Trend("walkguide_obstacle_latency_ms", true);
|
||||
|
||||
/** SOS trigger latency khusus (harus paling cepat) */
|
||||
export const sosLatency = new Trend("walkguide_sos_latency_ms", true);
|
||||
|
||||
/** Notification send latency */
|
||||
export const notifLatency = new Trend("walkguide_notif_latency_ms", true);
|
||||
|
||||
/** Auth latency */
|
||||
export const authLatency = new Trend("walkguide_auth_latency_ms", true);
|
||||
|
||||
/** Timeline query latency */
|
||||
export const timelineLatency = new Trend("walkguide_timeline_latency_ms", true);
|
||||
|
||||
/** Pairing flow latency */
|
||||
export const pairingLatency = new Trend("walkguide_pairing_latency_ms", true);
|
||||
|
||||
/** Successful requests counter */
|
||||
export const successfulRequests = new Counter("walkguide_successful_requests");
|
||||
|
||||
/** Failed requests counter */
|
||||
export const failedRequests = new Counter("walkguide_failed_requests");
|
||||
|
||||
/** JVM heap usage (MB) — populated via actuator polling */
|
||||
export const jvmHeapUsed = new Gauge("walkguide_jvm_heap_used_mb");
|
||||
|
||||
/** Active DB connections estimate (via actuator) */
|
||||
export const dbConnections = new Gauge("walkguide_db_connections");
|
||||
|
||||
// ── Endpoint → metric mapping ─────────────────────────────────────────────────
|
||||
|
||||
const ENDPOINT_METRIC_MAP = {
|
||||
location_update: locationLatency,
|
||||
obstacle_log: obstacleLatency,
|
||||
sos_trigger: sosLatency,
|
||||
send_notification: notifLatency,
|
||||
login: authLatency,
|
||||
register: authLatency,
|
||||
refresh_token: authLatency,
|
||||
location_history: timelineLatency,
|
||||
activity_logs: timelineLatency,
|
||||
activity_logs_guardian: timelineLatency,
|
||||
pairing_invite: pairingLatency,
|
||||
pairing_respond: pairingLatency,
|
||||
};
|
||||
|
||||
// ── metricsHelper object ──────────────────────────────────────────────────────
|
||||
|
||||
export const metricsHelper = {
|
||||
/**
|
||||
* Catat metric untuk setiap response.
|
||||
* @param {string} endpointTag — string yang di-pass sebagai tags.endpoint
|
||||
* @param {Response} res — k6 http response object
|
||||
*/
|
||||
recordEndpoint(endpointTag, res) {
|
||||
const isSuccess = res.status >= 200 && res.status < 300;
|
||||
const durationMs = res.timings.duration;
|
||||
|
||||
// Error rate
|
||||
errorRate.add(!isSuccess);
|
||||
|
||||
// Generic endpoint latency
|
||||
endpointLatency.add(durationMs, { endpoint: endpointTag });
|
||||
|
||||
// Specific metric kalau ada di map
|
||||
if (ENDPOINT_METRIC_MAP[endpointTag]) {
|
||||
ENDPOINT_METRIC_MAP[endpointTag].add(durationMs);
|
||||
}
|
||||
|
||||
// Success / fail counter
|
||||
if (isSuccess) {
|
||||
successfulRequests.add(1);
|
||||
} else {
|
||||
failedRequests.add(1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll Spring Actuator untuk JVM heap.
|
||||
* Call ini dari setup() atau secara periodik dari satu VU khusus.
|
||||
* @param {string} baseUrl
|
||||
* @param {string} token — optional, actuator mungkin butuh auth
|
||||
*/
|
||||
pollJvmHeap(baseUrl, token = null) {
|
||||
const headers = token
|
||||
? { Authorization: `Bearer ${token}`, Accept: "application/json" }
|
||||
: { Accept: "application/json" };
|
||||
|
||||
try {
|
||||
const res = http.get(`${baseUrl}/actuator/metrics/jvm.memory.used`, {
|
||||
headers,
|
||||
timeout: "5s",
|
||||
tags: { endpoint: "actuator_jvm" },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
// measurement[0].value adalah bytes
|
||||
const heapMb = body.measurements[0].value / (1024 * 1024);
|
||||
jvmHeapUsed.add(heapMb);
|
||||
}
|
||||
} catch (e) {
|
||||
// Actuator mungkin tidak public — skip
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll Spring Actuator untuk DB connection pool (HikariCP).
|
||||
* @param {string} baseUrl
|
||||
* @param {string} token
|
||||
*/
|
||||
pollDbConnections(baseUrl, token = null) {
|
||||
const headers = token
|
||||
? { Authorization: `Bearer ${token}`, Accept: "application/json" }
|
||||
: { Accept: "application/json" };
|
||||
|
||||
try {
|
||||
const res = http.get(
|
||||
`${baseUrl}/actuator/metrics/hikaricp.connections.active`,
|
||||
{ headers, timeout: "5s", tags: { endpoint: "actuator_db" } },
|
||||
);
|
||||
if (res.status === 200) {
|
||||
const body = JSON.parse(res.body);
|
||||
const active = body.measurements[0].value;
|
||||
dbConnections.add(active);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate ringkasan teks dari custom metrics (untuk print di teardown).
|
||||
*/
|
||||
summary() {
|
||||
return {
|
||||
errorRate: errorRate,
|
||||
locationP95: locationLatency,
|
||||
obstacleP95: obstacleLatency,
|
||||
sosP95: sosLatency,
|
||||
notifP95: notifLatency,
|
||||
authP95: authLatency,
|
||||
timelineP95: timelineLatency,
|
||||
pairingP95: pairingLatency,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default thresholds yang bisa di-reuse di semua scenario options.
|
||||
* Sesuai ketentuan pillar 3: p95 < 500ms, error rate < 1%.
|
||||
*/
|
||||
export const DEFAULT_THRESHOLDS = {
|
||||
// ── HTTP default k6
|
||||
http_req_duration: ["p(95)<500", "p(99)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
|
||||
// ── WalkGuide custom
|
||||
walkguide_error_rate: ["rate<0.01"],
|
||||
walkguide_endpoint_latency_ms: ["p(95)<500"],
|
||||
walkguide_location_latency_ms: ["p(95)<300"], // location harus cepat
|
||||
walkguide_obstacle_latency_ms: ["p(95)<400"],
|
||||
walkguide_sos_latency_ms: ["p(95)<200"], // SOS PALING KRITIS
|
||||
walkguide_notif_latency_ms: ["p(95)<500"],
|
||||
walkguide_auth_latency_ms: ["p(95)<800"],
|
||||
walkguide_timeline_latency_ms: ["p(95)<1000"], // query analytics boleh lebih lambat
|
||||
walkguide_pairing_latency_ms: ["p(95)<600"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Relaxed thresholds untuk stress test (ekspektasi lebih longgar).
|
||||
*/
|
||||
export const STRESS_THRESHOLDS = {
|
||||
http_req_duration: ["p(95)<1500", "p(99)<3000"],
|
||||
http_req_failed: ["rate<0.05"],
|
||||
walkguide_error_rate: ["rate<0.05"],
|
||||
walkguide_sos_latency_ms: ["p(95)<500"],
|
||||
};
|
||||
|
||||
/**
|
||||
* handleSummary helper — generate output JSON + text.
|
||||
* Import di tiap scenario: export { handleSummary } from '../modules/metrics-helper.js'
|
||||
*/
|
||||
export function handleSummary(data) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const fileName = `k6-results/summary_${stamp}.json`;
|
||||
|
||||
return {
|
||||
stdout: textSummary(data),
|
||||
[fileName]: JSON.stringify(data, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function textSummary(data) {
|
||||
const { metrics } = data;
|
||||
const lines = [
|
||||
"═══════════════════════════════════════════════════════════════",
|
||||
" WalkGuide Load Test — Result Summary",
|
||||
"═══════════════════════════════════════════════════════════════",
|
||||
];
|
||||
|
||||
const pick = (name, stat) => {
|
||||
if (metrics[name]) {
|
||||
const val = metrics[name].values[stat];
|
||||
return val !== undefined ? val.toFixed(2) : "N/A";
|
||||
}
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
lines.push(
|
||||
` http_req_duration p95=${pick("http_req_duration", "p(95)")}ms p99=${pick("http_req_duration", "p(99)")}ms`,
|
||||
);
|
||||
lines.push(` http_req_failed rate=${pick("http_req_failed", "rate")}`);
|
||||
lines.push(
|
||||
` sos_latency p95=${pick("walkguide_sos_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` location_latency p95=${pick("walkguide_location_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` obstacle_latency p95=${pick("walkguide_obstacle_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` notif_latency p95=${pick("walkguide_notif_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` auth_latency p95=${pick("walkguide_auth_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push(
|
||||
` timeline_latency p95=${pick("walkguide_timeline_latency_ms", "p(95)")}ms`,
|
||||
);
|
||||
lines.push("═══════════════════════════════════════════════════════════════");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* WalkGuide — Synthetic Test Data Generator
|
||||
* Menghasilkan data dummy yang realistis untuk semua skenario load test.
|
||||
*/
|
||||
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
|
||||
|
||||
// ── Alphabet untuk uniqueUserId (12 char alphanumeric) ──────────────────────
|
||||
const ALPHANUM =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
function randomString(len, chars = ALPHANUM) {
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 6) {
|
||||
return parseFloat((Math.random() * (max - min) + min).toFixed(decimals));
|
||||
}
|
||||
|
||||
function randomElement(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
// ── Koordinat realistis: sekitar Surabaya ───────────────────────────────────
|
||||
const SURABAYA_CENTER = { lat: -7.2575, lng: 112.7521 };
|
||||
|
||||
export function randomSurabayaCoord(radiusDegrees = 0.05) {
|
||||
return {
|
||||
lat: SURABAYA_CENTER.lat + randomFloat(-radiusDegrees, radiusDegrees),
|
||||
lng: SURABAYA_CENTER.lng + randomFloat(-radiusDegrees, radiusDegrees),
|
||||
};
|
||||
}
|
||||
|
||||
// ── User / Guardian data ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate payload untuk register GUARDIAN
|
||||
*/
|
||||
export function guardianRegisterPayload(suffix = "") {
|
||||
const uid = suffix || randomString(8);
|
||||
return {
|
||||
email: `guardian_${uid}@walkguide-test.com`,
|
||||
password: `Pass@${randomString(6)}!`,
|
||||
displayName: `Guardian ${uid}`,
|
||||
role: "ROLE_GUARDIAN",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate payload untuk register USER (tunanetra)
|
||||
*/
|
||||
export function userRegisterPayload(suffix = "") {
|
||||
const uid = suffix || randomString(8);
|
||||
return {
|
||||
email: `user_${uid}@walkguide-test.com`,
|
||||
password: `Pass@${randomString(6)}!`,
|
||||
displayName: `User ${uid}`,
|
||||
role: "ROLE_USER",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Location data ────────────────────────────────────────────────────────────
|
||||
|
||||
const DIRECTION_LABELS = [
|
||||
"NORTH",
|
||||
"NORTHEAST",
|
||||
"EAST",
|
||||
"SOUTHEAST",
|
||||
"SOUTH",
|
||||
"SOUTHWEST",
|
||||
"WEST",
|
||||
"NORTHWEST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate satu location update payload (simulasi GPS setiap 5 detik)
|
||||
*/
|
||||
export function locationUpdatePayload(
|
||||
baseLat = SURABAYA_CENTER.lat,
|
||||
baseLng = SURABAYA_CENTER.lng,
|
||||
) {
|
||||
return {
|
||||
lat: baseLat + randomFloat(-0.001, 0.001),
|
||||
lng: baseLng + randomFloat(-0.001, 0.001),
|
||||
accuracy: randomFloat(3.0, 15.0, 1),
|
||||
speed: randomFloat(0.5, 2.5, 2), // walking speed m/s
|
||||
heading: randomFloat(0.0, 359.9, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate array of location updates (simulasi walking path)
|
||||
*/
|
||||
export function walkingPath(
|
||||
steps = 10,
|
||||
startLat = SURABAYA_CENTER.lat,
|
||||
startLng = SURABAYA_CENTER.lng,
|
||||
) {
|
||||
const path = [];
|
||||
let lat = startLat,
|
||||
lng = startLng;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
lat += randomFloat(-0.0002, 0.0002);
|
||||
lng += randomFloat(-0.0002, 0.0002);
|
||||
path.push({
|
||||
lat,
|
||||
lng,
|
||||
accuracy: randomFloat(3, 10, 1),
|
||||
speed: randomFloat(0.8, 1.8, 2),
|
||||
heading: randomFloat(0, 360, 1),
|
||||
});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── Obstacle data ────────────────────────────────────────────────────────────
|
||||
|
||||
const YOLO_LABELS = [
|
||||
"person",
|
||||
"car",
|
||||
"motorcycle",
|
||||
"bicycle",
|
||||
"truck",
|
||||
"bus",
|
||||
"traffic light",
|
||||
"stop sign",
|
||||
"bench",
|
||||
"chair",
|
||||
"potted plant",
|
||||
"fire hydrant",
|
||||
"parking meter",
|
||||
"dog",
|
||||
"cat",
|
||||
"suitcase",
|
||||
];
|
||||
|
||||
const OBSTACLE_DIRECTIONS = ["LEFT", "CENTER", "RIGHT"];
|
||||
const OBSTACLE_DISTANCES = ["Very Close", "Close", "Medium", "Far"];
|
||||
|
||||
/**
|
||||
* Generate satu obstacle log payload (output YOLO detection)
|
||||
*/
|
||||
export function obstacleLogPayload() {
|
||||
const coord = randomSurabayaCoord(0.02);
|
||||
return {
|
||||
label: randomElement(YOLO_LABELS),
|
||||
confidence: randomFloat(0.5, 0.99, 4),
|
||||
direction: randomElement(OBSTACLE_DIRECTIONS),
|
||||
estimatedDist: randomElement(OBSTACLE_DISTANCES),
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate burst obstacle logs (simulasi: 5 FPS deteksi selama beberapa detik)
|
||||
*/
|
||||
export function obstacleLogBurst(count = 5) {
|
||||
return Array.from({ length: count }, () => obstacleLogPayload());
|
||||
}
|
||||
|
||||
// ── SOS data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SOS_TRIGGER_TYPES = ["VOICE_COMMAND", "BUTTON", "MANUAL"];
|
||||
|
||||
export function sosPayload() {
|
||||
const coord = randomSurabayaCoord(0.03);
|
||||
return {
|
||||
triggerType: randomElement(SOS_TRIGGER_TYPES),
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notification data ─────────────────────────────────────────────────────────
|
||||
|
||||
const NOTIFICATION_MESSAGES = [
|
||||
"Hati-hati ada kendaraan di depan!",
|
||||
"Sudah sampai tujuan belum?",
|
||||
"Minta kabar, kamu baik-baik saja?",
|
||||
"Ada yang bisa dibantu?",
|
||||
"Jangan lupa berhati-hati di jalan.",
|
||||
"Aku pantau dari sini ya.",
|
||||
"Sudah mau hujan, cari tempat teduh.",
|
||||
"Ada mobil di sebelah kanan!",
|
||||
"Tolong berhenti dulu.",
|
||||
"Aman ya? Jawab kalau dengar.",
|
||||
];
|
||||
|
||||
export function sendNotificationPayload() {
|
||||
return {
|
||||
notifType: "TEXT",
|
||||
content: randomElement(NOTIFICATION_MESSAGES),
|
||||
};
|
||||
}
|
||||
|
||||
// ── AI Config data ────────────────────────────────────────────────────────────
|
||||
|
||||
export function aiConfigPayload() {
|
||||
return {
|
||||
confidenceThreshold: randomFloat(0.3, 0.8, 2),
|
||||
alertDistanceClose: randomFloat(1.0, 2.5, 1),
|
||||
alertDistanceMedium: randomFloat(2.5, 5.0, 1),
|
||||
maxInferenceFps: randomInt(3, 10),
|
||||
enabledLabels: "ALL",
|
||||
};
|
||||
}
|
||||
|
||||
// ── User Settings data ────────────────────────────────────────────────────────
|
||||
|
||||
export function userSettingsPayload() {
|
||||
return {
|
||||
ttsLanguage: randomElement(["id-ID", "en-US"]),
|
||||
ttsPitch: randomFloat(0.8, 1.5, 1),
|
||||
ttsSpeed: randomFloat(0.7, 1.3, 1),
|
||||
warnNoGuardian: true,
|
||||
hapticEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Geofence Config data ──────────────────────────────────────────────────────
|
||||
|
||||
export function geofenceConfigPayload() {
|
||||
const coord = randomSurabayaCoord(0.01);
|
||||
return {
|
||||
centerLat: coord.lat,
|
||||
centerLng: coord.lng,
|
||||
radiusMeters: randomFloat(200, 1000, 0),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Unique user id (mirror dari backend logic) ───────────────────────────────
|
||||
|
||||
export function fakeUniqueUserId() {
|
||||
return randomString(12);
|
||||
}
|
||||
|
||||
// ── Generic helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export const testData = {
|
||||
guardianRegisterPayload,
|
||||
userRegisterPayload,
|
||||
locationUpdatePayload,
|
||||
walkingPath,
|
||||
obstacleLogPayload,
|
||||
obstacleLogBurst,
|
||||
sosPayload,
|
||||
sendNotificationPayload,
|
||||
aiConfigPayload,
|
||||
userSettingsPayload,
|
||||
geofenceConfigPayload,
|
||||
fakeUniqueUserId,
|
||||
randomSurabayaCoord,
|
||||
randomElement,
|
||||
randomInt,
|
||||
randomFloat,
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WalkGuide — k6 Test Runner
|
||||
# Jalankan semua k6 scenarios secara berurutan atau individual.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x run-all-tests.sh
|
||||
# ./run-all-tests.sh [mode] [BASE_URL]
|
||||
#
|
||||
# Modes:
|
||||
# smoke — 10 VUs, 1 menit (quick sanity check)
|
||||
# load — 50 VUs, 5 menit (normal production load, WAJIB untuk exam)
|
||||
# stress — 100 VUs, 10 menit (breaking point)
|
||||
# spike — 0→200 VUs sudden spike
|
||||
# all — jalankan smoke → load → stress → spike berurutan
|
||||
# auth — hanya auth-flow.js
|
||||
# pairing — hanya pairing-flow.js
|
||||
# location — hanya location-update.js
|
||||
# obstacle — hanya obstacle-logging.js
|
||||
# sos — hanya sos-flow.js
|
||||
# notif — hanya notification-send.js
|
||||
# timeline — hanya timeline-query.js
|
||||
#
|
||||
# Contoh:
|
||||
# ./run-all-tests.sh load http://202.46.28.160:8080
|
||||
# ./run-all-tests.sh smoke
|
||||
# ./run-all-tests.sh all http://202.46.28.160:8080
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
MODE="${1:-load}"
|
||||
BASE_URL="${2:-http://202.46.28.160:8080}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
RESULTS_DIR="${SCRIPT_DIR}/k6-results"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN}[✅ OK]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[⚠️ WARN]${NC} $1"; }
|
||||
err() { echo -e "${RED}[❌ ERR]${NC} $1"; }
|
||||
hr() { echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"; }
|
||||
|
||||
# ── Pre-flight checks ─────────────────────────────────────────────────────────
|
||||
hr
|
||||
echo -e "${BOLD} 🦺 WalkGuide — k6 Load Test Runner${NC}"
|
||||
echo -e " Mode: ${YELLOW}${MODE}${NC} | Backend: ${YELLOW}${BASE_URL}${NC}"
|
||||
hr
|
||||
|
||||
# Check k6 installed
|
||||
if ! command -v k6 &> /dev/null; then
|
||||
err "k6 not installed!"
|
||||
echo "Install: https://k6.io/docs/getting-started/installation/"
|
||||
echo "Ubuntu: sudo apt-get install k6"
|
||||
echo "Mac: brew install k6"
|
||||
echo "Docker: docker run grafana/k6 run ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
K6_VERSION=$(k6 version 2>&1 | head -1)
|
||||
ok "k6 found: $K6_VERSION"
|
||||
|
||||
# Check Node.js (for result parsing)
|
||||
if command -v node &> /dev/null; then
|
||||
ok "Node.js: $(node --version)"
|
||||
else
|
||||
warn "Node.js not found — skipping HTML report generation"
|
||||
fi
|
||||
|
||||
# Create results dir
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
ok "Results dir: $RESULTS_DIR"
|
||||
|
||||
# ── Backend reachability check ────────────────────────────────────────────────
|
||||
log "Checking backend reachability..."
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/auth/ping" --max-time 10 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_STATUS" = "200" ]; then
|
||||
ok "Backend reachable (HTTP $HTTP_STATUS)"
|
||||
elif [ "$HTTP_STATUS" = "000" ]; then
|
||||
warn "Backend not reachable or timeout. Continuing anyway (tests will fail gracefully)."
|
||||
else
|
||||
warn "Backend returned HTTP $HTTP_STATUS. Continuing..."
|
||||
fi
|
||||
|
||||
# ── k6 run helper ─────────────────────────────────────────────────────────────
|
||||
run_k6() {
|
||||
local scenario_file="$1"
|
||||
local scenario_name="$2"
|
||||
local extra_args="${3:-}"
|
||||
|
||||
local output_json="${RESULTS_DIR}/${scenario_name}_${TIMESTAMP}.ndjson"
|
||||
local summary_json="${RESULTS_DIR}/${scenario_name}_summary_${TIMESTAMP}.json"
|
||||
|
||||
log "Running scenario: ${BOLD}${scenario_name}${NC}"
|
||||
log "Script: ${scenario_file}"
|
||||
|
||||
local k6_cmd="k6 run \
|
||||
--out json=${output_json} \
|
||||
-e BASE_URL=${BASE_URL} \
|
||||
--summary-export=${summary_json} \
|
||||
--no-color \
|
||||
${extra_args} \
|
||||
${SCRIPT_DIR}/scenarios/${scenario_file}"
|
||||
|
||||
echo "Command: $k6_cmd"
|
||||
hr
|
||||
|
||||
if eval "$k6_cmd"; then
|
||||
ok "Scenario ${scenario_name} completed successfully"
|
||||
else
|
||||
warn "Scenario ${scenario_name} completed with threshold violations (check results)"
|
||||
fi
|
||||
|
||||
# Parse results if Node.js available
|
||||
if command -v node &> /dev/null && [ -f "$output_json" ]; then
|
||||
log "Parsing results..."
|
||||
local parsed_json="${RESULTS_DIR}/${scenario_name}_parsed_${TIMESTAMP}.json"
|
||||
local html_report="${RESULTS_DIR}/${scenario_name}_report_${TIMESTAMP}.html"
|
||||
|
||||
node "${SCRIPT_DIR}/utils/result-parser.js" "$output_json" "$parsed_json" 2>/dev/null || true
|
||||
node "${SCRIPT_DIR}/utils/html-reporter.js" "$parsed_json" "$html_report" 2>/dev/null || true
|
||||
|
||||
if [ -f "$html_report" ]; then
|
||||
ok "HTML report: $html_report"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Stage-specific k6 args ────────────────────────────────────────────────────
|
||||
SMOKE_ARGS="--stage 30s:3 --stage 60s:10 --stage 30s:0"
|
||||
# Note: --stage shorthand diganti inline di script JSON
|
||||
|
||||
# ── Test execution ─────────────────────────────────────────────────────────────
|
||||
case "$MODE" in
|
||||
|
||||
smoke)
|
||||
log "=== SMOKE TEST (10 VUs, 1 min) ==="
|
||||
run_k6 "auth-flow.js" "smoke_auth" "--vus 3 --duration 60s"
|
||||
run_k6 "location-update.js" "smoke_location" "--vus 3 --duration 60s"
|
||||
run_k6 "sos-flow.js" "smoke_sos" "--vus 2 --duration 60s"
|
||||
;;
|
||||
|
||||
load)
|
||||
log "=== LOAD TEST (50+ VUs, 5 min) — EXAM REQUIRED ==="
|
||||
run_k6 "auth-flow.js" "load_auth"
|
||||
run_k6 "pairing-flow.js" "load_pairing"
|
||||
run_k6 "location-update.js" "load_location"
|
||||
run_k6 "obstacle-logging.js" "load_obstacle"
|
||||
run_k6 "sos-flow.js" "load_sos"
|
||||
run_k6 "notification-send.js" "load_notif"
|
||||
run_k6 "timeline-query.js" "load_timeline"
|
||||
;;
|
||||
|
||||
stress)
|
||||
log "=== STRESS TEST (100 VUs, 10 min) ==="
|
||||
run_k6 "location-update.js" "stress_location"
|
||||
run_k6 "obstacle-logging.js" "stress_obstacle"
|
||||
run_k6 "notification-send.js" "stress_notif"
|
||||
run_k6 "timeline-query.js" "stress_timeline"
|
||||
;;
|
||||
|
||||
spike)
|
||||
log "=== SPIKE TEST (0→200 VUs sudden) ==="
|
||||
run_k6 "location-update.js" "spike_location"
|
||||
run_k6 "obstacle-logging.js" "spike_obstacle"
|
||||
;;
|
||||
|
||||
all)
|
||||
log "=== FULL TEST SUITE (smoke → load → stress → spike) ==="
|
||||
warn "Estimated total time: 30-45 minutes"
|
||||
read -p "Continue? [y/N] " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "Aborted."; exit 0
|
||||
fi
|
||||
|
||||
# Smoke
|
||||
log "--- Phase 1: Smoke Test ---"
|
||||
run_k6 "auth-flow.js" "fullsuite_smoke_auth" "--vus 3 --duration 60s"
|
||||
run_k6 "location-update.js" "fullsuite_smoke_location" "--vus 3 --duration 60s"
|
||||
|
||||
# Load
|
||||
log "--- Phase 2: Load Test ---"
|
||||
run_k6 "auth-flow.js" "fullsuite_load_auth"
|
||||
run_k6 "location-update.js" "fullsuite_load_location"
|
||||
run_k6 "obstacle-logging.js" "fullsuite_load_obstacle"
|
||||
run_k6 "sos-flow.js" "fullsuite_load_sos"
|
||||
run_k6 "notification-send.js" "fullsuite_load_notif"
|
||||
run_k6 "timeline-query.js" "fullsuite_load_timeline"
|
||||
|
||||
# Stress
|
||||
log "--- Phase 3: Stress Test ---"
|
||||
run_k6 "location-update.js" "fullsuite_stress_location"
|
||||
|
||||
# Spike
|
||||
log "--- Phase 4: Spike Test ---"
|
||||
run_k6 "location-update.js" "fullsuite_spike_location"
|
||||
;;
|
||||
|
||||
# Individual scenarios
|
||||
auth) run_k6 "auth-flow.js" "individual_auth" ;;
|
||||
pairing) run_k6 "pairing-flow.js" "individual_pairing" ;;
|
||||
location) run_k6 "location-update.js" "individual_location" ;;
|
||||
obstacle) run_k6 "obstacle-logging.js" "individual_obstacle" ;;
|
||||
sos) run_k6 "sos-flow.js" "individual_sos" ;;
|
||||
notif) run_k6 "notification-send.js" "individual_notif" ;;
|
||||
timeline) run_k6 "timeline-query.js" "individual_timeline" ;;
|
||||
|
||||
*)
|
||||
err "Unknown mode: $MODE"
|
||||
echo "Valid modes: smoke | load | stress | spike | all | auth | pairing | location | obstacle | sos | notif | timeline"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Final summary ─────────────────────────────────────────────────────────────
|
||||
hr
|
||||
ok "All k6 tests finished!"
|
||||
log "Results saved to: $RESULTS_DIR"
|
||||
echo ""
|
||||
echo "Files generated:"
|
||||
ls -lh "$RESULTS_DIR"/*.{json,html} 2>/dev/null || echo " (no files found)"
|
||||
hr
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* SCENARIO: auth-flow.js
|
||||
* Register → Login → Refresh Token → Logout flow
|
||||
*
|
||||
* Tujuan: Stress test auth endpoints yang dipakai setiap user saat app dibuka.
|
||||
* Target: p95 auth latency < 800ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
import { registerAndLogin, doRefreshToken } from "../modules/auth-helper.js";
|
||||
|
||||
// ── Konfigurasi test — override via --config atau env ───────────────────────
|
||||
export const options = {
|
||||
scenarios: {
|
||||
auth_flow: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 }, // warm-up
|
||||
{ duration: "60s", target: 30 }, // ramp-up
|
||||
{ duration: "60s", target: 30 }, // sustain
|
||||
{ duration: "30s", target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_auth_latency_ms: ["p(95)<800", "p(99)<1500"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function authFlow() {
|
||||
const vuId = __VU;
|
||||
|
||||
// ── Step 1: Register user baru (setiap VU punya unique email) ───────────────
|
||||
const role = vuId % 2 === 0 ? "ROLE_GUARDIAN" : "ROLE_USER";
|
||||
const suffix = `${vuId}_${__ITER}`;
|
||||
const payload =
|
||||
role === "ROLE_GUARDIAN"
|
||||
? testData.guardianRegisterPayload(suffix)
|
||||
: testData.userRegisterPayload(suffix);
|
||||
|
||||
const regRes = api.register(payload);
|
||||
check(regRes, {
|
||||
"register: status 200": (r) => r.status === 200,
|
||||
"register: has accessToken": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
});
|
||||
|
||||
let tokens = null;
|
||||
if (regRes.status === 200) {
|
||||
const body = api.parseBody(regRes);
|
||||
if (body && body.data) tokens = body.data;
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── Step 2: Login ulang dengan credentials yang sama ────────────────────────
|
||||
const loginRes = api.login(payload.email, payload.password);
|
||||
check(loginRes, {
|
||||
"login: status 200": (r) => r.status === 200,
|
||||
"login: accessToken set": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
"login: refreshToken set": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.refreshToken;
|
||||
},
|
||||
"login: role correct": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.role === role;
|
||||
},
|
||||
});
|
||||
|
||||
if (loginRes.status === 200) {
|
||||
const body = api.parseBody(loginRes);
|
||||
if (body && body.data) tokens = body.data;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Refresh token (simulasi token expire) ───────────────────────────
|
||||
if (tokens && tokens.refreshToken) {
|
||||
const refreshRes = api.refreshToken(tokens.refreshToken);
|
||||
check(refreshRes, {
|
||||
"refresh: status 200": (r) => r.status === 200,
|
||||
"refresh: new accessToken": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && !!b.data.accessToken;
|
||||
},
|
||||
});
|
||||
|
||||
// Update access token
|
||||
if (refreshRes.status === 200) {
|
||||
const body = api.parseBody(refreshRes);
|
||||
if (body && body.data) tokens.accessToken = body.data.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 4: Update FCM token (dilakukan tiap login di production) ───────────
|
||||
if (tokens && tokens.accessToken) {
|
||||
const fakeFcm = `fcm_${Math.random().toString(36).slice(2, 30)}:APA91b${Math.random().toString(36).slice(2, 50)}`;
|
||||
const fcmRes = api.updateFcmToken(tokens.accessToken, fakeFcm);
|
||||
check(fcmRes, {
|
||||
"fcm-token: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 5: Logout ──────────────────────────────────────────────────────────
|
||||
if (tokens && tokens.accessToken) {
|
||||
const logoutRes = api.logout(tokens.accessToken);
|
||||
check(logoutRes, {
|
||||
"logout: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Think time: user nyata butuh beberapa detik antar aksi ──────────────────
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
// ── Health check sebelum test dimulai ────────────────────────────────────────
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Backend tidak reachable. Ping returned ${res.status}. URL: ${__ENV.BASE_URL || "http://202.46.28.160:8080"}`,
|
||||
);
|
||||
}
|
||||
console.log("✅ Backend reachable. Auth flow test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* SCENARIO: location-update.js
|
||||
* Real-time location stress test — simulasi banyak User berjalan serentak.
|
||||
*
|
||||
* WalkGuide mengirim lokasi setiap 5 detik saat WalkGuide aktif.
|
||||
* Ini endpoint paling high-frequency di seluruh sistem.
|
||||
*
|
||||
* Target: p95 < 300ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
metricsHelper,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://202.46.28.160:8080";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
location_stress: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "30s", target: 20 }, // ramp-up
|
||||
{ duration: "90s", target: 50 }, // sustain at 50 walking users
|
||||
{ duration: "60s", target: 100 }, // peak load
|
||||
{ duration: "30s", target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_location_latency_ms: ["p(95)<300", "p(99)<600"],
|
||||
http_req_duration: ["p(95)<400"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── VU shared state (tiap VU punya token sendiri) ─────────────────────────────
|
||||
let vuTokens = null;
|
||||
let vuBaseLat = null;
|
||||
let vuBaseLng = null;
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
export default function locationStress() {
|
||||
// ── Inisialisasi: register + login sekali per VU ───────────────────────────
|
||||
if (!vuTokens) {
|
||||
const suffix = `loc_${__VU}_${Date.now()}`;
|
||||
const payload = testData.userRegisterPayload(suffix);
|
||||
const res = api.register(payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data) vuTokens = body.data;
|
||||
}
|
||||
|
||||
if (!vuTokens) {
|
||||
console.error(`[location] VU ${__VU} failed to get tokens`);
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Posisi awal acak di Surabaya
|
||||
const coord = testData.randomSurabayaCoord(0.05);
|
||||
vuBaseLat = coord.lat;
|
||||
vuBaseLng = coord.lng;
|
||||
|
||||
// Start walkguide session
|
||||
const startRes = api.startWalkguide(vuTokens.accessToken);
|
||||
check(startRes, {
|
||||
"walkguide-start: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Poll actuator untuk JVM heap (hanya VU 1, tidak mengganggu tes lain) ────
|
||||
if (__VU === 1 && __ITER % 10 === 0) {
|
||||
metricsHelper.pollJvmHeap(BASE_URL);
|
||||
metricsHelper.pollDbConnections(BASE_URL);
|
||||
}
|
||||
|
||||
// ── Simulasi walking: kirim 3 location update berturut-turut (15 detik real) ─
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const locPayload = testData.locationUpdatePayload(vuBaseLat, vuBaseLng);
|
||||
|
||||
const res = api.updateLocation(
|
||||
vuTokens.accessToken,
|
||||
locPayload.lat,
|
||||
locPayload.lng,
|
||||
locPayload.accuracy,
|
||||
locPayload.speed,
|
||||
locPayload.heading,
|
||||
);
|
||||
|
||||
check(res, {
|
||||
"location-update: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"location-update: latency ok": (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
// Update base coordinate (simulasi pergerakan)
|
||||
vuBaseLat += testData.randomFloat(-0.0003, 0.0003);
|
||||
vuBaseLng += testData.randomFloat(-0.0003, 0.0003);
|
||||
|
||||
// 5 detik interval seperti production
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
// ── Kadang Guardian juga poll lokasi (setiap 10 iteration) ──────────────────
|
||||
// Ini simulasi Guardian membuka map screen
|
||||
if (__ITER % 5 === 0 && vuTokens) {
|
||||
// Re-login as guardian to test guardian endpoint
|
||||
// Dalam real load test, Guardian VU terpisah — di sini kita skip
|
||||
// karena token kita adalah ROLE_USER
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup: siapkan backend ────────────────────────────────────────────────────
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log(`✅ Location stress test starting. Backend: ${BASE_URL}`);
|
||||
return { startTime: Date.now() };
|
||||
}
|
||||
|
||||
// ── Teardown: stop semua walkguide session ────────────────────────────────────
|
||||
export function teardown(data) {
|
||||
console.log(
|
||||
`Location stress test finished. Duration: ${(Date.now() - data.startTime) / 1000}s`,
|
||||
);
|
||||
// Note: individual VU stop walkguide tidak bisa dilakukan di teardown
|
||||
// karena token sudah tidak tersedia. Dalam production, session timeout handles ini.
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* SCENARIO: notification-send.js
|
||||
* Mass notification broadcast — Guardian kirim pesan ke User.
|
||||
*
|
||||
* Simulasi banyak Guardian mengirim notifikasi serentak + User membaca.
|
||||
* Termasuk test mark-read dan unread-count endpoints.
|
||||
*
|
||||
* Target: p95 < 500ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
// Scenario A: Guardian send notifications
|
||||
guardian_send: {
|
||||
executor: "constant-vus",
|
||||
vus: 25,
|
||||
duration: "120s",
|
||||
exec: "guardianSend",
|
||||
tags: { scenario: "guardian_send" },
|
||||
},
|
||||
// Scenario B: User read notifications (mulai setelah 10s)
|
||||
user_read: {
|
||||
executor: "constant-vus",
|
||||
vus: 25,
|
||||
duration: "100s",
|
||||
startTime: "10s",
|
||||
exec: "userRead",
|
||||
tags: { scenario: "user_read" },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_notif_latency_ms: ["p(95)<500", "p(99)<1000"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Shared VU states ──────────────────────────────────────────────────────────
|
||||
let guardianVuToken = null;
|
||||
let userVuToken = null;
|
||||
|
||||
// ── EXEC: Guardian send notifications ────────────────────────────────────────
|
||||
export function guardianSend() {
|
||||
if (!guardianVuToken) {
|
||||
const suffix = `notif_g_${__VU}_${Date.now()}`;
|
||||
const res = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (res.status === 200) {
|
||||
const b = api.parseBody(res);
|
||||
if (b && b.data) guardianVuToken = b.data.accessToken;
|
||||
}
|
||||
if (!guardianVuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// Kirim 1-3 notifikasi per iterasi
|
||||
const count = testData.randomInt(1, 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const payload = testData.sendNotificationPayload();
|
||||
const res = api.sendNotification(guardianVuToken, payload);
|
||||
|
||||
check(res, {
|
||||
"send-notif: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"send-notif: latency < 500": (r) => r.timings.duration < 500,
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// Cek activity log guardian
|
||||
if (__ITER % 5 === 0) {
|
||||
const logsRes = api.getActivityLogsGuardian(guardianVuToken);
|
||||
check(logsRes, {
|
||||
"guardian-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
// ── EXEC: User read notifications ─────────────────────────────────────────────
|
||||
export function userRead() {
|
||||
if (!userVuToken) {
|
||||
const suffix = `notif_u_${__VU}_${Date.now()}`;
|
||||
const res = api.register(testData.userRegisterPayload(suffix));
|
||||
if (res.status === 200) {
|
||||
const b = api.parseBody(res);
|
||||
if (b && b.data) userVuToken = b.data.accessToken;
|
||||
}
|
||||
if (!userVuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// ── Step 1: Cek unread count ─────────────────────────────────────────────
|
||||
const unreadRes = api.getUnreadCount(userVuToken);
|
||||
check(unreadRes, {
|
||||
"unread-count: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"unread-count: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 2: Get notification list ────────────────────────────────────────
|
||||
const page = __ITER % 3; // test pagination
|
||||
const notifRes = api.getNotifications(userVuToken, page, 20);
|
||||
check(notifRes, {
|
||||
"get-notifs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"get-notifs: has list": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Extract first notif id untuk test mark-read
|
||||
let firstNotifId = null;
|
||||
if (notifRes.status >= 200 && notifRes.status < 300) {
|
||||
const body = api.parseBody(notifRes);
|
||||
if (
|
||||
body &&
|
||||
body.data &&
|
||||
Array.isArray(body.data.content) &&
|
||||
body.data.content.length > 0
|
||||
) {
|
||||
firstNotifId = body.data.content[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 3: Mark individual notification read (jika ada) ─────────────────
|
||||
if (firstNotifId) {
|
||||
const markRes = api.markOneRead(userVuToken, firstNotifId);
|
||||
check(markRes, {
|
||||
"mark-one-read: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// ── Step 4: Mark all read (setiap 5 iterasi) ─────────────────────────────
|
||||
if (__ITER % 5 === 0) {
|
||||
const markAllRes = api.markAllRead(userVuToken);
|
||||
check(markAllRes, {
|
||||
"mark-all-read: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
sleep(testData.randomFloat(0.5, 2.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log(
|
||||
"📬 Notification send/read test starting (2 parallel scenarios).",
|
||||
);
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* SCENARIO: obstacle-logging.js
|
||||
* High-frequency obstacle detection log — simulasi YOLO inference results.
|
||||
*
|
||||
* WalkGuide menjalankan YOLO pada 5 FPS, setiap obstacle terdeteksi dikirim ke backend.
|
||||
* Ini endpoint write-heavy terbesar karena obstacle bisa sangat sering.
|
||||
*
|
||||
* Target: p95 < 400ms, throughput > 200 req/s pada 50 VUs
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
obstacle_burst: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "120s", target: 50 }, // sustain high load
|
||||
{ duration: "20s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "10s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_obstacle_latency_ms: ["p(95)<400", "p(99)<800"],
|
||||
http_reqs: ["rate>100"], // throughput > 100 req/s
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let vuToken = null;
|
||||
|
||||
export default function obstacleLogging() {
|
||||
// ── Init: register + login ─────────────────────────────────────────────────
|
||||
if (!vuToken) {
|
||||
const suffix = `obs_${__VU}_${Date.now()}`;
|
||||
const payload = testData.userRegisterPayload(suffix);
|
||||
const res = api.register(payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
const body = api.parseBody(res);
|
||||
if (body && body.data) vuToken = body.data.accessToken;
|
||||
}
|
||||
|
||||
if (!vuToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start walkguide
|
||||
api.startWalkguide(vuToken);
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// ── Burst mode: kirim 5 obstacle logs (simulasi 5 FPS selama 1 detik) ──────
|
||||
const burst = testData.obstacleLogBurst(5);
|
||||
let allSuccess = true;
|
||||
|
||||
for (const obstaclePayload of burst) {
|
||||
const res = api.logObstacle(vuToken, obstaclePayload);
|
||||
|
||||
const ok = check(res, {
|
||||
"obstacle-log: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"obstacle-log: fast response": (r) => r.timings.duration < 600,
|
||||
});
|
||||
|
||||
if (!ok) allSuccess = false;
|
||||
|
||||
// Minimal sleep (5 FPS = 200ms per frame)
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
// ── Setiap 10 iteration, kirim obstacle dengan confidence tinggi ────────────
|
||||
if (__ITER % 10 === 0) {
|
||||
const criticalObstacle = {
|
||||
label: "person",
|
||||
confidence: 0.95,
|
||||
direction: "CENTER",
|
||||
estimatedDist: "Very Close",
|
||||
lat: testData.randomSurabayaCoord().lat,
|
||||
lng: testData.randomSurabayaCoord().lng,
|
||||
};
|
||||
const res = api.logObstacle(vuToken, criticalObstacle);
|
||||
check(res, {
|
||||
"critical-obstacle: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"critical-obstacle: fast": (r) => r.timings.duration < 300,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Think time minimal — obstacle logging adalah high-frequency ─────────────
|
||||
sleep(testData.randomFloat(0.1, 0.5, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("✅ Obstacle logging stress test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SCENARIO: pairing-flow.js
|
||||
* Guardian invite → User accept/reject → Pairing status check → Unpair
|
||||
*
|
||||
* Tujuan: Test business logic pairing — paling complex di WalkGuide karena
|
||||
* melibatkan dua role berbeda, constraint unique, dan state transitions.
|
||||
* Target: p95 pairing latency < 600ms, error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
pairing_flow: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 5 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "20s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "20s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_pairing_latency_ms: ["p(95)<600", "p(99)<1200"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helper: register dan dapat tokens ────────────────────────────────────────
|
||||
function registerGetTokens(payload) {
|
||||
const res = api.register(payload);
|
||||
if (res.status !== 200) return null;
|
||||
const body = api.parseBody(res);
|
||||
return body && body.data ? body.data : null;
|
||||
}
|
||||
|
||||
// ── Main VU function ──────────────────────────────────────────────────────────
|
||||
export default function pairingFlow() {
|
||||
const vuId = __VU;
|
||||
const iter = __ITER;
|
||||
const suffix = `${vuId}_${iter}_${Date.now()}`;
|
||||
|
||||
// ── Step 1: Register Guardian ─────────────────────────────────────────────
|
||||
const gPayload = testData.guardianRegisterPayload(suffix);
|
||||
const gTokens = registerGetTokens(gPayload);
|
||||
|
||||
if (!gTokens) {
|
||||
console.error(`[pairing-flow] Guardian register failed for VU ${vuId}`);
|
||||
sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 2: Register User (tunanetra) ─────────────────────────────────────
|
||||
const uPayload = testData.userRegisterPayload(suffix);
|
||||
const uTokens = registerGetTokens(uPayload);
|
||||
|
||||
if (!uTokens) {
|
||||
console.error(`[pairing-flow] User register failed for VU ${vuId}`);
|
||||
sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// uniqueUserId harus ada untuk user baru
|
||||
const uniqueUserId = uTokens.uniqueUserId;
|
||||
if (!uniqueUserId) {
|
||||
console.warn(`[pairing-flow] uniqueUserId missing, VU ${vuId}`);
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Check pairing status (harus NONE/belum paired) ───────────────
|
||||
const statusRes1 = api.getPairingStatus(gTokens.accessToken);
|
||||
check(statusRes1, {
|
||||
"pairing-status initial: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 4: Guardian invite User ─────────────────────────────────────────
|
||||
const inviteRes = api.inviteUser(
|
||||
gTokens.accessToken,
|
||||
uniqueUserId || testData.fakeUniqueUserId(),
|
||||
);
|
||||
check(inviteRes, {
|
||||
"pairing-invite: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"pairing-invite: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
let pairingId = null;
|
||||
if (inviteRes.status >= 200 && inviteRes.status < 300) {
|
||||
const body = api.parseBody(inviteRes);
|
||||
pairingId = body && body.data ? body.data.pairingId : null;
|
||||
}
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 5: User check pending invitation ─────────────────────────────────
|
||||
const statusRes2 = api.getPairingStatus(uTokens.accessToken);
|
||||
check(statusRes2, {
|
||||
"user-pairing-status: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"user-pairing-status: PENDING": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
// Bisa PENDING atau NONE jika invite belum proses
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 6: User accept atau reject (alternating per iteration) ───────────
|
||||
if (pairingId) {
|
||||
const shouldAccept = iter % 3 !== 0; // 2/3 accept, 1/3 reject
|
||||
const respondRes = api.respondPairing(
|
||||
uTokens.accessToken,
|
||||
pairingId,
|
||||
shouldAccept,
|
||||
);
|
||||
check(respondRes, {
|
||||
"pairing-respond: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 7: Confirm status setelah respond ──────────────────────────────
|
||||
const statusRes3 = api.getPairingStatus(gTokens.accessToken);
|
||||
check(statusRes3, {
|
||||
"pairing-confirm: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"pairing-confirm: status matches": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
if (!b || !b.data) return true; // optional
|
||||
const s = b.data.status;
|
||||
return shouldAccept ? s === "ACTIVE" : s === "REJECTED" || s === "NONE";
|
||||
},
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 8: Unpair (cleanup + test unpair endpoint) ────────────────────
|
||||
if (shouldAccept) {
|
||||
const unpairRes = api.unpair(gTokens.accessToken);
|
||||
check(unpairRes, {
|
||||
"unpair: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 9: Test bahwa Guardian tidak bisa masuk ke /user endpoint ─────────
|
||||
const forbiddenRes = api.getUserProfile(gTokens.accessToken);
|
||||
check(forbiddenRes, {
|
||||
"RBAC: guardian cannot access /user/profile": (r) =>
|
||||
r.status === 403 || r.status === 401,
|
||||
});
|
||||
|
||||
// ── Step 10: Test bahwa User tidak bisa akses /guardian endpoint ───────────
|
||||
const forbiddenRes2 = api.getGuardianDashboard(uTokens.accessToken);
|
||||
check(forbiddenRes2, {
|
||||
"RBAC: user cannot access /guardian/dashboard": (r) =>
|
||||
r.status === 403 || r.status === 401,
|
||||
});
|
||||
|
||||
sleep(testData.randomFloat(0.5, 1.5, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("✅ Pairing flow test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* SCENARIO: sos-flow.js
|
||||
* SOS trigger → Guardian acknowledge → Status resolution
|
||||
*
|
||||
* SOS adalah fitur PALING KRITIS di WalkGuide — life-safety feature.
|
||||
* Harus memiliki latency terendah di seluruh sistem.
|
||||
*
|
||||
* Target: p95 < 200ms, error rate = 0% (zero tolerance)
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sos_critical: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "15s", target: 5 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "60s", target: 20 },
|
||||
{ duration: "15s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "10s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
// SOS: zero error tolerance, ultra-low latency
|
||||
walkguide_sos_latency_ms: ["p(95)<200", "p(99)<400", "max<800"],
|
||||
walkguide_error_rate: ["rate<0.005"], // < 0.5% error untuk SOS
|
||||
http_req_failed: ["rate<0.005"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let userToken = null;
|
||||
let guardianToken = null;
|
||||
|
||||
export default function sosFlow() {
|
||||
// ── Init: setup user + guardian pair ──────────────────────────────────────
|
||||
if (!userToken || !guardianToken) {
|
||||
const suffix = `sos_${__VU}_${Date.now()}`;
|
||||
|
||||
// Register user
|
||||
const uRes = api.register(testData.userRegisterPayload(suffix));
|
||||
if (uRes.status === 200) {
|
||||
const b = api.parseBody(uRes);
|
||||
if (b && b.data) userToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
// Register guardian
|
||||
const gRes = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (gRes.status === 200) {
|
||||
const b = api.parseBody(gRes);
|
||||
if (b && b.data) guardianToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
if (!userToken || !guardianToken) {
|
||||
console.error(`[sos-flow] VU ${__VU} failed to get tokens`);
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Step 1: User trigger SOS ───────────────────────────────────────────────
|
||||
const sosTriggerTypes = ["VOICE_COMMAND", "BUTTON", "MANUAL"];
|
||||
const triggerType = sosTriggerTypes[__ITER % 3];
|
||||
const coord = testData.randomSurabayaCoord(0.03);
|
||||
|
||||
const startTs = Date.now();
|
||||
const sosRes = api.triggerSos(userToken, triggerType, coord.lat, coord.lng);
|
||||
const triggerMs = Date.now() - startTs;
|
||||
|
||||
const sosOk = check(sosRes, {
|
||||
"sos-trigger: status 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"sos-trigger: latency < 200ms": (r) => r.timings.duration < 200,
|
||||
"sos-trigger: has sosId": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.id !== undefined;
|
||||
},
|
||||
"sos-trigger: status TRIGGERED": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.status === "TRIGGERED";
|
||||
},
|
||||
});
|
||||
|
||||
let sosId = null;
|
||||
if (sosRes.status >= 200 && sosRes.status < 300) {
|
||||
const body = api.parseBody(sosRes);
|
||||
sosId = body && body.data ? body.data.id : null;
|
||||
}
|
||||
|
||||
if (!sosOk) {
|
||||
console.warn(
|
||||
`[sos-flow] SOS trigger not OK for VU ${__VU}, trigger latency: ${triggerMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
sleep(0.2);
|
||||
|
||||
// ── Step 2: Guardian poll SOS events ──────────────────────────────────────
|
||||
const sosListRes = api.getSosEvents(guardianToken);
|
||||
check(sosListRes, {
|
||||
"guardian-sos-list: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── Step 3: Guardian acknowledge SOS ──────────────────────────────────────
|
||||
if (sosId) {
|
||||
const ackRes = api.acknowledgeSos(guardianToken, sosId);
|
||||
check(ackRes, {
|
||||
"sos-acknowledge: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"sos-acknowledge: status ACKNOWLEDGED": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data && b.data.status === "ACKNOWLEDGED";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── Step 4: User check activity logs (SOS harus ter-log) ──────────────────
|
||||
const logsRes = api.getActivityLogs(userToken, 0, 5);
|
||||
check(logsRes, {
|
||||
"activity-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// ── Think time antar SOS (SOS tidak terjadi setiap detik di production) ────
|
||||
sleep(testData.randomFloat(1.0, 3.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("🚨 SOS flow test starting — Zero error tolerance mode.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* SCENARIO: timeline-query.js
|
||||
* Timeline analytics endpoints — Guardian dashboard, location history, activity logs.
|
||||
*
|
||||
* Simulasi Guardian yang aktif monitoring: buka dashboard, lihat peta,
|
||||
* scroll history, cek obstacle logs. Query-heavy read scenario.
|
||||
*
|
||||
* Target: p95 < 1000ms (analytics boleh lebih lambat), error rate < 1%
|
||||
*/
|
||||
import { sleep, check } from "k6";
|
||||
import {
|
||||
DEFAULT_THRESHOLDS,
|
||||
handleSummary,
|
||||
} from "../modules/metrics-helper.js";
|
||||
import * as api from "../modules/api-client.js";
|
||||
import { testData } from "../modules/test-data.js";
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
timeline_analytics: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: "20s", target: 10 },
|
||||
{ duration: "90s", target: 30 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
gracefulRampDown: "15s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...DEFAULT_THRESHOLDS,
|
||||
walkguide_timeline_latency_ms: ["p(95)<1000", "p(99)<2000"],
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Per-VU state ──────────────────────────────────────────────────────────────
|
||||
let guardianToken = null;
|
||||
let userToken = null;
|
||||
|
||||
export default function timelineQuery() {
|
||||
// ── Init: setup both roles ─────────────────────────────────────────────────
|
||||
if (!guardianToken || !userToken) {
|
||||
const suffix = `tl_${__VU}_${Date.now()}`;
|
||||
|
||||
const gRes = api.register(testData.guardianRegisterPayload(suffix));
|
||||
if (gRes.status === 200) {
|
||||
const b = api.parseBody(gRes);
|
||||
if (b && b.data) guardianToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
const uRes = api.register(testData.userRegisterPayload(suffix));
|
||||
if (uRes.status === 200) {
|
||||
const b = api.parseBody(uRes);
|
||||
if (b && b.data) userToken = b.data.accessToken;
|
||||
}
|
||||
|
||||
if (!guardianToken || !userToken) {
|
||||
sleep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed beberapa location updates agar ada data untuk query
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const coord = testData.randomSurabayaCoord(0.02);
|
||||
api.updateLocation(userToken, coord.lat, coord.lng, 5.0, 1.2, 90.0);
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
// Seed obstacle logs
|
||||
for (let i = 0; i < 5; i++) {
|
||||
api.logObstacle(userToken, testData.obstacleLogPayload());
|
||||
sleep(0.1);
|
||||
}
|
||||
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
// ── Guardian Dashboard Simulation ─────────────────────────────────────────
|
||||
|
||||
// Step 1: Guardian dashboard (overview semua data)
|
||||
const dashRes = api.getGuardianDashboard(guardianToken);
|
||||
check(dashRes, {
|
||||
"dashboard: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"dashboard: fast": (r) => r.timings.duration < 1000,
|
||||
"dashboard: has data": (r) => {
|
||||
const b = api.parseBody(r);
|
||||
return b && b.data !== undefined;
|
||||
},
|
||||
});
|
||||
sleep(0.5);
|
||||
|
||||
// Step 2: Get user location (paling sering dipanggil di Guardian map screen)
|
||||
const locRes = api.getUserLocation(guardianToken);
|
||||
check(locRes, {
|
||||
"user-location: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"user-location: fast": (r) => r.timings.duration < 500,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 3: Location history (scroll timeline peta seperti Google Maps Timeline)
|
||||
const pages = [0, 1, 2];
|
||||
const pageSizes = [20, 50, 100];
|
||||
|
||||
for (const page of pages) {
|
||||
const size = pageSizes[page % pageSizes.length];
|
||||
const histRes = api.getLocationHistory(guardianToken, page, size);
|
||||
check(histRes, {
|
||||
[`location-history page${page}: 2xx`]: (r) =>
|
||||
r.status >= 200 && r.status < 300,
|
||||
[`location-history page${page}: latency`]: (r) =>
|
||||
r.timings.duration < 1500,
|
||||
});
|
||||
sleep(0.3);
|
||||
}
|
||||
|
||||
// Step 4: Obstacle logs (Guardian ingin tahu apa saja yang terdeteksi)
|
||||
const obsRes = api.getObstacleLogs(guardianToken, 0, 20);
|
||||
check(obsRes, {
|
||||
"obstacle-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"obstacle-logs: fast": (r) => r.timings.duration < 800,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 5: Activity logs (timeline aktivitas User)
|
||||
const actRes = api.getActivityLogsGuardian(guardianToken, 0, 20);
|
||||
check(actRes, {
|
||||
"activity-logs: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
"activity-logs: fast": (r) => r.timings.duration < 1000,
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Step 6: SOS events history
|
||||
const sosRes = api.getSosEvents(guardianToken, 0, 10);
|
||||
check(sosRes, {
|
||||
"sos-events: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
|
||||
// Step 7: AI config (Guardian mau lihat setting YOLO)
|
||||
const aiRes = api.getAiConfig(guardianToken);
|
||||
check(aiRes, {
|
||||
"ai-config: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
sleep(0.2);
|
||||
|
||||
// Step 8: Geofence config
|
||||
const geoRes = api.getGeofenceConfig(guardianToken);
|
||||
check(geoRes, {
|
||||
"geofence: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// ── User side: check own activity ─────────────────────────────────────────
|
||||
sleep(0.5);
|
||||
|
||||
const uActRes = api.getActivityLogs(userToken, 0, 10);
|
||||
check(uActRes, {
|
||||
"user-activity: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
const uSettRes = api.getUserSettings(userToken);
|
||||
check(uSettRes, {
|
||||
"user-settings: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
const uAiRes = api.getUserAiConfig(userToken);
|
||||
check(uAiRes, {
|
||||
"user-ai-config: 2xx": (r) => r.status >= 200 && r.status < 300,
|
||||
});
|
||||
|
||||
// Think time — Guardian tidak refresh setiap detik
|
||||
sleep(testData.randomFloat(2.0, 5.0, 1));
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = api.ping();
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Backend not reachable: ${res.status}`);
|
||||
console.log("📊 Timeline analytics test starting.");
|
||||
}
|
||||
|
||||
export { handleSummary };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* WalkGuide — k6 HTML Report Generator (Node.js)
|
||||
* Mengkonversi parsed-report.json menjadi HTML report yang rapi untuk submission.
|
||||
*
|
||||
* Usage:
|
||||
* node html-reporter.js <parsed-report.json> [output.html]
|
||||
*
|
||||
* Output: HTML report siap lampir ke laporan final exam.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const inputFile = process.argv[2];
|
||||
const outputFile = process.argv[3] || "k6-results/load-test-report.html";
|
||||
|
||||
if (!inputFile || !fs.existsSync(inputFile)) {
|
||||
console.error(
|
||||
"Usage: node html-reporter.js <parsed-report.json> [output.html]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(inputFile, "utf8"));
|
||||
|
||||
// ── HTML Template ─────────────────────────────────────────────────────────────
|
||||
function badge(result) {
|
||||
if (!result) return '<span class="badge skip">SKIP</span>';
|
||||
if (result.includes("PASS")) return '<span class="badge pass">✅ PASS</span>';
|
||||
if (result.includes("FAIL")) return '<span class="badge fail">❌ FAIL</span>';
|
||||
return `<span class="badge skip">${result}</span>`;
|
||||
}
|
||||
|
||||
function metricRow(label, val) {
|
||||
return `<tr><td>${label}</td><td><strong>${val || "N/A"}</strong></td></tr>`;
|
||||
}
|
||||
|
||||
function endpointTableRows() {
|
||||
const rows = [];
|
||||
for (const [ep, metrics] of Object.entries(report.endpoints || {})) {
|
||||
const dur = metrics["http_req_duration"];
|
||||
if (!dur) continue;
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td><code>${ep}</code></td>
|
||||
<td>${dur.count || "—"}</td>
|
||||
<td>${dur.avg || "—"}</td>
|
||||
<td>${dur.p95 || "—"}</td>
|
||||
<td>${dur.p99 || "—"}</td>
|
||||
<td>${dur.max || "—"}</td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
return rows.join("");
|
||||
}
|
||||
|
||||
function thresholdRows() {
|
||||
return (report.thresholdResults || [])
|
||||
.map(
|
||||
(t) => `
|
||||
<tr>
|
||||
<td>${t.name}</td>
|
||||
<td><code>${t.metric}</code></td>
|
||||
<td>${t.threshold}</td>
|
||||
<td>${t.actual}</td>
|
||||
<td>${badge(t.result)}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function wgMetricCards() {
|
||||
const wg = report.walkguideMetrics || {};
|
||||
const cards = [
|
||||
{ label: "🔐 Auth", key: "authLatency", threshold: "< 800ms" },
|
||||
{ label: "📍 Location", key: "locationLatency", threshold: "< 300ms" },
|
||||
{ label: "🚧 Obstacle", key: "obstacleLatency", threshold: "< 400ms" },
|
||||
{ label: "🚨 SOS", key: "sosLatency", threshold: "< 200ms" },
|
||||
{ label: "📬 Notif", key: "notifLatency", threshold: "< 500ms" },
|
||||
{ label: "📊 Timeline", key: "timelineLatency", threshold: "< 1000ms" },
|
||||
{ label: "🔗 Pairing", key: "pairingLatency", threshold: "< 600ms" },
|
||||
];
|
||||
|
||||
return cards
|
||||
.map((c) => {
|
||||
const data = wg[c.key];
|
||||
return `
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">${c.label}</div>
|
||||
<div class="metric-p95">${data ? data.p95 : "N/A"}</div>
|
||||
<div class="metric-sub">p95 | threshold ${c.threshold}</div>
|
||||
<div class="metric-avg">avg: ${data ? data.avg : "N/A"} | p99: ${data ? data.p99 : "N/A"}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>WalkGuide — k6 Load Test Report</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; }
|
||||
h1 { font-size: 2rem; color: #38bdf8; margin-bottom: 4px; }
|
||||
h2 { font-size: 1.2rem; color: #7dd3fc; margin: 32px 0 12px; border-bottom: 1px solid #1e3a5f; padding-bottom: 6px; }
|
||||
p { color: #94a3b8; margin-bottom: 8px; }
|
||||
.header { margin-bottom: 32px; }
|
||||
.header .meta { color: #64748b; font-size: 0.85rem; margin-top: 4px; }
|
||||
|
||||
/* Key Metrics Grid */
|
||||
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.key-card { background: #1e293b; border-radius: 10px; padding: 18px; border: 1px solid #334155; }
|
||||
.key-card .klabel { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.key-card .kvalue { font-size: 1.5rem; font-weight: 700; color: #f0f9ff; margin: 6px 0 2px; }
|
||||
.key-card .kdesc { font-size: 0.72rem; color: #475569; }
|
||||
|
||||
/* WalkGuide Metric Cards */
|
||||
.wg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
||||
.metric-card { background: #1e293b; border-radius: 8px; padding: 14px; border: 1px solid #1e3a5f; text-align: center; }
|
||||
.metric-label { font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
|
||||
.metric-p95 { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
|
||||
.metric-sub { font-size: 0.7rem; color: #475569; margin-top: 2px; }
|
||||
.metric-avg { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 0.88rem; }
|
||||
th { background: #1e3a5f; color: #7dd3fc; text-align: left; padding: 10px 12px; }
|
||||
td { padding: 9px 12px; border-bottom: 1px solid #1e293b; color: #cbd5e1; }
|
||||
tr:nth-child(even) td { background: #0f1f35; }
|
||||
code { background: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 0.82rem; color: #38bdf8; }
|
||||
|
||||
/* Badges */
|
||||
.badge { padding: 3px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
|
||||
.badge.pass { background: #064e3b; color: #34d399; }
|
||||
.badge.fail { background: #450a0a; color: #f87171; }
|
||||
.badge.skip { background: #1e293b; color: #94a3b8; }
|
||||
|
||||
footer { margin-top: 40px; text-align: center; color: #334155; font-size: 0.78rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🦺 WalkGuide — k6 Load Test Report</h1>
|
||||
<p>Spring Boot Backend Performance Benchmark — Pillar 3</p>
|
||||
<div class="meta">
|
||||
Generated: ${report.generatedAt} |
|
||||
Input: ${path.basename(report.inputFile || "")} |
|
||||
Total Data Points: ${report.totalPoints}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>📊 Key Metrics (Pillar 3 — 5 Required)</h2>
|
||||
<div class="key-grid">
|
||||
<div class="key-card">
|
||||
<div class="klabel">1. Throughput</div>
|
||||
<div class="kvalue">${report.keyMetrics.throughput.value || "N/A"}</div>
|
||||
<div class="kdesc">Requests per second</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">2. p95 Latency</div>
|
||||
<div class="kvalue">${report.keyMetrics.p95Latency.value || "N/A"}</div>
|
||||
<div class="kdesc">95th percentile response time</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">3. Error Rate</div>
|
||||
<div class="kvalue">${report.keyMetrics.errorRate.value || "N/A"}</div>
|
||||
<div class="kdesc">Non-2xx responses | ${report.keyMetrics.errorRate.passFail || "—"}</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">4. DB Query Time</div>
|
||||
<div class="kvalue">${report.keyMetrics.dbQueryTime.value || "N/A"}</div>
|
||||
<div class="kdesc">Estimated via write endpoint p95</div>
|
||||
</div>
|
||||
<div class="key-card">
|
||||
<div class="klabel">5. JVM Heap</div>
|
||||
<div class="kvalue">${report.keyMetrics.jvmHeap.value || "N/A"}</div>
|
||||
<div class="kdesc">Spring Actuator jvm.memory.used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>🏃 WalkGuide Endpoint Latency Breakdown</h2>
|
||||
<div class="wg-grid">${wgMetricCards()}</div>
|
||||
|
||||
<h2>✅ Threshold Evaluation</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Check</th><th>Metric</th><th>Threshold</th><th>Actual</th><th>Result</th></tr>
|
||||
</thead>
|
||||
<tbody>${thresholdRows()}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>📋 Per-Endpoint Latency (ms)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Endpoint</th><th>Requests</th><th>Avg</th><th>p95</th><th>p99</th><th>Max</th></tr>
|
||||
</thead>
|
||||
<tbody>${endpointTableRows()}</tbody>
|
||||
</table>
|
||||
|
||||
<footer>
|
||||
WalkGuide AI — Integrated Mobile Application Project Final Exam |
|
||||
Generated by html-reporter.js
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────────────
|
||||
const outDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(outputFile, html);
|
||||
console.log(`✅ HTML report written to: ${outputFile}`);
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* WalkGuide — k6 JSON Result Parser (Node.js)
|
||||
* Parse output dari k6 --out json=output.ndjson dan generate readable report.
|
||||
*
|
||||
* Usage:
|
||||
* node result-parser.js <input.ndjson> [output-report.json]
|
||||
*
|
||||
* k6 JSON format: satu JSON object per baris (newline-delimited JSON / NDJSON)
|
||||
* Setiap baris adalah metric data point atau test info.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ── Parse args ────────────────────────────────────────────────────────────────
|
||||
const inputFile = process.argv[2];
|
||||
const outputFile = process.argv[3] || "k6-results/parsed-report.json";
|
||||
|
||||
if (!inputFile) {
|
||||
console.error(
|
||||
"Usage: node result-parser.js <k6-output.ndjson> [output.json]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`Input file not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Read and parse NDJSON ─────────────────────────────────────────────────────
|
||||
const raw = fs.readFileSync(inputFile, "utf8");
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
|
||||
const dataPoints = [];
|
||||
const errors = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
dataPoints.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
errors.push({ line: line.slice(0, 100), error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Parsed ${dataPoints.length} data points, ${errors.length} parse errors.`,
|
||||
);
|
||||
|
||||
// ── Aggregate metrics ─────────────────────────────────────────────────────────
|
||||
const metricAggregates = {};
|
||||
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.type !== "Point") continue;
|
||||
|
||||
const metricName = dp.metric;
|
||||
const value = dp.data && dp.data.value !== undefined ? dp.data.value : 0;
|
||||
const tags = dp.data && dp.data.tags ? dp.data.tags : {};
|
||||
|
||||
if (!metricAggregates[metricName]) {
|
||||
metricAggregates[metricName] = {
|
||||
values: [],
|
||||
byEndpoint: {},
|
||||
};
|
||||
}
|
||||
|
||||
metricAggregates[metricName].values.push(value);
|
||||
|
||||
// Group by endpoint tag
|
||||
const endpoint = tags.endpoint || "unknown";
|
||||
if (!metricAggregates[metricName].byEndpoint[endpoint]) {
|
||||
metricAggregates[metricName].byEndpoint[endpoint] = [];
|
||||
}
|
||||
metricAggregates[metricName].byEndpoint[endpoint].push(value);
|
||||
}
|
||||
|
||||
// ── Statistical helpers ───────────────────────────────────────────────────────
|
||||
function percentile(arr, p) {
|
||||
if (!arr || arr.length === 0) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const idx = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
function avg(arr) {
|
||||
if (!arr || arr.length === 0) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
function summarizeSeries(arr) {
|
||||
if (!arr || arr.length === 0) return null;
|
||||
return {
|
||||
count: arr.length,
|
||||
min: Math.min(...arr).toFixed(2),
|
||||
max: Math.max(...arr).toFixed(2),
|
||||
avg: avg(arr).toFixed(2),
|
||||
p50: percentile(arr, 50).toFixed(2),
|
||||
p90: percentile(arr, 90).toFixed(2),
|
||||
p95: percentile(arr, 95).toFixed(2),
|
||||
p99: percentile(arr, 99).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build structured report ───────────────────────────────────────────────────
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
inputFile: path.resolve(inputFile),
|
||||
totalPoints: dataPoints.length,
|
||||
parseErrors: errors.length,
|
||||
|
||||
// 5 KEY METRICS sesuai ketentuan pillar 3:
|
||||
keyMetrics: {
|
||||
// 1. Throughput — request per second
|
||||
throughput: {
|
||||
label: "Throughput (req/s)",
|
||||
description: "Total HTTP requests divided by test duration",
|
||||
value: null, // dihitung di bawah
|
||||
},
|
||||
// 2. p95 Latency — overall
|
||||
p95Latency: {
|
||||
label: "p95 Response Time (ms)",
|
||||
description: "95th percentile of all HTTP request durations",
|
||||
value: null,
|
||||
},
|
||||
// 3. Error Rate
|
||||
errorRate: {
|
||||
label: "Error Rate (%)",
|
||||
description: "Percentage of non-2xx HTTP responses",
|
||||
value: null,
|
||||
passFail: null,
|
||||
},
|
||||
// 4. DB Query Time (estimated via response time)
|
||||
dbQueryTime: {
|
||||
label: "Estimated DB Query Time (ms)",
|
||||
description: "p95 of write endpoints (location + obstacle) as DB proxy",
|
||||
value: null,
|
||||
note: "Actual DB time requires Spring Actuator or DB metrics",
|
||||
},
|
||||
// 5. JVM Heap
|
||||
jvmHeap: {
|
||||
label: "JVM Heap Used (MB)",
|
||||
description: "From Spring Actuator metric, collected during test",
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Per-metric detailed stats
|
||||
metrics: {},
|
||||
|
||||
// Per-endpoint breakdown
|
||||
endpoints: {},
|
||||
|
||||
// WalkGuide-specific breakdown
|
||||
walkguideMetrics: {
|
||||
authLatency: null,
|
||||
locationLatency: null,
|
||||
obstacleLatency: null,
|
||||
sosLatency: null,
|
||||
notifLatency: null,
|
||||
timelineLatency: null,
|
||||
pairingLatency: null,
|
||||
},
|
||||
|
||||
// Threshold evaluation
|
||||
thresholdResults: [],
|
||||
};
|
||||
|
||||
// ── Populate metrics ──────────────────────────────────────────────────────────
|
||||
for (const [name, data] of Object.entries(metricAggregates)) {
|
||||
report.metrics[name] = summarizeSeries(data.values);
|
||||
|
||||
// Per-endpoint
|
||||
for (const [ep, vals] of Object.entries(data.byEndpoint)) {
|
||||
if (!report.endpoints[ep]) report.endpoints[ep] = {};
|
||||
report.endpoints[ep][name] = summarizeSeries(vals);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fill key metrics ──────────────────────────────────────────────────────────
|
||||
if (report.metrics["http_req_duration"]) {
|
||||
report.keyMetrics.p95Latency.value =
|
||||
report.metrics["http_req_duration"].p95 + " ms";
|
||||
}
|
||||
|
||||
if (report.metrics["http_req_failed"]) {
|
||||
const rate = (
|
||||
parseFloat(report.metrics["http_req_failed"].avg) * 100
|
||||
).toFixed(2);
|
||||
report.keyMetrics.errorRate.value = rate + "%";
|
||||
report.keyMetrics.errorRate.passFail = parseFloat(rate) < 1 ? "PASS" : "FAIL";
|
||||
}
|
||||
|
||||
if (report.metrics["walkguide_jvm_heap_used_mb"]) {
|
||||
report.keyMetrics.jvmHeap.value =
|
||||
report.metrics["walkguide_jvm_heap_used_mb"].avg + " MB";
|
||||
}
|
||||
|
||||
// DB estimate: avg of location_update + obstacle_log p95
|
||||
const dbMetrics = [
|
||||
"walkguide_location_latency_ms",
|
||||
"walkguide_obstacle_latency_ms",
|
||||
];
|
||||
const dbVals = dbMetrics
|
||||
.filter((m) => report.metrics[m])
|
||||
.map((m) => parseFloat(report.metrics[m].p95));
|
||||
if (dbVals.length > 0) {
|
||||
report.keyMetrics.dbQueryTime.value =
|
||||
(dbVals.reduce((a, b) => a + b, 0) / dbVals.length).toFixed(2) + " ms";
|
||||
}
|
||||
|
||||
// Throughput: dari http_reqs total / test duration (estimasi dari data points)
|
||||
if (report.metrics["http_reqs"]) {
|
||||
const totalReqs = report.metrics["http_reqs"].count;
|
||||
// Estimasi durasi dari timestamps
|
||||
const timestamps = dataPoints
|
||||
.filter((dp) => dp.type === "Point" && dp.metric === "http_reqs")
|
||||
.map((dp) => new Date(dp.data.time).getTime());
|
||||
if (timestamps.length > 1) {
|
||||
const durationS =
|
||||
(Math.max(...timestamps) - Math.min(...timestamps)) / 1000;
|
||||
report.keyMetrics.throughput.value =
|
||||
(totalReqs / durationS).toFixed(1) + " req/s";
|
||||
} else {
|
||||
report.keyMetrics.throughput.value = totalReqs + " total requests";
|
||||
}
|
||||
}
|
||||
|
||||
// ── WalkGuide-specific ────────────────────────────────────────────────────────
|
||||
const wgMetricMap = {
|
||||
authLatency: "walkguide_auth_latency_ms",
|
||||
locationLatency: "walkguide_location_latency_ms",
|
||||
obstacleLatency: "walkguide_obstacle_latency_ms",
|
||||
sosLatency: "walkguide_sos_latency_ms",
|
||||
notifLatency: "walkguide_notif_latency_ms",
|
||||
timelineLatency: "walkguide_timeline_latency_ms",
|
||||
pairingLatency: "walkguide_pairing_latency_ms",
|
||||
};
|
||||
|
||||
for (const [key, metricName] of Object.entries(wgMetricMap)) {
|
||||
if (report.metrics[metricName]) {
|
||||
report.walkguideMetrics[key] = {
|
||||
p95: report.metrics[metricName].p95 + " ms",
|
||||
p99: report.metrics[metricName].p99 + " ms",
|
||||
avg: report.metrics[metricName].avg + " ms",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Threshold checks ──────────────────────────────────────────────────────────
|
||||
const thresholdChecks = [
|
||||
{
|
||||
name: "p95 overall < 500ms",
|
||||
metric: "http_req_duration",
|
||||
stat: "p95",
|
||||
threshold: 500,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "error rate < 1%",
|
||||
metric: "http_req_failed",
|
||||
stat: "avg",
|
||||
threshold: 0.01,
|
||||
unit: "rate",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "SOS p95 < 200ms",
|
||||
metric: "walkguide_sos_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 200,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Location p95 < 300ms",
|
||||
metric: "walkguide_location_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 300,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Obstacle p95 < 400ms",
|
||||
metric: "walkguide_obstacle_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 400,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Auth p95 < 800ms",
|
||||
metric: "walkguide_auth_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 800,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Timeline p95 < 1000ms",
|
||||
metric: "walkguide_timeline_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 1000,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
{
|
||||
name: "Notification p95 < 500ms",
|
||||
metric: "walkguide_notif_latency_ms",
|
||||
stat: "p95",
|
||||
threshold: 500,
|
||||
unit: "ms",
|
||||
operator: "<",
|
||||
},
|
||||
];
|
||||
|
||||
for (const check of thresholdChecks) {
|
||||
const m = report.metrics[check.metric];
|
||||
if (!m) {
|
||||
report.thresholdResults.push({ ...check, actual: "N/A", result: "SKIP" });
|
||||
continue;
|
||||
}
|
||||
const actual = parseFloat(m[check.stat]);
|
||||
const pass =
|
||||
check.operator === "<"
|
||||
? actual < check.threshold
|
||||
: actual > check.threshold;
|
||||
report.thresholdResults.push({
|
||||
name: check.name,
|
||||
metric: check.metric,
|
||||
threshold: check.threshold + " " + check.unit,
|
||||
actual: actual.toFixed(2) + " " + check.unit,
|
||||
result: pass ? "✅ PASS" : "❌ FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Write output ──────────────────────────────────────────────────────────────
|
||||
const outDir = path.dirname(outputFile);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2));
|
||||
console.log(`\n✅ Report written to: ${outputFile}`);
|
||||
|
||||
// ── Print summary to stdout ───────────────────────────────────────────────────
|
||||
console.log("\n═══════════════════════════════════════════════════════");
|
||||
console.log(" WalkGuide k6 — Parsed Result Summary");
|
||||
console.log("═══════════════════════════════════════════════════════");
|
||||
console.log(` Throughput: ${report.keyMetrics.throughput.value || "N/A"}`);
|
||||
console.log(` p95 Latency: ${report.keyMetrics.p95Latency.value || "N/A"}`);
|
||||
console.log(
|
||||
` Error Rate: ${report.keyMetrics.errorRate.value || "N/A"} [${report.keyMetrics.errorRate.passFail || "N/A"}]`,
|
||||
);
|
||||
console.log(` DB Est. Time: ${report.keyMetrics.dbQueryTime.value || "N/A"}`);
|
||||
console.log(` JVM Heap: ${report.keyMetrics.jvmHeap.value || "N/A"}`);
|
||||
console.log("\n Threshold Results:");
|
||||
for (const t of report.thresholdResults) {
|
||||
console.log(` ${t.result} ${t.name} (actual: ${t.actual})`);
|
||||
}
|
||||
console.log("═══════════════════════════════════════════════════════\n");
|
||||
Reference in New Issue
Block a user