/** * AI Software Factory — k6: API load (1,000 rps mixed profile). * * Profile (PRODUCT-SPEC §Performance, API p95 < 300 ms): * 40% GET /api/v1/pages/:id (page load, hot cache path) * 30% GET /api/v1/pages/:id/blocks (editor block tree) * 20% POST /api/v1/pages/:id/blocks:batch (editor structural op) * 10% GET /api/v1/search?q=… (search round-trip) * * Ramp: 0 → 100 rps (30 s) → 500 rps (30 s) → 1,000 rps, hold 60 s. * * Thresholds are the pass criteria (fail the run when violated): * http_req_duration p(95) < 300 ms * http_req_failed rate < 0.1% * * Auth: the setup() logs in as the demo user (registering first if needed) * and creates a dedicated workspace + page so the load does not touch * seeded data. Credentials come from K6_EMAIL / K6_PASSWORD (defaults match * prisma/seed.ts). */ import http from "k6/http"; import { check, sleep } from "k6"; import { Trend, Rate } from "k6/metrics"; export const options = { scenarios: { api_load: { executor: "ramping-arrival-rate", startRate: 10, timeUnit: "1s", preAllocatedVUs: 50, maxVUs: 1_000, stages: [ { target: 100, duration: "30s" }, { target: 500, duration: "30s" }, { target: 1000, duration: "120s" }, ], }, }, thresholds: { http_req_duration: ["p(95)<300", "p(99)<600"], http_req_failed: ["rate<0.001"], auth_setup_failures: ["count<1"], search_ms: ["p(95)<500"], }, }; // Custom metric: search latency called out separately (search < 500 ms). const searchMs = new Trend("search_ms", true); const setupFailures = new Rate("auth_setup_failures"); const API_URL = __ENV.K6_API_URL || "http://localhost:4000"; const EMAIL = __ENV.K6_EMAIL || "demo@factory.local"; const PASSWORD = __ENV.K6_PASSWORD || "factory-demo-123"; export function setup() { const unique = `k6-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; // Ensure the demo user exists (dev stacks are seeded; staging may not be). let login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); if (login.status !== 200) { const reg = http.post(`${API_URL}/api/v1/auth/register`, JSON.stringify({ email: EMAIL, password: PASSWORD, name: "K6 Load" }), { headers: { "Content-Type": "application/json" }, }); if (reg.status !== 201 && reg.status !== 200) { setupFailures.add(1); throw new Error(`setup: cannot authenticate (login ${login.status}, register ${reg.status})`); } login = http.post(`${API_URL}/api/v1/auth/login`, JSON.stringify({ email: EMAIL, password: PASSWORD }), { headers: { "Content-Type": "application/json" }, }); } if (login.status !== 200) { setupFailures.add(1); throw new Error(`setup: login failed (${login.status})`); } const body = login.json(); const token = body.accessToken || body.access_token || body.token; if (!token) { setupFailures.add(1); throw new Error("setup: no access token in login response"); } const user = (body.user && body.user.id) || "unknown-user"; // Dedicated workspace + page for the load. const wsRes = http.post( `${API_URL}/api/v1/workspaces`, JSON.stringify({ name: `K6 WS ${unique}` }), { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }, ); if (wsRes.status !== 201) throw new Error(`setup: workspace create failed (${wsRes.status})`); const workspaceId = wsRes.json().workspace.id; const pageRes = http.post( `${API_URL}/api/v1/workspaces/${workspaceId}/pages`, JSON.stringify({ title: `K6 Load Page ${unique}` }), { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }, ); if (pageRes.status !== 201) throw new Error(`setup: page create failed (${pageRes.status})`); const pageId = pageRes.json().page.id; // Pre-populate a block tree so GET /blocks is a realistic read. http.post( `${API_URL}/api/v1/pages/${pageId}/blocks:batch`, JSON.stringify({ operations: Array.from({ length: 25 }, (_, i) => ({ op: "insert", type: "PARAGRAPH", content: { text: `k6 load block ${i}` }, afterId: i === 0 ? null : "PREV", })), }), { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }, ); return { token, userId: user, workspaceId, pageId }; } export function teardown(data) { // Best-effort cleanup (do not let cleanup failures fail the run). http.delete(`${API_URL}/api/v1/workspaces/${data.workspaceId}`, { headers: { Authorization: `Bearer ${data.token}` }, }); } const HEADERS = (data) => ({ "Content-Type": "application/json", Authorization: `Bearer ${data.token}`, }); export default function (data) { const roll = Math.random(); if (roll < 0.4) { // 40% — page load. const res = http.get(`${API_URL}/api/v1/pages/${data.pageId}`, { headers: HEADERS(data) }); check(res, { "page: 200": (r) => r.status === 200 }); } else if (roll < 0.7) { // 30% — block tree. const res = http.get(`${API_URL}/api/v1/pages/${data.pageId}/blocks`, { headers: HEADERS(data) }); check(res, { "blocks: 200": (r) => r.status === 200 }); } else if (roll < 0.9) { // 20% — structural op (batch insert one paragraph; the API assigns a // fractional position — positions are server-side, so the batch is // stateless w.r.t. client bookkeeping). const res = http.post( `${API_URL}/api/v1/pages/${data.pageId}/blocks:batch`, JSON.stringify({ operations: [ { op: "insert", type: "PARAGRAPH", content: { text: `k6 write ${Date.now()}-${Math.random().toString(36).slice(2)}` }, afterId: null, }, ], }), { headers: HEADERS(data) }, ); check(res, { "batch: 2xx": (r) => r.status === 200 || r.status === 201 }); } else { // 10% — search (its own budget: < 500 ms). const started = Date.now(); const res = http.get( `${API_URL}/api/v1/search?q=k6%20load%20block&limit=10`, { headers: HEADERS(data) }, ); searchMs.add(Date.now() - started); check(res, { "search: 200": (r) => r.status === 200 }); } // A small think-time keeps the arrival-rate executor honest (ramping- // arrival-rate already paces requests; sleep only between iterations). sleep(0.1); }