230 lines
7.2 KiB
JavaScript
230 lines
7.2 KiB
JavaScript
/* global __ENV */
|
|
import { check, sleep } from 'k6';
|
|
import { SharedArray } from 'k6/data';
|
|
import http from 'k6/http';
|
|
import { Rate, Trend } from 'k6/metrics';
|
|
|
|
// k6 resolves open() paths relative to this script file, not the shell cwd.
|
|
const DEFAULT_WORKS_DATA = '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);
|
|
}
|