/* 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); }