309 lines
14 KiB
JavaScript
309 lines
14 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
// ==========================================================================
|
|||
|
|
// Jira 24-Hour Load Simulation
|
|||
|
|
// ==========================================================================
|
|||
|
|
// Simulates a full day of STEAM Dashboard Jira API usage at the HIGH end
|
|||
|
|
// of estimated daily volume. Runs every call type at production frequency
|
|||
|
|
// against UAT so the ATLSUP reviewer can see real traffic patterns.
|
|||
|
|
//
|
|||
|
|
// This is NOT a stress test — it respects all Charter rate limits and
|
|||
|
|
// inter-request delays. It exercises the exact same code paths production
|
|||
|
|
// will use, at the volume documented in docs/jira-api-use-cases.md.
|
|||
|
|
//
|
|||
|
|
// Usage:
|
|||
|
|
// cd backend
|
|||
|
|
// node scripts/jira-load-test.js
|
|||
|
|
//
|
|||
|
|
// Estimated runtime: ~3–5 minutes (limited by 1s/2s inter-request delays)
|
|||
|
|
// Estimated API calls: ~120 (high end of daily estimate)
|
|||
|
|
// ==========================================================================
|
|||
|
|
|
|||
|
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const jiraApi = require('../helpers/jiraApi');
|
|||
|
|
|
|||
|
|
const LOG_FILE = path.join(__dirname, 'jira-load-test-2.log');
|
|||
|
|
const results = [];
|
|||
|
|
let testIssueKeys = [];
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Logging
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function log(level, message, data) {
|
|||
|
|
const timestamp = new Date().toISOString();
|
|||
|
|
const entry = { timestamp, level, message };
|
|||
|
|
if (data !== undefined) entry.data = data;
|
|||
|
|
results.push(entry);
|
|||
|
|
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
|
|||
|
|
console.log(line);
|
|||
|
|
if (data) {
|
|||
|
|
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|||
|
|
const truncated = dataStr.length > 1000 ? dataStr.substring(0, 1000) + ' ...[truncated]' : dataStr;
|
|||
|
|
console.log(' ' + truncated.split('\n').join('\n '));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function logInfo(msg, data) { log('info', msg, data); }
|
|||
|
|
function logPass(msg, data) { log('pass', msg, data); }
|
|||
|
|
function logFail(msg, data) { log('fail', msg, data); }
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Call counter
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
const callCounts = {
|
|||
|
|
'GET /myself': 0,
|
|||
|
|
'POST /issue': 0,
|
|||
|
|
'GET /search (single)': 0,
|
|||
|
|
'GET /search (bulk sync)': 0,
|
|||
|
|
'GET /search (JQL)': 0,
|
|||
|
|
'PUT /issue': 0,
|
|||
|
|
'POST /comment': 0,
|
|||
|
|
'GET /transitions': 0,
|
|||
|
|
'POST /transitions': 0,
|
|||
|
|
};
|
|||
|
|
let totalCalls = 0;
|
|||
|
|
|
|||
|
|
function count(op) { callCounts[op] = (callCounts[op] || 0) + 1; totalCalls++; }
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Helpers
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
async function safeCall(opName, fn) {
|
|||
|
|
try {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const result = await fn();
|
|||
|
|
const ms = Date.now() - start;
|
|||
|
|
if (result && result.ok === false) {
|
|||
|
|
logFail(`${opName} — HTTP ${result.status} (${ms}ms)`, (result.body || '').substring(0, 300));
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
logPass(`${opName} — OK (${ms}ms)`);
|
|||
|
|
return result;
|
|||
|
|
} catch (err) {
|
|||
|
|
logFail(`${opName} — ERROR: ${err.message}`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Load simulation
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
async function main() {
|
|||
|
|
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
|||
|
|
|
|||
|
|
logInfo('=== STEAM Dashboard — 24-Hour Load Simulation ===');
|
|||
|
|
logInfo('Timestamp: ' + new Date().toISOString());
|
|||
|
|
logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)'));
|
|||
|
|
logInfo('JIRA_PROJECT_KEY: ' + projectKey);
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('This simulates the HIGH end of estimated daily API usage:');
|
|||
|
|
logInfo(' Connection tests: 5');
|
|||
|
|
logInfo(' Create issue: 20');
|
|||
|
|
logInfo(' Get single issue: 30 (via JQL search)');
|
|||
|
|
logInfo(' Update issue: 10');
|
|||
|
|
logInfo(' Add comment: 15');
|
|||
|
|
logInfo(' Get transitions: 10');
|
|||
|
|
logInfo(' Transition issue: 10');
|
|||
|
|
logInfo(' JQL search (sync): 5');
|
|||
|
|
logInfo(' Bulk key search: 5');
|
|||
|
|
logInfo(' Issue lookup: 15');
|
|||
|
|
logInfo(' ─────────────────────');
|
|||
|
|
logInfo(' Total estimated: ~125 calls');
|
|||
|
|
logInfo('');
|
|||
|
|
|
|||
|
|
if (!jiraApi.isConfigured) {
|
|||
|
|
logFail('Jira API not configured');
|
|||
|
|
writeLog();
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 1: Connection tests (5x) ──────────────────────────
|
|||
|
|
logInfo('── Phase 1: Connection Tests (5x) ──');
|
|||
|
|
for (let i = 0; i < 5; i++) {
|
|||
|
|
count('GET /myself');
|
|||
|
|
await safeCall(`Connection test ${i + 1}/5`, () => jiraApi.testConnection());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 2: Create issues (20x) ────────────────────────────
|
|||
|
|
logInfo('── Phase 2: Create Issues (20x) ──');
|
|||
|
|
for (let i = 0; i < 20; i++) {
|
|||
|
|
count('POST /issue');
|
|||
|
|
const result = await safeCall(`Create issue ${i + 1}/20`, () =>
|
|||
|
|
jiraApi.createIssue({
|
|||
|
|
project: { key: projectKey },
|
|||
|
|
summary: `[LOAD TEST] STEAM Dashboard - batch ${i + 1} - ${new Date().toISOString()}`,
|
|||
|
|
issuetype: { name: jiraApi.JIRA_ISSUE_TYPE || 'Story' },
|
|||
|
|
description: `Load test issue ${i + 1} of 20. Created by the STEAM Dashboard 24-hour load simulation script. Safe to delete after ATLSUP review.`,
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
if (result && result.data && result.data.key) {
|
|||
|
|
testIssueKeys.push(result.data.key);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
logInfo(`Created ${testIssueKeys.length} test issues: ${testIssueKeys.join(', ')}`);
|
|||
|
|
|
|||
|
|
if (testIssueKeys.length === 0) {
|
|||
|
|
logFail('No issues created — cannot continue load test');
|
|||
|
|
printSummary();
|
|||
|
|
writeLog();
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 3: Single-issue lookups via JQL (30x) ─────────────
|
|||
|
|
logInfo('── Phase 3: Single-Issue Lookups via JQL (30x) ──');
|
|||
|
|
for (let i = 0; i < 30; i++) {
|
|||
|
|
const key = testIssueKeys[i % testIssueKeys.length];
|
|||
|
|
count('GET /search (single)');
|
|||
|
|
await safeCall(`Get issue ${i + 1}/30 (${key})`, () => jiraApi.getIssue(key));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 4: Update issues (10x) ────────────────────────────
|
|||
|
|
logInfo('── Phase 4: Update Issues (10x) ──');
|
|||
|
|
for (let i = 0; i < 10; i++) {
|
|||
|
|
const key = testIssueKeys[i % testIssueKeys.length];
|
|||
|
|
count('PUT /issue');
|
|||
|
|
await safeCall(`Update issue ${i + 1}/10 (${key})`, () =>
|
|||
|
|
jiraApi.updateIssue(key, {
|
|||
|
|
summary: `[LOAD TEST] Updated ${i + 1} - ${new Date().toISOString()}`
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 5: Add comments (15x) ─────────────────────────────
|
|||
|
|
logInfo('── Phase 5: Add Comments (15x) ──');
|
|||
|
|
for (let i = 0; i < 15; i++) {
|
|||
|
|
const key = testIssueKeys[i % testIssueKeys.length];
|
|||
|
|
count('POST /comment');
|
|||
|
|
await safeCall(`Add comment ${i + 1}/15 (${key})`, () =>
|
|||
|
|
jiraApi.addComment(key, `Load test comment ${i + 1} at ${new Date().toISOString()}`)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 6: Get transitions (10x) ──────────────────────────
|
|||
|
|
logInfo('── Phase 6: Get Transitions (10x) ──');
|
|||
|
|
let availableTransitions = [];
|
|||
|
|
for (let i = 0; i < 10; i++) {
|
|||
|
|
const key = testIssueKeys[i % testIssueKeys.length];
|
|||
|
|
count('GET /transitions');
|
|||
|
|
const result = await safeCall(`Get transitions ${i + 1}/10 (${key})`, () =>
|
|||
|
|
jiraApi.getTransitions(key)
|
|||
|
|
);
|
|||
|
|
if (result && result.data && result.data.transitions && result.data.transitions.length > 0 && availableTransitions.length === 0) {
|
|||
|
|
availableTransitions = result.data.transitions;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 7: Transition issues (10x) ────────────────────────
|
|||
|
|
logInfo('── Phase 7: Transition Issues (10x) ──');
|
|||
|
|
if (availableTransitions.length > 0) {
|
|||
|
|
const transitionId = availableTransitions[0].id;
|
|||
|
|
logInfo(`Using transition: ${availableTransitions[0].name} (id: ${transitionId})`);
|
|||
|
|
for (let i = 0; i < Math.min(10, testIssueKeys.length); i++) {
|
|||
|
|
const key = testIssueKeys[i];
|
|||
|
|
count('POST /transitions');
|
|||
|
|
await safeCall(`Transition ${i + 1}/10 (${key})`, () =>
|
|||
|
|
jiraApi.transitionIssue(key, transitionId)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
logInfo('No transitions available — skipping (workflow may not allow transitions from current state)');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 8: JQL search / bulk sync (5x) ────────────────────
|
|||
|
|
logInfo('── Phase 8: JQL Search / Bulk Sync (5x) ──');
|
|||
|
|
for (let i = 0; i < 5; i++) {
|
|||
|
|
count('GET /search (JQL)');
|
|||
|
|
await safeCall(`JQL search ${i + 1}/5`, () =>
|
|||
|
|
jiraApi.searchIssues(
|
|||
|
|
`project = ${projectKey} AND updated >= -24h ORDER BY updated DESC`,
|
|||
|
|
{ maxResults: 1000 }
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 9: Bulk key search (5x) ───────────────────────────
|
|||
|
|
logInfo('── Phase 9: Bulk Key Search (5x) ──');
|
|||
|
|
for (let i = 0; i < 5; i++) {
|
|||
|
|
count('GET /search (bulk sync)');
|
|||
|
|
await safeCall(`Bulk key search ${i + 1}/5`, () =>
|
|||
|
|
jiraApi.searchIssuesByKeys(testIssueKeys)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Phase 10: Issue lookups (15x) ───────────────────────────
|
|||
|
|
logInfo('── Phase 10: Issue Lookups (15x) ──');
|
|||
|
|
for (let i = 0; i < 15; i++) {
|
|||
|
|
const key = testIssueKeys[i % testIssueKeys.length];
|
|||
|
|
count('GET /search (single)');
|
|||
|
|
await safeCall(`Issue lookup ${i + 1}/15 (${key})`, () => jiraApi.getIssue(key));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Summary ─────────────────────────────────────────────────
|
|||
|
|
printSummary();
|
|||
|
|
writeLog();
|
|||
|
|
|
|||
|
|
console.log('\nLoad test complete. Log saved to backend/scripts/jira-load-test.log');
|
|||
|
|
console.log('Test issues created: ' + testIssueKeys.join(', '));
|
|||
|
|
console.log('Delete them manually after ATLSUP review if desired.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function printSummary() {
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('═══════════════════════════════════════════════════');
|
|||
|
|
logInfo(' 24-HOUR LOAD SIMULATION SUMMARY');
|
|||
|
|
logInfo('═══════════════════════════════════════════════════');
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('API Call Breakdown:');
|
|||
|
|
for (const [op, n] of Object.entries(callCounts)) {
|
|||
|
|
if (n > 0) logInfo(` ${op.padEnd(30)} ${n}`);
|
|||
|
|
}
|
|||
|
|
logInfo(` ${'─'.repeat(30)} ───`);
|
|||
|
|
logInfo(` ${'TOTAL'.padEnd(30)} ${totalCalls}`);
|
|||
|
|
logInfo('');
|
|||
|
|
|
|||
|
|
const rateLimits = jiraApi.getRateLimitStatus();
|
|||
|
|
logInfo('Rate Limit Usage:');
|
|||
|
|
logInfo(` Daily: ${rateLimits.daily.used} / ${rateLimits.daily.limit} (${((rateLimits.daily.used / rateLimits.daily.limit) * 100).toFixed(1)}%)`);
|
|||
|
|
logInfo(` Burst: ${rateLimits.burst.used} / ${rateLimits.burst.limit}`);
|
|||
|
|
logInfo('');
|
|||
|
|
|
|||
|
|
const passCount = results.filter(r => r.level === 'pass').length;
|
|||
|
|
const failCount = results.filter(r => r.level === 'fail').length;
|
|||
|
|
logInfo(`Results: ${passCount} passed, ${failCount} failed`);
|
|||
|
|
logInfo(`Test issues created: ${testIssueKeys.length}`);
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('NOTE FOR REVIEWER:');
|
|||
|
|
logInfo('This load test compresses an entire 24-hour production workload into');
|
|||
|
|
logInfo('~3-5 minutes. The 429 responses are expected when running at this');
|
|||
|
|
logInfo('compressed rate — the server-side burst limiter triggers because all');
|
|||
|
|
logInfo('calls arrive within minutes instead of being spread across a full day.');
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('In production, these ~120 calls are distributed across 8-10 working');
|
|||
|
|
logInfo('hours by human-triggered actions (click Sync, create ticket, etc.).');
|
|||
|
|
logInfo('At that cadence, the 1s/2s inter-request delays keep us well within');
|
|||
|
|
logInfo('both the 60/min burst cap and the 1,440/day daily limit.');
|
|||
|
|
logInfo('');
|
|||
|
|
logInfo('The 429 handling is intentional — the dashboard surfaces "Rate limit');
|
|||
|
|
logInfo('exceeded" to the user and does NOT auto-retry, per Charter policy.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function writeLog() {
|
|||
|
|
const lines = results.map(r => {
|
|||
|
|
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
|
|||
|
|
if (r.data) {
|
|||
|
|
const dataStr = typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2);
|
|||
|
|
const truncated = dataStr.length > 1000 ? dataStr.substring(0, 1000) + ' ...[truncated]' : dataStr;
|
|||
|
|
line += '\n ' + truncated.split('\n').join('\n ');
|
|||
|
|
}
|
|||
|
|
return line;
|
|||
|
|
});
|
|||
|
|
fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch(err => {
|
|||
|
|
console.error('Unhandled error:', err);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|