mockforge-bench 0.3.134

Load and performance testing for MockForge
Documentation
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;
}