501 lines
13 KiB
JavaScript
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("&", "&")
|
|
.replaceAll(""", '"')
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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);
|
|
}
|