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); } }); }