who_need_help/load/k6/public-and-websocket.js

501 lines
13 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 positiveInteger(name) {
const value = Number(required(name));
if (!Number.isInteger(value) || 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(/\/+$/, "");
const publicOrigin = required("PUBLIC_ORIGIN").replace(/\/+$/, "");
if (!/^https?:\/\/[^/?#]+$/.test(baseUrl)) {
throw new Error("BASE_URL must be an HTTP(S) origin without a path");
}
if (!/^https?:\/\/[^/?#]+$/.test(publicOrigin)) {
throw new Error("PUBLIC_ORIGIN must be an HTTP(S) origin without a path");
}
const websocketUrl =
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
const httpVus = positiveInteger("HTTP_VUS");
const websocketVus = positiveInteger("WS_VUS");
const authenticatedVus = positiveInteger("AUTH_VUS");
const maxVus = httpVus + websocketVus + authenticatedVus;
const authWebsocketTimeoutMs = positiveInteger("AUTH_WS_TIMEOUT_MS");
const authThinkSeconds = nonNegativeNumber("AUTH_THINK_SECONDS");
const fixturePassword = required("FIXTURE_PASSWORD");
const fixtureManifest = JSON.parse(open("/fixtures/fixtures.json"));
const fixtureRunId = required("FIXTURE_RUN_ID");
if (
fixtureManifest.schema_version !== 1 ||
fixtureManifest.run_id !== fixtureRunId ||
!Array.isArray(fixtureManifest.fixtures) ||
fixtureManifest.fixtures.length < maxVus
) {
throw new Error("The authenticated-load fixture manifest is invalid or too small");
}
const websocketOpened = new Counter("wnh_websocket_opened");
const websocketErrors = new Counter("wnh_websocket_errors");
const heartbeatReplies = new Counter("wnh_websocket_heartbeat_replies");
const authenticatedLogins = new Counter("wnh_authenticated_logins");
const authenticatedPages = new Counter("wnh_authenticated_pages");
const trackingUpdates = new Counter("wnh_tracking_updates");
const liveViewJoins = new Counter("wnh_liveview_joins");
const liveViewTrackingStarts = new Counter("wnh_liveview_tracking_starts");
const liveViewTrackingStops = new Counter("wnh_liveview_tracking_stops");
const liveViewMessages = new Counter("wnh_liveview_messages");
const authenticatedErrors = new Counter("wnh_authenticated_errors");
let authenticated = false;
let fixture;
export const options = {
discardResponseBodies: true,
// The isolated Traefik profile uses its generated local-only certificate.
// Transport remains TLS; trust verification is scoped to this k6 process.
insecureSkipTLSVerify: true,
// Each authenticated VU represents one continuing user session.
noCookiesReset: true,
scenarios: {
public_http: {
executor: "constant-vus",
exec: "publicHttp",
vus: httpVus,
duration: required("DURATION"),
},
phoenix_websocket: {
executor: "constant-vus",
exec: "phoenixWebsocket",
vus: websocketVus,
duration: required("DURATION"),
},
authenticated_mutual_aid: {
executor: "constant-vus",
exec: "authenticatedMutualAid",
vus: authenticatedVus,
duration: required("DURATION"),
},
},
};
export function publicHttp() {
const responses = http.batch([
{
method: "GET",
url: `${baseUrl}/`,
tags: { endpoint: "home" },
},
{
method: "GET",
url: `${baseUrl}/safety`,
tags: { endpoint: "safety" },
},
{
method: "GET",
url: `${baseUrl}/healthz/ready`,
tags: { endpoint: "readiness" },
},
]);
check(responses[0], { "home returned 200": (response) => response.status === 200 });
check(responses[1], { "safety returned 200": (response) => response.status === 200 });
check(responses[2], {
"readiness returned 200": (response) => response.status === 200,
});
sleep(httpThinkSeconds);
}
export function phoenixWebsocket() {
const socket = new WebSocket(websocketUrl, [], {
tags: { endpoint: "phoenix_live_socket" },
});
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] &&
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);
}
});
}
function htmlAttribute(tag, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tag.match(new RegExp(`\\s${escaped}=(?:"([^"]*)"|'([^']*)')`));
if (!match) {
return null;
}
return (match[1] ?? match[2])
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'");
}
function csrfToken(body) {
const metaTags = body.match(/<meta\b[^>]*>/gi) || [];
const tag = metaTags.find((candidate) => htmlAttribute(candidate, "name") === "csrf-token");
return tag ? htmlAttribute(tag, "content") : null;
}
function liveViewRoot(body) {
const tags = body.match(/<[^!/][^>]*>/g) || [];
const tag = tags.find((candidate) => /\sdata-phx-main(?:\s|=|>)/.test(candidate));
if (!tag) {
return null;
}
const root = {
id: htmlAttribute(tag, "id"),
session: htmlAttribute(tag, "data-phx-session"),
static: htmlAttribute(tag, "data-phx-static"),
};
return root.id && root.session ? root : null;
}
function fixtureForVu() {
const index = exec.vu.idInInstance - 1;
const selected = fixtureManifest.fixtures[index];
if (!selected) {
throw new Error(`No fixture exists for VU ${exec.vu.idInInstance}`);
}
return selected;
}
function authenticateActor(actor) {
const loginPage = http.get(`${baseUrl}/users/log-in`, {
responseType: "text",
tags: { endpoint: "auth_login_page" },
});
const csrf = typeof loginPage.body === "string" ? csrfToken(loginPage.body) : null;
if (loginPage.status !== 200 || !csrf) {
authenticatedErrors.add(1);
return false;
}
const login = http.post(
`${baseUrl}/users/log-in`,
{
_csrf_token: csrf,
"user[email]": actor.helper_email,
"user[password]": fixturePassword,
},
{
redirects: 0,
tags: { endpoint: "auth_password_login" },
},
);
const jar = http.cookieJar();
const cookies = jar.cookiesForURL(baseUrl);
const succeeded =
login.status === 302 &&
Array.isArray(cookies._who_need_help_key) &&
cookies._who_need_help_key.length > 0;
if (succeeded) {
authenticatedLogins.add(1);
} else {
authenticatedErrors.add(1);
}
return succeeded;
}
export function authenticatedMutualAid() {
if (!fixture) {
fixture = fixtureForVu();
}
if (!authenticated) {
authenticated = authenticateActor(fixture);
}
if (!authenticated) {
sleep(authThinkSeconds);
return;
}
const requestPath = `/requests/${fixture.request_id}`;
const page = http.get(`${baseUrl}${requestPath}`, {
responseType: "text",
tags: { endpoint: "authenticated_request_page" },
});
const body = typeof page.body === "string" ? page.body : "";
const csrf = csrfToken(body);
const root = liveViewRoot(body);
if (page.status !== 200 || !csrf || !root) {
authenticatedErrors.add(1);
sleep(authThinkSeconds);
return;
}
authenticatedPages.add(1);
const socketUrl =
baseUrl.replace(/^http/, "ws") +
`/live/websocket?_csrf_token=${encodeURIComponent(csrf)}` +
"&client_type=browser&vsn=2.0.0";
const socket = new WebSocket(socketUrl, [], {
jar: http.cookieJar(),
headers: { Origin: publicOrigin },
tags: { endpoint: "authenticated_liveview" },
});
const joinRef = `${exec.vu.idInInstance}-${__ITER}-join`;
const trackingRef = `${exec.vu.idInInstance}-${__ITER}-tracking`;
const eventRef = `${exec.vu.idInInstance}-${__ITER}-message`;
const stopRef = `${exec.vu.idInInstance}-${__ITER}-stop`;
const topic = `lv:${root.id}`;
let completed = false;
const timer = setTimeout(() => {
if (!completed) {
authenticatedErrors.add(1);
}
socket.close();
}, authWebsocketTimeoutMs);
socket.addEventListener("open", () => {
socket.send(
JSON.stringify([
joinRef,
joinRef,
topic,
"phx_join",
{
url: `${publicOrigin}${requestPath}`,
params: {
_csrf_token: csrf,
client_type: "browser",
_mounts: 0,
_mount_attempts: 0,
_track_static: [],
},
session: root.session,
static: root.static || null,
},
]),
);
});
socket.addEventListener("message", (event) => {
let frame;
try {
frame = JSON.parse(event.data);
} catch (_error) {
return;
}
if (!Array.isArray(frame) || frame[2] !== topic || frame[3] !== "phx_reply") {
return;
}
if (frame[1] === joinRef && frame[4]?.status === "ok") {
liveViewJoins.add(1);
socket.send(
JSON.stringify([
joinRef,
trackingRef,
topic,
"event",
{
type: "click",
event: "start-tracking",
value: {},
cid: null,
},
]),
);
} else if (frame[1] === trackingRef && frame[4]?.status === "ok") {
liveViewTrackingStarts.add(1);
const trackingStartResponse = frame[4]?.response ?? null;
const latitude = fixture.latitude + ((__ITER % 20) + 1) / 1_000_000;
const longitude = fixture.longitude + ((__ITER % 20) + 1) / 1_000_000;
const tracking = http.post(
`${baseUrl}/mobile/tracking/${fixture.assignment_id}/position`,
{
latitude: String(latitude),
longitude: String(longitude),
accuracy_meters: "5",
captured_at: new Date().toISOString(),
},
{
headers: { "x-csrf-token": csrf },
responseType: "text",
tags: { endpoint: "authenticated_tracking_update" },
},
);
if (tracking.status !== 204) {
console.error(
`authenticated tracking update failed: ${JSON.stringify({
vu_id_in_instance: exec.vu.idInInstance,
vu_id_in_test: exec.vu.idInTest,
fixture_index: fixture.index,
iteration: __ITER,
status: tracking.status,
response_body: tracking.body,
tracking_start_response: trackingStartResponse,
})}`,
);
completed = true;
authenticatedErrors.add(1);
clearTimeout(timer);
socket.close();
return;
}
trackingUpdates.add(1);
const message =
`load:${fixtureRunId}:vu-${exec.vu.idInInstance}:iteration-${__ITER}`;
const formValue = `message%5Bbody%5D=${encodeURIComponent(message)}`;
socket.send(
JSON.stringify([
joinRef,
eventRef,
topic,
"event",
{
type: "form",
event: "send-message",
value: formValue,
meta: { _target: "undefined" },
uploads: {},
cid: null,
},
]),
);
} else if (frame[1] === eventRef && frame[4]?.status === "ok") {
liveViewMessages.add(1);
socket.send(
JSON.stringify([
joinRef,
stopRef,
topic,
"event",
{
type: "click",
event: "stop-tracking",
value: {},
cid: null,
},
]),
);
} else if (frame[1] === stopRef && frame[4]?.status === "ok") {
completed = true;
liveViewTrackingStops.add(1);
clearTimeout(timer);
socket.close();
} else if (
frame[1] === joinRef ||
frame[1] === trackingRef ||
frame[1] === eventRef ||
frame[1] === stopRef
) {
completed = true;
authenticatedErrors.add(1);
clearTimeout(timer);
socket.close();
}
});
socket.addEventListener("error", () => {
if (!completed) {
completed = true;
authenticatedErrors.add(1);
}
clearTimeout(timer);
socket.close();
});
socket.addEventListener("close", () => clearTimeout(timer));
sleep(authThinkSeconds);
}