test: add k6 works list load test
This commit is contained in:
228
scripts/loadtest/k6-works-list.js
Normal file
228
scripts/loadtest/k6-works-list.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/* global __ENV */
|
||||
import { check, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import http from 'k6/http';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
|
||||
const DEFAULT_WORKS_DATA = 'scripts/loadtest/data/works-list.local.json';
|
||||
const WORKS_DATA = __ENV.WORKS_DATA || DEFAULT_WORKS_DATA;
|
||||
const BASE_URL = (__ENV.BASE_URL || 'http://127.0.0.1:8787').replace(/\/+$/u, '');
|
||||
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
|
||||
const SCENARIO = __ENV.SCENARIO || 'smoke';
|
||||
const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s';
|
||||
const SLEEP_MIN_SECONDS = Number(__ENV.SLEEP_MIN_SECONDS || '0.5');
|
||||
const SLEEP_MAX_SECONDS = Number(__ENV.SLEEP_MAX_SECONDS || '2');
|
||||
const DETAIL_RATIO = Number(__ENV.DETAIL_RATIO || '0');
|
||||
|
||||
const worksListShapeErrorRate = new Rate('works_list_shape_error_rate');
|
||||
const worksDetailShapeErrorRate = new Rate('works_detail_shape_error_rate');
|
||||
const worksListDuration = new Trend('works_list_duration');
|
||||
const worksDetailDuration = new Trend('works_detail_duration');
|
||||
|
||||
const data = new SharedArray('works-list-data', () => [JSON.parse(open(WORKS_DATA))])[0];
|
||||
const normalizedWorks = Array.isArray(data.normalizedWorks) ? data.normalizedWorks : [];
|
||||
|
||||
const scenarioOptions = {
|
||||
smoke: {
|
||||
scenarios: {
|
||||
smoke: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.VUS || 1),
|
||||
duration: __ENV.DURATION || '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<800'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
baseline: {
|
||||
scenarios: {
|
||||
baseline: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.VUS || 10),
|
||||
duration: __ENV.DURATION || '3m',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<800', 'p(99)<1500'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
spike: {
|
||||
scenarios: {
|
||||
spike: {
|
||||
executor: 'ramping-arrival-rate',
|
||||
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
||||
maxVUs: Number(__ENV.MAX_VUS || 200),
|
||||
timeUnit: '1s',
|
||||
stages: [
|
||||
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
|
||||
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
|
||||
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.05'],
|
||||
http_req_duration: ['p(95)<2000'],
|
||||
works_list_shape_error_rate: ['rate<0.05'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const options = scenarioOptions[SCENARIO] || scenarioOptions.smoke;
|
||||
|
||||
const PUBLIC_ENDPOINTS = [
|
||||
{
|
||||
name: 'puzzle_gallery_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/puzzle/gallery',
|
||||
expectCollectionKeys: ['items', 'works', 'entries'],
|
||||
},
|
||||
{
|
||||
name: 'custom_world_gallery_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/custom-world-gallery',
|
||||
expectCollectionKeys: ['entries', 'items', 'works'],
|
||||
},
|
||||
];
|
||||
|
||||
const AUTH_ENDPOINTS = [
|
||||
{
|
||||
name: 'puzzle_works_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/puzzle/works',
|
||||
expectCollectionKeys: ['items', 'works'],
|
||||
},
|
||||
{
|
||||
name: 'custom_world_works_list',
|
||||
method: 'GET',
|
||||
path: '/api/runtime/custom-world/works',
|
||||
expectCollectionKeys: ['items', 'entries', 'works'],
|
||||
},
|
||||
];
|
||||
|
||||
function requestParams(endpointName) {
|
||||
const headers = { 'x-genarrative-response-envelope': 'v1' };
|
||||
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
|
||||
return {
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
tags: { endpoint: endpointName },
|
||||
};
|
||||
}
|
||||
|
||||
function buildUrl(path) {
|
||||
return `${BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
function parseJson(response) {
|
||||
try {
|
||||
return response.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload(json) {
|
||||
if (!json || typeof json !== 'object') return null;
|
||||
if (json.data && typeof json.data === 'object') return json.data;
|
||||
return json;
|
||||
}
|
||||
|
||||
function hasCollection(payload, keys) {
|
||||
return keys.some((key) => Array.isArray(payload?.[key]));
|
||||
}
|
||||
|
||||
function firstCollection(payload, keys) {
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(payload?.[key])) return payload[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasListItemShape(payload, keys) {
|
||||
const collection = firstCollection(payload, keys);
|
||||
if (collection.length === 0) return true;
|
||||
const item = collection[0];
|
||||
const hasId = Boolean(
|
||||
item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
|
||||
);
|
||||
const hasTitle = Boolean(
|
||||
item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName,
|
||||
);
|
||||
return hasId && hasTitle;
|
||||
}
|
||||
|
||||
function randomItem(items) {
|
||||
if (!items.length) return null;
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
function listEndpoints() {
|
||||
return AUTH_TOKEN ? PUBLIC_ENDPOINTS.concat(AUTH_ENDPOINTS) : PUBLIC_ENDPOINTS;
|
||||
}
|
||||
|
||||
function detailEndpointFor(work) {
|
||||
if (!work || !work.profileId) return null;
|
||||
if (work.type === 'puzzle') {
|
||||
return {
|
||||
name: 'puzzle_gallery_detail',
|
||||
path: `/api/runtime/puzzle/gallery/${encodeURIComponent(work.profileId)}`,
|
||||
expectKeys: ['item', 'work', 'entry'],
|
||||
};
|
||||
}
|
||||
if (work.type === 'customWorld' && work.profileId && work.ownerUserId) {
|
||||
return {
|
||||
name: 'custom_world_gallery_detail',
|
||||
path: `/api/runtime/custom-world-gallery/${encodeURIComponent(work.ownerUserId)}/${encodeURIComponent(work.profileId)}`,
|
||||
expectKeys: ['entry', 'item', 'work'],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function performListRequest(endpoint) {
|
||||
const url = buildUrl(endpoint.path);
|
||||
const response = http.request(endpoint.method, url, null, requestParams(endpoint.name));
|
||||
worksListDuration.add(response.timings.duration, { endpoint: endpoint.name });
|
||||
const json = parseJson(response);
|
||||
const payload = unwrapPayload(json);
|
||||
const ok = check(response, {
|
||||
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
|
||||
[`${endpoint.name} returns json object`]: () => Boolean(payload),
|
||||
[`${endpoint.name} has collection`]: () => hasCollection(payload, endpoint.expectCollectionKeys),
|
||||
[`${endpoint.name} list item shape`]: () => hasListItemShape(payload, endpoint.expectCollectionKeys),
|
||||
});
|
||||
worksListShapeErrorRate.add(!ok, { endpoint: endpoint.name });
|
||||
}
|
||||
|
||||
function performDetailRequest() {
|
||||
const endpoint = detailEndpointFor(randomItem(normalizedWorks));
|
||||
if (!endpoint) return;
|
||||
|
||||
const response = http.get(buildUrl(endpoint.path), requestParams(endpoint.name));
|
||||
worksDetailDuration.add(response.timings.duration, { endpoint: endpoint.name });
|
||||
const json = parseJson(response);
|
||||
const payload = unwrapPayload(json);
|
||||
const ok = check(response, {
|
||||
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
|
||||
[`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]),
|
||||
});
|
||||
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
|
||||
}
|
||||
|
||||
export default function () {
|
||||
for (const endpoint of listEndpoints()) {
|
||||
performListRequest(endpoint);
|
||||
}
|
||||
if (normalizedWorks.length && DETAIL_RATIO > 0 && Math.random() < DETAIL_RATIO) {
|
||||
performDetailRequest();
|
||||
}
|
||||
|
||||
const jitter = SLEEP_MIN_SECONDS + Math.random() * Math.max(0, SLEEP_MAX_SECONDS - SLEEP_MIN_SECONDS);
|
||||
sleep(jitter);
|
||||
}
|
||||
Reference in New Issue
Block a user