071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
279 lines
7.6 KiB
JavaScript
279 lines
7.6 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',
|
|
startRate: Number(__ENV.START_RPS || 5),
|
|
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
|
maxVUs: Number(__ENV.MAX_VUS || 200),
|
|
timeUnit: '1s',
|
|
stages: [
|
|
{
|
|
target: Number(__ENV.PEAK_RPS || 25),
|
|
duration: __ENV.RAMP_UP || '30s',
|
|
},
|
|
{
|
|
target: Number(__ENV.PEAK_RPS || 25),
|
|
duration: __ENV.HOLD || '2m',
|
|
},
|
|
{
|
|
target: Number(__ENV.END_RPS || 5),
|
|
duration: __ENV.RAMP_DOWN || '30s',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
thresholds: {
|
|
http_req_failed: ['rate<0.01'],
|
|
http_req_duration: ['p(95)<2000'],
|
|
dropped_iterations: ['count==0'],
|
|
works_list_shape_error_rate: ['rate<0.01'],
|
|
},
|
|
},
|
|
};
|
|
|
|
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 Boolean(payload) && keys.some((key) => Array.isArray(payload[key]));
|
|
}
|
|
|
|
function firstCollection(payload, keys) {
|
|
for (const key of keys) {
|
|
if (payload && 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 &&
|
|
(item.profileId ||
|
|
item.profile_id ||
|
|
item.workId ||
|
|
item.work_id ||
|
|
item.publicWorkCode),
|
|
);
|
|
const hasTitle = Boolean(
|
|
item &&
|
|
(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`]: () =>
|
|
Boolean(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);
|
|
}
|