import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
{{#each dynamic_imports}}
{{{this}}}
{{/each}}
{{#each dynamic_globals}}
{{{this}}}
{{/each}}
// Custom metrics per operation
{{#each operations}}
const {{this.name}}_latency = new Trend('{{this.metric_name}}_latency');
const {{this.name}}_errors = new Rate('{{this.metric_name}}_errors');
{{/each}}
// Server-injected chaos signals (parsed from MockForge response headers).
// `mockforge_server_injected_latency_ms` and `mockforge_server_injected_jitter_ms`
// are Trends that capture `X-MockForge-Injected-Latency-Ms` / `X-MockForge-
// Injected-Jitter-Ms`, and `mockforge_server_fault_total` counts responses that
// carried an `X-MockForge-Fault` marker (e.g. `partial_response`).
// Issue #79 — Srikanth's round-3 reply asked to see server-side latency on
// the client side.
const server_injected_latency_ms = new Trend('mockforge_server_injected_latency_ms');
const server_injected_jitter_ms = new Trend('mockforge_server_injected_jitter_ms');
const server_fault_total = new Counter('mockforge_server_fault_total');
// Test configuration
export const options = {
{{#if skip_tls_verify}}
insecureSkipTLSVerify: true,
{{/if}}
{{#if no_keep_alive}}
// --cps was passed: force a fresh TCP/TLS connection per request so users
// can drive a high connections-per-second rate. Issue #79.
noConnectionReuse: true,
{{/if}}
scenarios: {
{{scenario_name}}: {
{{#if target_rps}}
// --rps was passed: open-model load at a fixed request rate. VUs are
// pre-allocated up to `--vus`; k6 fires {{target_rps}} requests/sec
// regardless of how long each one takes.
//
// Issue #79 (round 5): we deliberately do NOT derive duration / VU
// pool from the chosen scenario's last stage here. Under the default
// `ramp-up` scenario the last stage is the ramp-DOWN to `target: 0`,
// which previously gave `preAllocatedVUs: 0` → k6 ran with no VUs and
// produced 0 requests. With `--rps`, the executor is open-model, so
// pre-allocate the full `--vus` pool for the full `--duration`.
executor: 'constant-arrival-rate',
rate: {{target_rps}},
timeUnit: '1s',
duration: '{{duration_secs}}s',
preAllocatedVUs: {{max_vus}},
maxVUs: {{max_vus}},
{{else}}
executor: 'ramping-vus',
startVUs: 0,
stages: [
{{#each stages}}
{ duration: '{{this.duration}}', target: {{this.target}} },
{{/each}}
],
gracefulRampDown: '10s',
{{/if}}
},
},
thresholds: {
'http_req_duration': ['{{threshold_percentile}}<{{threshold_ms}}'],
'http_req_failed': ['rate<{{max_error_rate}}'],
},
};
// Inspect MockForge response headers and feed the chaos-signal trends/counter.
// Called after every http.* invocation in the body so server-injected latency
// and faults surface in summary.json next to the standard k6 metrics.
function recordMockForgeChaosHeaders(res) {
if (!res || !res.headers) return;
const lat = res.headers['X-Mockforge-Injected-Latency-Ms'] || res.headers['x-mockforge-injected-latency-ms'];
const jit = res.headers['X-Mockforge-Injected-Jitter-Ms'] || res.headers['x-mockforge-injected-jitter-ms'];
const fault = res.headers['X-Mockforge-Fault'] || res.headers['x-mockforge-fault'];
if (lat != null) {
const n = parseFloat(lat);
if (!isNaN(n) && n > 0) server_injected_latency_ms.add(n);
}
if (jit != null) {
const n = parseFloat(jit);
if (!isNaN(n) && n > 0) server_injected_jitter_ms.add(n);
}
if (fault != null && fault !== '') {
server_fault_total.add(1, { fault: fault });
}
}
const BASE_URL = '{{base_url}}';
export default function () {
{{#each operations}}
// Operation {{@index}}: {{this.display_name}}
{
{{#if @root.security_testing_enabled}}
// Get a grouped security payload for each operation (array of related payloads)
const secPayloadGroup = typeof getNextSecurityPayload === 'function' ? getNextSecurityPayload() : [];
// Copy headers and build request URL, applying all payloads from the group
const requestHeaders = { ...{{{this.headers}}} };
{{#if this.path_is_dynamic}}
const __path = {{{this.path}}};
let requestUrl = `${BASE_URL}${__path}`;
{{else}}
let requestUrl = `${BASE_URL}{{{this.path}}}`;
{{/if}}
let secBodyPayload = null;
let hasSecCookie = false;
for (const secPayload of secPayloadGroup) {
if (secPayload.location === 'header' && secPayload.headerName) {
requestHeaders[secPayload.headerName] = secPayload.payload;
if (secPayload.headerName === 'Cookie') hasSecCookie = true;
}
if (secPayload.location === 'uri') {
if (secPayload.injectAsPath) {
// Replace URL path so WAF inspects via REQUEST_FILENAME (e.g., CRS 942101)
requestUrl = `${BASE_URL}/${encodeURI(secPayload.payload)}`;
} else {
requestUrl += (requestUrl.includes('?') ? '&' : '?') + 'test=' + encodeURIComponent(secPayload.payload);
}
}
if (secPayload.location === 'body') {
secBodyPayload = secPayload;
}
}
// Skip CookieJar when security payload sets Cookie header (jar overrides manual Cookie headers)
const secRequestOpts = hasSecCookie ? { headers: requestHeaders } : { headers: requestHeaders, jar: new http.CookieJar() };
{{else}}
const requestHeaders = {{{this.headers}}};
{{/if}}
{{#if this.has_body}}
{{#if this.body_is_dynamic}}
// Dynamic body with runtime placeholders
let payload = {{{this.body}}};
{{else}}
let payload = {{{this.body}}};
{{/if}}
{{#if @root.security_testing_enabled}}
// Apply security payload to body if available
let requestBody;
if (secBodyPayload && secBodyPayload.formBody) {
// Parse form body into object for k6's native form encoding (CRS 942432)
// k6 auto-encodes objects as application/x-www-form-urlencoded
const formData = {};
secBodyPayload.formBody.split('&').forEach(function(pair) {
const eq = pair.indexOf('=');
if (eq >= 0) {
formData[decodeURIComponent(pair.substring(0, eq).replace(/\+/g, ' '))] =
decodeURIComponent(pair.substring(eq + 1).replace(/\+/g, ' '));
}
});
requestBody = formData;
} else if (secBodyPayload && typeof applySecurityPayload === 'function') {
payload = applySecurityPayload(payload, [], secBodyPayload);
requestBody = JSON.stringify(payload);
} else {
requestBody = JSON.stringify(payload);
}
const res = http.{{this.method}}(requestUrl, requestBody, secRequestOpts);
{{else}}
{{#if this.path_is_dynamic}}
const url = {{{this.path}}};
const res = http.{{this.method}}(`${BASE_URL}${url}`, JSON.stringify(payload), { headers: requestHeaders, jar: new http.CookieJar() });
{{else}}
const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, JSON.stringify(payload), { headers: requestHeaders, jar: new http.CookieJar() });
{{/if}}
{{/if}}
{{else if this.is_get_or_head}}
// GET and HEAD only take 2 args: http.get(url, params)
{{#if @root.security_testing_enabled}}
const res = http.{{this.method}}(requestUrl, secRequestOpts);
{{else}}
{{#if this.path_is_dynamic}}
const url = {{{this.path}}};
const res = http.{{this.method}}(`${BASE_URL}${url}`, { headers: requestHeaders, jar: new http.CookieJar() });
{{else}}
const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, { headers: requestHeaders, jar: new http.CookieJar() });
{{/if}}
{{/if}}
{{else}}
// POST, PUT, PATCH, DELETE take 3 args: http.post(url, body, params)
{{#if @root.security_testing_enabled}}
// Send form-encoded body if available, otherwise null
let requestBody = null;
if (secBodyPayload && secBodyPayload.formBody) {
requestBody = secBodyPayload.formBody;
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
}
const res = http.{{this.method}}(requestUrl, requestBody, secRequestOpts);
{{else}}
{{#if this.path_is_dynamic}}
const url = {{{this.path}}};
const res = http.{{this.method}}(`${BASE_URL}${url}`, null, { headers: requestHeaders, jar: new http.CookieJar() });
{{else}}
const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, null, { headers: requestHeaders, jar: new http.CookieJar() });
{{/if}}
{{/if}}
{{/if}}
// Pick up MockForge-injected chaos signals before counting check success
// so the trends/counter populate even on injected error paths.
recordMockForgeChaosHeaders(res);
const success = check(res, {
'{{this.display_name}}: status is OK': (r) => r.status >= 200 && r.status < 300,
'{{this.display_name}}: has response': (r) => r.body !== null && r.body.length > 0,
});
{{this.name}}_latency.add(res.timings.duration);
{{this.name}}_errors.add(!success);
{{#unless @root.target_rps}}
// `constant-arrival-rate` (set via --rps) drives the rate itself, so any
// per-iteration sleep is dead weight that lowers the achievable RPS. Keep
// the 1-second pacing only for the legacy ramping-vus executor.
sleep(1);
{{/unless}}
}
{{/each}}
}
export function handleSummary(data) {
return {
'stdout': textSummary(data, { indent: ' ', enableColors: true }),
'summary.json': JSON.stringify(data),
};
}
function textSummary(data, options) {
const indent = options.indent || '';
const enableColors = options.enableColors || false;
const metrics = data.metrics;
let output = '\n';
output += indent + '='.repeat(60) + '\n';
output += indent + 'Load Test Summary\n';
output += indent + '='.repeat(60) + '\n\n';
// Request metrics
if (metrics.http_reqs && metrics.http_reqs.values) {
const count = metrics.http_reqs.values.count || 0;
const rate = metrics.http_reqs.values.rate;
output += indent + `Total Requests: ${count}\n`;
output += indent + `Request Rate: ${rate != null ? rate.toFixed(2) : '0.00'} req/s\n`;
{{#if no_keep_alive}}
// --cps was passed → noConnectionReuse is on, so every request opens a
// fresh TCP/TLS connection. Connections-per-second therefore equals the
// request rate; emit it explicitly so users running CPS-stress benches
// see the connection rate they were targeting. Issue #79 (round 5):
// Srikanth reported "CPS without RPS Command is Working but Client dont
// report CPS Counts".
output += indent + `Total Connections: ${count}\n`;
output += indent + `Connection Rate: ${rate != null ? rate.toFixed(2) : '0.00'} conn/s (--cps)\n`;
if (metrics.http_req_connecting && metrics.http_req_connecting.values) {
const tc = metrics.http_req_connecting.values;
output += indent + ` TCP connect avg: ${tc.avg != null ? tc.avg.toFixed(2) : 'N/A'}ms (max ${tc.max != null ? tc.max.toFixed(2) : 'N/A'}ms)\n`;
}
if (metrics.http_req_tls_handshaking && metrics.http_req_tls_handshaking.values) {
const th = metrics.http_req_tls_handshaking.values;
if ((th.count || 0) > 0) {
output += indent + ` TLS handshake avg: ${th.avg != null ? th.avg.toFixed(2) : 'N/A'}ms (max ${th.max != null ? th.max.toFixed(2) : 'N/A'}ms)\n`;
}
}
{{/if}}
output += '\n';
} else {
output += indent + 'Total Requests: 0\n';
output += indent + 'Request Rate: 0.00 req/s\n\n';
}
// Duration metrics
if (metrics.http_req_duration && metrics.http_req_duration.values) {
const v = metrics.http_req_duration.values;
output += indent + 'Response Times:\n';
output += indent + ` Min: ${v.min != null ? v.min.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Avg: ${v.avg != null ? v.avg.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Med: ${v.med != null ? v.med.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` p90: ${v['p(90)'] != null ? v['p(90)'].toFixed(2) : 'N/A'}ms\n`;
output += indent + ` p95: ${v['p(95)'] != null ? v['p(95)'].toFixed(2) : 'N/A'}ms\n`;
output += indent + ` p99: ${v['p(99)'] != null ? v['p(99)'].toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Max: ${v.max != null ? v.max.toFixed(2) : 'N/A'}ms\n\n`;
} else {
output += indent + 'Response Times: No successful requests\n\n';
}
// Error rate
if (metrics.http_req_failed && metrics.http_req_failed.values) {
const rate = metrics.http_req_failed.values.rate;
const errorRate = rate != null ? (rate * 100).toFixed(2) : '100.00';
output += indent + `Error Rate: ${errorRate}%\n\n`;
} else {
output += indent + 'Error Rate: N/A\n\n';
}
// MockForge server-injected chaos signals (Issue #79 — surface server-side
// injected latency next to client-observed latency so users can see how
// much of the wire time is artificial).
const mfLat = metrics.mockforge_server_injected_latency_ms;
if (mfLat && mfLat.values && mfLat.values.count > 0) {
output += indent + 'Server-Injected Latency (from X-Mockforge-Injected-Latency-Ms):\n';
output += indent + ` Samples: ${mfLat.values.count}\n`;
output += indent + ` Avg: ${mfLat.values.avg != null ? mfLat.values.avg.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Min: ${mfLat.values.min != null ? mfLat.values.min.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Max: ${mfLat.values.max != null ? mfLat.values.max.toFixed(2) : 'N/A'}ms\n`;
if (mfLat.values['p(95)'] != null) {
output += indent + ` p95: ${mfLat.values['p(95)'].toFixed(2)}ms\n`;
}
output += '\n';
}
const mfJit = metrics.mockforge_server_injected_jitter_ms;
if (mfJit && mfJit.values && mfJit.values.count > 0) {
output += indent + 'Server-Injected Jitter:\n';
output += indent + ` Samples: ${mfJit.values.count}\n`;
output += indent + ` Avg: ${mfJit.values.avg != null ? mfJit.values.avg.toFixed(2) : 'N/A'}ms\n`;
output += indent + ` Max: ${mfJit.values.max != null ? mfJit.values.max.toFixed(2) : 'N/A'}ms\n\n`;
}
const mfFault = metrics.mockforge_server_fault_total;
if (mfFault && mfFault.values && mfFault.values.count > 0) {
output += indent + `Server-Reported Faults (X-Mockforge-Fault): ${mfFault.values.count}\n\n`;
}
output += indent + '='.repeat(60) + '\n';
return output;
}