195 lines
5.0 KiB
JavaScript
195 lines
5.0 KiB
JavaScript
import { check, sleep } from "k6";
|
|
import http from "k6/http";
|
|
import exec from "k6/execution";
|
|
import { Counter } from "k6/metrics";
|
|
import { WebSocket } from "k6/websockets";
|
|
|
|
function required(name) {
|
|
const value = __ENV[name];
|
|
|
|
if (value === undefined || value === "") {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function nonNegativeInteger(name) {
|
|
const value = Number(required(name));
|
|
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
throw new Error(`${name} must be a non-negative integer`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function positiveInteger(name) {
|
|
const value = nonNegativeInteger(name);
|
|
|
|
if (value === 0) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function nonNegativeNumber(name) {
|
|
const value = Number(required(name));
|
|
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
throw new Error(`${name} must be a non-negative number`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
|
|
|
|
if (baseUrl !== "https://whoneedhelp.com") {
|
|
throw new Error("The production read-only profile is pinned to https://whoneedhelp.com");
|
|
}
|
|
|
|
const httpVus = nonNegativeInteger("HTTP_VUS");
|
|
const websocketVus = nonNegativeInteger("WS_VUS");
|
|
|
|
if (httpVus + websocketVus === 0) {
|
|
throw new Error("At least one HTTP or WebSocket VU is required");
|
|
}
|
|
|
|
const duration = required("DURATION");
|
|
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
|
|
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
|
|
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
|
|
const websocketUrl =
|
|
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
|
|
|
|
// This allowlist is intentionally compiled into the read-only profile. It
|
|
// contains GET-only public pages and the readiness endpoint. Authentication,
|
|
// registration, support submission, email delivery, and every mutating route
|
|
// are outside this file.
|
|
const publicRoutes = [
|
|
{ path: "/", endpoint: "home" },
|
|
{ path: "/safety", endpoint: "safety" },
|
|
{ path: "/privacy", endpoint: "privacy" },
|
|
{ path: "/terms", endpoint: "terms" },
|
|
{ path: "/users/log-in", endpoint: "login_page" },
|
|
{ path: "/users/register", endpoint: "registration_page" },
|
|
{ path: "/healthz/ready", endpoint: "readiness" },
|
|
];
|
|
|
|
const websocketOpened = new Counter("wnh_readonly_websocket_opened");
|
|
const websocketErrors = new Counter("wnh_readonly_websocket_errors");
|
|
const heartbeatReplies = new Counter("wnh_readonly_heartbeat_replies");
|
|
|
|
const scenarios = {};
|
|
const thresholds = {};
|
|
|
|
if (httpVus > 0) {
|
|
scenarios.public_http = {
|
|
executor: "constant-vus",
|
|
exec: "publicHttp",
|
|
vus: httpVus,
|
|
duration,
|
|
};
|
|
thresholds.checks = [{ threshold: "rate==1", abortOnFail: true }];
|
|
thresholds.http_req_failed = [{ threshold: "rate==0", abortOnFail: true }];
|
|
}
|
|
|
|
if (websocketVus > 0) {
|
|
scenarios.phoenix_websocket = {
|
|
executor: "constant-vus",
|
|
exec: "phoenixWebsocket",
|
|
vus: websocketVus,
|
|
duration,
|
|
};
|
|
thresholds.wnh_readonly_websocket_errors = [
|
|
{ threshold: "count==0", abortOnFail: true },
|
|
];
|
|
}
|
|
|
|
export const options = {
|
|
discardResponseBodies: true,
|
|
scenarios,
|
|
thresholds,
|
|
};
|
|
|
|
export function publicHttp() {
|
|
const route = publicRoutes[exec.scenario.iterationInTest % publicRoutes.length];
|
|
const response = http.get(`${baseUrl}${route.path}`, {
|
|
redirects: 0,
|
|
tags: { endpoint: route.endpoint, safety_scope: "production_readonly" },
|
|
headers: { "User-Agent": "WhoNeedHelp-production-readonly-load/1" },
|
|
});
|
|
|
|
check(response, {
|
|
[`GET ${route.path} returned 200`]: (candidate) => candidate.status === 200,
|
|
});
|
|
|
|
sleep(httpThinkSeconds);
|
|
}
|
|
|
|
export function phoenixWebsocket() {
|
|
const socket = new WebSocket(websocketUrl, [], {
|
|
tags: {
|
|
endpoint: "phoenix_live_socket",
|
|
safety_scope: "production_readonly",
|
|
},
|
|
headers: {
|
|
Origin: baseUrl,
|
|
"User-Agent": "WhoNeedHelp-production-readonly-load/1",
|
|
},
|
|
});
|
|
const ref = `${__VU}-${__ITER}`;
|
|
let opened = false;
|
|
let closeTimer;
|
|
const connectTimer = setTimeout(() => {
|
|
if (!opened) {
|
|
websocketErrors.add(1);
|
|
socket.close();
|
|
}
|
|
}, websocketConnectTimeoutMs);
|
|
|
|
socket.addEventListener("open", () => {
|
|
opened = true;
|
|
clearTimeout(connectTimer);
|
|
websocketOpened.add(1);
|
|
socket.send(JSON.stringify([null, ref, "phoenix", "heartbeat", {}]));
|
|
closeTimer = setTimeout(() => socket.close(), websocketHoldMs);
|
|
});
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
let frame;
|
|
|
|
try {
|
|
frame = JSON.parse(event.data);
|
|
} catch (_error) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
Array.isArray(frame) &&
|
|
frame[1] === ref &&
|
|
frame[2] === "phoenix" &&
|
|
frame[3] === "phx_reply" &&
|
|
frame[4]?.status === "ok"
|
|
) {
|
|
heartbeatReplies.add(1);
|
|
}
|
|
});
|
|
|
|
socket.addEventListener("error", () => {
|
|
websocketErrors.add(1);
|
|
clearTimeout(connectTimer);
|
|
socket.close();
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
clearTimeout(connectTimer);
|
|
|
|
if (closeTimer !== undefined) {
|
|
clearTimeout(closeTimer);
|
|
}
|
|
});
|
|
}
|