test: add k6 works list load test

This commit is contained in:
2026-05-11 21:31:24 +08:00
parent 54968701f0
commit b994acf635
8 changed files with 1567 additions and 0 deletions

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