import http from "k6/http";
import { check } from "k6";
import { Trend } from "k6/metrics";
const BASE = __ENV.BASE_URL || "http://localhost:9898";
const API = `${BASE}/api/v1`;
const EMAIL = __ENV.EMAIL || "admin@test.com";
const PASSWORD = __ENV.PASSWORD || "Admin1234";
const VUS = parseInt(__ENV.VUS || "20", 10);
const DUR_SEC = parseSec(__ENV.DUR || "8s");
const COOLDOWN_SEC = parseInt(__ENV.COOLDOWN || "3", 10);
const PREPARE = parseInt(__ENV.PREPARE || "0", 10);
const ONLY = __ENV.ONLY ? __ENV.ONLY.split(",").map((s) => s.trim()) : null;
const customMetrics = {};
function trend(name) {
const t = new Trend(name, true);
customMetrics[name] = t;
return t;
}
const SCENES_DEF = [
{
id: "healthz",
label: "GET /healthz",
metric: trend("healthz"),
fn: () => http.get(`${BASE}/healthz`),
check: (r) => r.status === 200,
},
{
id: "posts_list",
label: "GET /posts (列表)",
metric: trend("posts_list"),
fn: (d) => http.get(`${API}/posts?page=1&page_size=20`, authHdr(d.jwt)),
check: (r) => r.status === 200,
},
{
id: "cms_list",
label: "GET /cms/{ct} (列表)",
metric: trend("cms_list"),
needs: "cmsPlural",
fn: (d) => http.get(`${API}/cms/${d.cmsPlural}?page=1&page_size=20`, authHdr(d.jwt)),
check: (r) => r.status === 200,
},
{
id: "posts_detail",
label: "GET /posts/{slug}",
metric: trend("posts_detail"),
needs: "postSlug",
fn: (d) => http.get(`${API}/posts/${d.postSlug}`, authHdr(d.jwt)),
check: (r) => r.status === 200,
},
{
id: "cms_detail",
label: "GET /cms/{ct}/{id}",
metric: trend("cms_detail"),
needs: "cmsItemId",
fn: (d) => http.get(`${API}/cms/${d.cmsPlural}/${d.cmsItemId}`, authHdr(d.jwt)),
check: (r) => r.status === 200,
},
{
id: "auth_jwt",
label: "GET /users/me (JWT)",
metric: trend("auth_jwt"),
fn: (d) => http.get(`${API}/users/me`, authHdr(d.jwt)),
check: (r) => r.status === 200,
},
{
id: "auth_token",
label: "GET /users/me (API Token)",
metric: trend("auth_token"),
fn: (d) => http.get(`${API}/users/me`, authHdr(d.apiToken)),
check: (r) => r.status === 200,
},
{
id: "auth_token_posts",
label: "GET /posts (API Token)",
metric: trend("auth_token_posts"),
fn: (d) => http.get(`${API}/posts?page=1&page_size=20`, authHdr(d.apiToken)),
check: (r) => r.status === 200,
},
{
id: "posts_create",
label: "POST /posts (创建)",
metric: trend("posts_create"),
fn: (d, vu, iter) =>
http.post(
`${API}/posts`,
JSON.stringify({
title: `k6 ${vu}-${iter} ${Date.now()}`,
content: `Benchmark. ${"z".repeat(200)}`,
status: "draft",
category_id: null,
tag_ids: [],
}),
authHdr(d.jwt)
),
check: (r) => r.status === 200,
},
{
id: "cms_create",
label: "POST /cms/{ct} (创建)",
metric: trend("cms_create"),
needs: "cmsPlural",
fn: (d, vu, iter) =>
http.post(
`${API}/cms/${d.cmsPlural}`,
JSON.stringify({
title: `k6 ${d.cmsPlural} ${vu}-${iter} ${Date.now()}`,
content: `Benchmark. ${"z".repeat(200)}`,
status: "draft",
}),
authHdr(d.jwt)
),
check: (r) => r.status === 201 || r.status === 200,
},
];
const ACTIVE = SCENES_DEF.filter((s) => {
if (ONLY && !ONLY.includes(s.id)) return false;
return true;
});
const scenarios = {};
let offsetMs = 0;
for (const s of ACTIVE) {
scenarios[s.id] = {
executor: "constant-vus",
vus: VUS,
duration: `${DUR_SEC}s`,
startTime: `${offsetMs}ms`,
exec: "benchLoop",
tags: { scene: s.id },
env: { _SCENE: s.id },
};
offsetMs += (DUR_SEC + COOLDOWN_SEC) * 1000;
}
export const options = {
scenarios,
thresholds: {
http_req_failed: ["rate<0.1"],
checks: ["rate>0.9"],
},
};
export function setup() {
const loginRes = http.post(
`${API}/auth/login`,
JSON.stringify({ email: EMAIL, password: PASSWORD }),
{ headers: { "Content-Type": "application/json" } }
);
check(loginRes, { "login ok": (r) => r.status === 200 });
const jwt = loginRes.json("data.access_token");
const h = authHdr(jwt);
const tokRes = http.post(
`${API}/tokens`,
JSON.stringify({ name: "k6-bench", scopes: ["read", "write"] }),
h
);
const apiToken = tokRes.json("data.token");
check(tokRes, { "api token ok": (r) => r.status === 201 && apiToken });
let cmsPlural = null;
let cmsItemId = null;
const ctRes = http.get(`${API}/admin/content-types`, h);
if (ctRes.status === 200) {
for (const ct of ctRes.json("data") || []) {
if (ct.plural) {
cmsPlural = ct.plural;
break;
}
}
}
let postSlug = null;
const pRes = http.get(`${API}/posts?page=1&page_size=1`, h);
if (pRes.status === 200) {
const items = pRes.json("data.items") || [];
if (items.length > 0) postSlug = items[0].slug || items[0].id;
}
if (cmsPlural) {
const cRes = http.get(`${API}/cms/${cmsPlural}?page=1&page_size=1`, h);
if (cRes.status === 200) {
const items = cRes.json("data.items") || [];
if (items.length > 0) cmsItemId = items[0].id;
}
}
if (PREPARE > 0) {
console.log(`准备数据: ${PREPARE} posts + ${cmsPlural ? PREPARE + " cms" : "0 cms"} ...`);
const batch = {};
for (let i = 0; i < PREPARE; i++) {
batch[`p${i}`] = {
method: "POST",
url: `${API}/posts`,
body: JSON.stringify({
title: `k6-prep-${i}-${Date.now()}`,
content: `Prep #${i}. ${"x".repeat(200)}`,
status: "published",
category_id: null,
tag_ids: [],
}),
headers: h.headers,
};
}
if (cmsPlural) {
for (let i = 0; i < PREPARE; i++) {
batch[`c${i}`] = {
method: "POST",
url: `${API}/cms/${cmsPlural}`,
body: JSON.stringify({
title: `k6-prep-${i}-${Date.now()}`,
content: `Prep #${i}. ${"y".repeat(200)}`,
status: "published",
}),
headers: h.headers,
};
}
}
const br = http.batch(batch);
let ok = 0;
for (const k in br) if (br[k].status < 300) ok++;
console.log(` 插入: ${ok}/${Object.keys(br).length} 成功`);
}
console.log(`Setup ✓ jwt=${jwt.slice(0, 15)}... apiToken=${apiToken.slice(0, 12)}... cms=${cmsPlural || "none"} postSlug=${postSlug || "none"}`);
return { jwt, apiToken, cmsPlural, cmsItemId, postSlug };
}
export function benchLoop(data) {
const sceneId = __ENV._SCENE;
const scene = ACTIVE.find((s) => s.id === sceneId);
if (!scene) return;
if (scene.needs && !data[scene.needs]) return;
const vu = __VU;
const iter = __ITER;
const r = scene.fn(data, vu, iter);
check(r, { [scene.label]: scene.check });
scene.metric.add(r.timings.duration);
}
export function handleSummary(data) {
const totalReqDur = data.metrics?.http_req_duration?.values;
let report = "\n";
report += color("1;36") + "=".repeat(72) + color("0") + "\n";
report += color("1;36") + " raisfast Benchmark Report" + color("0") + "\n";
report += color("1;36") + "=".repeat(72) + color("0") + "\n";
report += ` VUs: ${VUS} Duration: ${DUR_SEC}s Cooldown: ${COOLDOWN_SEC}s Base: ${BASE}\n\n`;
report += color("1;37") + ` ${"场景".padEnd(38)} ${"Avg".padStart(8)} ${"P50".padStart(8)} ${"P90".padStart(8)} ${"P95".padStart(8)} ${"P99".padStart(8)} ${"Min".padStart(8)} ${"Max".padStart(8)}` + color("0") + "\n";
report += ` ${"─".repeat(98)}` + "\n";
const summaries = [];
for (const s of ACTIVE) {
const m = data.metrics[s.id];
if (!m || !m.values || !m.values.avg) {
report += ` ${s.label.padEnd(38)} (skipped)\n`;
continue;
}
const v = m.values;
const info = {
id: s.id,
label: s.label,
avg: v.avg,
p50: v.med,
p90: v["p(90)"],
p95: v["p(95)"],
min: v.min,
max: v.max,
};
summaries.push(info);
report += ` ${s.label.padEnd(38)} ${"".padStart(7)} ${ms(v.avg).padStart(8)} ${ms(v.med).padStart(8)} ${ms(v["p(90)"]).padStart(8)} ${ms(v["p(95)"]).padStart(8)} ${ms(v["p(99)"] || 0).padStart(8)} ${ms(v.min).padStart(8)} ${ms(v.max).padStart(8)}\n`;
}
report += ` ${"─".repeat(98)}` + "\n\n";
const cmpPairs = [
["auth_jwt", "auth_token", "JWT vs Token (/users/me)"],
["posts_list", "auth_token_posts", "JWT vs Token (/posts)"],
["posts_list", "cms_list", "原生 vs CMS (列表)"],
["posts_detail", "cms_detail", "原生 vs CMS (详情)"],
["posts_create", "cms_create", "原生 vs CMS (创建)"],
];
const byId = {};
for (const s of summaries) byId[s.id] = s;
const hasPairs = cmpPairs.some(([a, b]) => byId[a] && byId[b]);
if (hasPairs) {
report += color("1;36") + "=".repeat(72) + color("0") + "\n";
report += color("1;36") + " 对比汇总" + color("0") + "\n";
report += color("1;36") + "=".repeat(72) + color("0") + "\n";
report += ` ${"对比项".padEnd(34)} ${"A Avg".padStart(9)} ${"B Avg".padStart(9)} ${"比值".padStart(7)} ${"A P50".padStart(9)} ${"B P50".padStart(9)} ${"比值".padStart(7)}\n`;
report += ` ${"─".repeat(88)}` + "\n";
for (const [aId, bId, label] of cmpPairs) {
const a = byId[aId];
const b = byId[bId];
if (!a || !b) continue;
const ratioAvg = a.avg > 0 ? b.avg / a.avg : 0;
const ratioP50 = a.p50 > 0 ? b.p50 / a.p50 : 0;
report += ` ${label.padEnd(34)} ${ms(a.avg).padStart(9)} ${ms(b.avg).padStart(9)} ${ratioAvg.toFixed(2).padStart(6)}x ${ms(a.p50).padStart(9)} ${ms(b.p50).padStart(9)} ${ratioP50.toFixed(2).padStart(6)}x\n`;
}
report += ` ${"─".repeat(88)}` + "\n\n";
}
if (totalReqDur) {
report += ` 全局: ${totalReqDur.count || 0} reqs avg=${ms(totalReqDur.avg || 0)} p50=${ms(totalReqDur.med || 0)} p(90)=${ms(totalReqDur["p(90)"] || 0)} p(95)=${ms(totalReqDur["p(95)"] || 0)}\n`;
}
const failRate = data.metrics?.http_req_failed?.values?.rate || 0;
report += ` 失败率: ${(failRate * 100).toFixed(2)}%\n\n`;
console.log(report);
return { stdout: report };
}
function authHdr(token) {
return { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } };
}
function ms(v) {
return `${(v).toFixed(2)}`;
}
function parseSec(s) {
const m = s.match(/^(\d+)(s|m)?$/);
if (!m) return 8;
const n = parseInt(m[1], 10);
return m[2] === "m" ? n * 60 : n;
}
function color(code) {
return __ENV.NO_COLOR ? "" : `\x1b[${code}m`;
}