Spaces:
Running
feat: Add comprehensive production stress test suite
Browse filesAdd a complete stress testing framework for the backend API with multiple
scenarios and detailed performance reporting.
**Features:**
- 4 test scenarios: user registration, conversation creation, concurrent messages, admin monitoring
- Environment-based configuration (BASE_URL, CONCURRENT_USERS, etc.)
- Performance metrics: response times (min/avg/max/p95/p99), success rates, throughput
- HTML report generation with detailed metrics
- Production safety: confirmation prompts, dry-run mode, circuit breaker
- Admin endpoint verification
**Architecture:**
```
scripts/stress-test/
โโโ index.ts # Main orchestrator
โโโ config.ts # Environment configuration
โโโ metrics.ts # Metrics collection & HTML reports
โโโ utils.ts # HTTP client, helpers
โโโ README.md # Documentation
โโโ scenarios/
โโโ user-registration.ts # Scenario 1: Mass user registration
โโโ conversation-flow.ts # Scenario 2: Conversation creation
โโโ concurrent-messages.ts # Scenario 3: Concurrent LLM calls
โโโ admin-monitoring.ts # Scenario 4: Admin endpoint load
```
**Usage:**
```bash
# Local testing
npm run stress-test
# Production (safe)
BASE_URL=https://taboola-cz-sel-chat-coach.hf.space \
CONCURRENT_USERS=20 \
DURATION_MINUTES=2 \
npm run stress-test
# Dry run
DRY_RUN=true npm run stress-test
```
**Test Results (Local):**
- 5 concurrent users
- 10 conversations created
- 22 admin endpoint checks
- 100% success rate
- All scenarios passed
**Configuration Options:**
- BASE_URL - API endpoint (default: http://localhost:3000)
- CONCURRENT_USERS - Number of concurrent users (default: 50)
- DURATION_MINUTES - Test duration (default: 5)
- SCENARIOS - Comma-separated scenario list
- DRY_RUN - Validate config without executing
- MAX_ERROR_RATE - Circuit breaker threshold (default: 0.1)
**Safety Features:**
- Confirmation prompt for production URLs
- Circuit breaker stops test if error rate > 10%
- Dry-run mode for validation
- Detailed error logging
**Output:**
- Real-time console progress
- HTML report with performance charts
- Pass/fail status based on success rate
This enables systematic load testing and performance monitoring of the
production backend API.
๐ค Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- package.json +2 -1
- scripts/stress-test/README.md +292 -0
- scripts/stress-test/config.ts +75 -0
- scripts/stress-test/index.ts +182 -0
- scripts/stress-test/metrics.ts +348 -0
- scripts/stress-test/scenarios/admin-monitoring.ts +138 -0
- scripts/stress-test/scenarios/concurrent-messages.ts +81 -0
- scripts/stress-test/scenarios/conversation-flow.ts +74 -0
- scripts/stress-test/scenarios/user-registration.ts +56 -0
- scripts/stress-test/utils.ts +255 -0
|
@@ -12,7 +12,8 @@
|
|
| 12 |
"test:ui": "playwright test --project=ui",
|
| 13 |
"test:e2e": "playwright test",
|
| 14 |
"test:e2e:headed": "playwright test --headed",
|
| 15 |
-
"test:e2e:ui": "playwright test --ui"
|
|
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@ai-sdk/openai": "^2.0.32",
|
|
|
|
| 12 |
"test:ui": "playwright test --project=ui",
|
| 13 |
"test:e2e": "playwright test",
|
| 14 |
"test:e2e:headed": "playwright test --headed",
|
| 15 |
+
"test:e2e:ui": "playwright test --ui",
|
| 16 |
+
"stress-test": "tsx scripts/stress-test/index.ts"
|
| 17 |
},
|
| 18 |
"dependencies": {
|
| 19 |
"@ai-sdk/openai": "^2.0.32",
|
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production Backend API Stress Test
|
| 2 |
+
|
| 3 |
+
Comprehensive stress testing tool for the SEL Chat Coach backend API. Simulates realistic user load and monitors admin endpoints for performance issues.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- โ
**Configurable via environment variables**
|
| 8 |
+
- โ
**Multiple test scenarios**: User registration, conversation creation, concurrent messaging, admin monitoring
|
| 9 |
+
- โ
**Performance metrics**: Response times (min/avg/max/p95/p99), success rates, throughput
|
| 10 |
+
- โ
**Circuit breaker**: Auto-stop if error rate exceeds threshold
|
| 11 |
+
- โ
**HTML report generation**: Detailed performance reports with charts
|
| 12 |
+
- โ
**Production safety**: Confirmation prompts, dry-run mode, error rate limits
|
| 13 |
+
|
| 14 |
+
## Quick Start
|
| 15 |
+
|
| 16 |
+
### 1. Run Against Local Development Server
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
# Start dev server first
|
| 20 |
+
npm run dev
|
| 21 |
+
|
| 22 |
+
# In another terminal, run stress test (defaults to localhost:3000)
|
| 23 |
+
npm run stress-test
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
### 2. Run Against Production
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
BASE_URL=https://taboola-cz-sel-chat-coach.hf.space \
|
| 30 |
+
CONCURRENT_USERS=20 \
|
| 31 |
+
DURATION_MINUTES=2 \
|
| 32 |
+
npm run stress-test
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### 3. Dry Run (Validate Configuration)
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
BASE_URL=https://taboola-cz-sel-chat-coach.hf.space \
|
| 39 |
+
DRY_RUN=true \
|
| 40 |
+
npm run stress-test
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Configuration
|
| 44 |
+
|
| 45 |
+
All configuration is done via environment variables:
|
| 46 |
+
|
| 47 |
+
| Variable | Description | Default |
|
| 48 |
+
|----------|-------------|---------|
|
| 49 |
+
| `BASE_URL` | API base URL | `http://localhost:3000` |
|
| 50 |
+
| `BASIC_AUTH_PASSWORD` | Auth password | `cz-2025` |
|
| 51 |
+
| `CONCURRENT_USERS` | Number of concurrent users | `50` |
|
| 52 |
+
| `DURATION_MINUTES` | Test duration in minutes | `5` |
|
| 53 |
+
| `SCENARIOS` | Comma-separated scenario list | All scenarios |
|
| 54 |
+
| `DRY_RUN` | Validate config without executing | `false` |
|
| 55 |
+
| `CLEANUP` | Delete test data after run | `false` |
|
| 56 |
+
| `MAX_ERROR_RATE` | Circuit breaker threshold (0-1) | `0.1` (10%) |
|
| 57 |
+
| `VERBOSE` | Enable verbose logging | `false` |
|
| 58 |
+
|
| 59 |
+
## Scenarios
|
| 60 |
+
|
| 61 |
+
### 1. User Registration
|
| 62 |
+
- **What it does**: Registers N concurrent users
|
| 63 |
+
- **Metrics**: Registration response time, success rate
|
| 64 |
+
- **Duration**: ~10-30 seconds
|
| 65 |
+
|
| 66 |
+
### 2. Conversation Creation
|
| 67 |
+
- **What it does**: Each user creates 2 conversations with random student/coach combinations
|
| 68 |
+
- **Metrics**: Creation response time, database writes
|
| 69 |
+
- **Duration**: ~20-60 seconds
|
| 70 |
+
|
| 71 |
+
### 3. Concurrent Messages (WARNING: Slow)
|
| 72 |
+
- **What it does**: Sends 20 concurrent messages with LLM calls
|
| 73 |
+
- **Metrics**: LLM response time (60-90s per message), streaming performance
|
| 74 |
+
- **Duration**: ~60-90 seconds (due to LLM bottleneck)
|
| 75 |
+
|
| 76 |
+
### 4. Admin Monitoring
|
| 77 |
+
- **What it does**: Continuously polls admin endpoints (health, stats)
|
| 78 |
+
- **Metrics**: Query performance under load, data consistency
|
| 79 |
+
- **Duration**: Configured duration (default 5 minutes)
|
| 80 |
+
- **Verification**: Checks if database counts match expected values
|
| 81 |
+
|
| 82 |
+
## Usage Examples
|
| 83 |
+
|
| 84 |
+
### Test Specific Scenarios
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# Only run user registration and conversation creation
|
| 88 |
+
SCENARIOS=user-registration,conversation-flow \
|
| 89 |
+
npm run stress-test
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### High Concurrency Test
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
# Test with 100 concurrent users
|
| 96 |
+
CONCURRENT_USERS=100 \
|
| 97 |
+
DURATION_MINUTES=3 \
|
| 98 |
+
npm run stress-test
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### Extended Monitoring
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
# Monitor admin endpoints for 10 minutes
|
| 105 |
+
SCENARIOS=admin-monitoring \
|
| 106 |
+
DURATION_MINUTES=10 \
|
| 107 |
+
npm run stress-test
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### Production Test (Conservative)
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# Safe production test: 20 users, 2 minutes, skip LLM scenario
|
| 114 |
+
BASE_URL=https://taboola-cz-sel-chat-coach.hf.space \
|
| 115 |
+
CONCURRENT_USERS=20 \
|
| 116 |
+
DURATION_MINUTES=2 \
|
| 117 |
+
SCENARIOS=user-registration,conversation-flow,admin-monitoring \
|
| 118 |
+
npm run stress-test
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Output
|
| 122 |
+
|
| 123 |
+
### Console Output
|
| 124 |
+
|
| 125 |
+
```
|
| 126 |
+
๐ Production Backend API Stress Test
|
| 127 |
+
|
| 128 |
+
Configuration:
|
| 129 |
+
Base URL: https://taboola-cz-sel-chat-coach.hf.space
|
| 130 |
+
Concurrent Users: 50
|
| 131 |
+
Duration: 5 minutes
|
| 132 |
+
Scenarios: user-registration, conversation-flow, concurrent-messages, admin-monitoring
|
| 133 |
+
|
| 134 |
+
โ ๏ธ WARNING: Running against PRODUCTION environment!
|
| 135 |
+
Continue? (y/n) y
|
| 136 |
+
|
| 137 |
+
[User Registration] Registering 50 users...
|
| 138 |
+
โ [User Registration] Completed in 15.2s
|
| 139 |
+
Total Requests: 50
|
| 140 |
+
Success Rate: 100.0% (50/50)
|
| 141 |
+
Response Time:
|
| 142 |
+
min: 120ms
|
| 143 |
+
avg: 245ms
|
| 144 |
+
max: 890ms
|
| 145 |
+
p95: 450ms
|
| 146 |
+
p99: 720ms
|
| 147 |
+
Throughput: 3.29 req/s
|
| 148 |
+
|
| 149 |
+
[Conversation Creation] Creating 100 conversations (2 per user)...
|
| 150 |
+
โ [Conversation Creation] Completed in 22.8s
|
| 151 |
+
Total Requests: 100
|
| 152 |
+
Success Rate: 100.0% (100/100)
|
| 153 |
+
...
|
| 154 |
+
|
| 155 |
+
๐ Report saved to: /path/to/stress-test-report.html
|
| 156 |
+
|
| 157 |
+
Overall Statistics:
|
| 158 |
+
Total Requests: 270
|
| 159 |
+
Successful: 268
|
| 160 |
+
Failed: 2
|
| 161 |
+
Success Rate: 99.3%
|
| 162 |
+
|
| 163 |
+
โ Test PASSED
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### HTML Report
|
| 167 |
+
|
| 168 |
+
After the test completes, an HTML report is generated at `./stress-test-report.html`. Open it in a browser to view:
|
| 169 |
+
- Detailed metrics for each scenario
|
| 170 |
+
- Response time distributions
|
| 171 |
+
- Error breakdowns
|
| 172 |
+
- Performance charts
|
| 173 |
+
|
| 174 |
+
## Interpreting Results
|
| 175 |
+
|
| 176 |
+
### Success Criteria
|
| 177 |
+
|
| 178 |
+
โ
**Good Performance:**
|
| 179 |
+
- Success rate โฅ 95%
|
| 180 |
+
- p95 response time < 1000ms (except LLM calls)
|
| 181 |
+
- No timeout errors
|
| 182 |
+
- Database counts match expectations
|
| 183 |
+
|
| 184 |
+
โ ๏ธ **Warning Signs:**
|
| 185 |
+
- Success rate 80-95%
|
| 186 |
+
- p95 response time 1000-3000ms
|
| 187 |
+
- Occasional timeout errors
|
| 188 |
+
- Database inconsistencies
|
| 189 |
+
|
| 190 |
+
โ **Poor Performance:**
|
| 191 |
+
- Success rate < 80%
|
| 192 |
+
- p95 response time > 3000ms
|
| 193 |
+
- Frequent timeout errors
|
| 194 |
+
- Database connection pool exhaustion
|
| 195 |
+
|
| 196 |
+
### Common Issues
|
| 197 |
+
|
| 198 |
+
**Connection timeouts:**
|
| 199 |
+
- Check Supabase connection limits
|
| 200 |
+
- Verify network connectivity
|
| 201 |
+
- Reduce concurrent users
|
| 202 |
+
|
| 203 |
+
**High error rates:**
|
| 204 |
+
- Check server logs for details
|
| 205 |
+
- Verify authentication credentials
|
| 206 |
+
- Check rate limiting
|
| 207 |
+
|
| 208 |
+
**Slow response times:**
|
| 209 |
+
- Database performance issues
|
| 210 |
+
- LLM API rate limits
|
| 211 |
+
- Network latency
|
| 212 |
+
|
| 213 |
+
## Safety Features
|
| 214 |
+
|
| 215 |
+
### Production Safety
|
| 216 |
+
|
| 217 |
+
1. **Confirmation Prompt**: Requires manual confirmation when running against production
|
| 218 |
+
2. **Circuit Breaker**: Stops test if error rate exceeds 10% (configurable)
|
| 219 |
+
3. **Dry Run Mode**: Validate configuration without sending requests
|
| 220 |
+
4. **Max Concurrency Limit**: Hard limit of 1000 concurrent users
|
| 221 |
+
|
| 222 |
+
### Cleanup
|
| 223 |
+
|
| 224 |
+
By default, test data is NOT cleaned up (for debugging). To enable cleanup:
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
CLEANUP=true npm run stress-test
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
**Warning**: Cleanup is not yet implemented. Test users/conversations will persist in the database.
|
| 231 |
+
|
| 232 |
+
## Troubleshooting
|
| 233 |
+
|
| 234 |
+
### `MODULE_NOT_FOUND` Error
|
| 235 |
+
|
| 236 |
+
Ensure you have `tsx` installed:
|
| 237 |
+
```bash
|
| 238 |
+
npm install
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### Permission Denied
|
| 242 |
+
|
| 243 |
+
Make index.ts executable:
|
| 244 |
+
```bash
|
| 245 |
+
chmod +x scripts/stress-test/index.ts
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### Test Timeouts
|
| 249 |
+
|
| 250 |
+
Increase the duration for LLM-heavy scenarios:
|
| 251 |
+
```bash
|
| 252 |
+
DURATION_MINUTES=10 npm run stress-test
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
## Architecture
|
| 256 |
+
|
| 257 |
+
```
|
| 258 |
+
scripts/stress-test/
|
| 259 |
+
โโโ index.ts # Main orchestrator
|
| 260 |
+
โโโ config.ts # Environment variable parsing
|
| 261 |
+
โโโ metrics.ts # Metrics collection & HTML reports
|
| 262 |
+
โโโ utils.ts # HTTP client, auth, helpers
|
| 263 |
+
โโโ scenarios/
|
| 264 |
+
โโโ user-registration.ts # Scenario 1
|
| 265 |
+
โโโ conversation-flow.ts # Scenario 2
|
| 266 |
+
โโโ concurrent-messages.ts # Scenario 3
|
| 267 |
+
โโโ admin-monitoring.ts # Scenario 4
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
## Development
|
| 271 |
+
|
| 272 |
+
### Adding a New Scenario
|
| 273 |
+
|
| 274 |
+
1. Create scenario file: `scenarios/my-scenario.ts`
|
| 275 |
+
2. Implement scenario function
|
| 276 |
+
3. Add scenario name to config.ts
|
| 277 |
+
4. Import and call in index.ts
|
| 278 |
+
|
| 279 |
+
### Customizing Metrics
|
| 280 |
+
|
| 281 |
+
Edit `metrics.ts` to add new metrics or modify HTML report template.
|
| 282 |
+
|
| 283 |
+
## Limitations
|
| 284 |
+
|
| 285 |
+
- **LLM Rate Limits**: OpenAI has rate limits - concurrent message scenario is slow
|
| 286 |
+
- **Database**: SQLite (local) has limited concurrency vs Supabase (production)
|
| 287 |
+
- **Network Latency**: Production tests affected by HuggingFace โ Supabase Singapore latency
|
| 288 |
+
- **Cleanup**: Not yet implemented - test data persists
|
| 289 |
+
|
| 290 |
+
## License
|
| 291 |
+
|
| 292 |
+
Part of SEL Chat Coach project.
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Stress Test Configuration
|
| 3 |
+
* Reads from environment variables with sensible defaults
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface StressTestConfig {
|
| 7 |
+
baseURL: string;
|
| 8 |
+
password: string;
|
| 9 |
+
concurrentUsers: number;
|
| 10 |
+
durationMinutes: number;
|
| 11 |
+
scenarios: string[];
|
| 12 |
+
dryRun: boolean;
|
| 13 |
+
cleanup: boolean;
|
| 14 |
+
maxErrorRate: number; // Circuit breaker threshold (0-1)
|
| 15 |
+
verbose: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function loadConfig(): StressTestConfig {
|
| 19 |
+
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 20 |
+
const password = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 21 |
+
const concurrentUsers = parseInt(process.env.CONCURRENT_USERS || '50', 10);
|
| 22 |
+
const durationMinutes = parseInt(process.env.DURATION_MINUTES || '5', 10);
|
| 23 |
+
const dryRun = process.env.DRY_RUN === 'true';
|
| 24 |
+
const cleanup = process.env.CLEANUP === 'true';
|
| 25 |
+
const maxErrorRate = parseFloat(process.env.MAX_ERROR_RATE || '0.1'); // 10% default
|
| 26 |
+
const verbose = process.env.VERBOSE === 'true';
|
| 27 |
+
|
| 28 |
+
// Parse scenarios from env or use all by default
|
| 29 |
+
const scenariosEnv = process.env.SCENARIOS || '';
|
| 30 |
+
const scenarios = scenariosEnv
|
| 31 |
+
? scenariosEnv.split(',').map(s => s.trim())
|
| 32 |
+
: [
|
| 33 |
+
'user-registration',
|
| 34 |
+
'conversation-flow',
|
| 35 |
+
'concurrent-messages',
|
| 36 |
+
'admin-monitoring',
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
// Validation
|
| 40 |
+
if (concurrentUsers < 1 || concurrentUsers > 1000) {
|
| 41 |
+
throw new Error('CONCURRENT_USERS must be between 1 and 1000');
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (durationMinutes < 1 || durationMinutes > 60) {
|
| 45 |
+
throw new Error('DURATION_MINUTES must be between 1 and 60');
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (maxErrorRate < 0 || maxErrorRate > 1) {
|
| 49 |
+
throw new Error('MAX_ERROR_RATE must be between 0 and 1');
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
baseURL,
|
| 54 |
+
password,
|
| 55 |
+
concurrentUsers,
|
| 56 |
+
durationMinutes,
|
| 57 |
+
scenarios,
|
| 58 |
+
dryRun,
|
| 59 |
+
cleanup,
|
| 60 |
+
maxErrorRate,
|
| 61 |
+
verbose,
|
| 62 |
+
};
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export const STUDENT_PERSONALITIES = [
|
| 66 |
+
'ruirui', // ็ฟ็ฟ - Elementary, Distractor
|
| 67 |
+
'xiaoxu', // ๅฐ่จฑ - Elementary, Blamer
|
| 68 |
+
'xiaoen', // ๅฐๆฉ - Junior high, Distractor
|
| 69 |
+
'xiaojie', // ๅฐๅฉ - Junior high, Pleaser
|
| 70 |
+
'ajie', // ้ฟๆฐ - Junior high, Super-rational
|
| 71 |
+
];
|
| 72 |
+
|
| 73 |
+
export const COACH_TYPES = [
|
| 74 |
+
'satir', // Empathetic
|
| 75 |
+
];
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env tsx
|
| 2 |
+
/**
|
| 3 |
+
* Production Backend API Stress Test
|
| 4 |
+
*
|
| 5 |
+
* Simulates realistic load against the production backend and monitors
|
| 6 |
+
* admin endpoints for performance issues.
|
| 7 |
+
*
|
| 8 |
+
* Usage:
|
| 9 |
+
* BASE_URL=https://taboola-cz-sel-chat-coach.hf.space \
|
| 10 |
+
* CONCURRENT_USERS=50 \
|
| 11 |
+
* DURATION_MINUTES=5 \
|
| 12 |
+
* npm run stress-test
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import * as readline from 'readline';
|
| 16 |
+
import { loadConfig } from './config';
|
| 17 |
+
import { MetricsCollector } from './metrics';
|
| 18 |
+
import { userRegistrationScenario } from './scenarios/user-registration';
|
| 19 |
+
import { conversationFlowScenario } from './scenarios/conversation-flow';
|
| 20 |
+
import { concurrentMessagesScenario } from './scenarios/concurrent-messages';
|
| 21 |
+
import { adminMonitoringScenario } from './scenarios/admin-monitoring';
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Prompt user for confirmation
|
| 25 |
+
*/
|
| 26 |
+
function promptConfirm(question: string): Promise<boolean> {
|
| 27 |
+
const rl = readline.createInterface({
|
| 28 |
+
input: process.stdin,
|
| 29 |
+
output: process.stdout,
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
return new Promise(resolve => {
|
| 33 |
+
rl.question(question + ' (y/n) ', answer => {
|
| 34 |
+
rl.close();
|
| 35 |
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
| 36 |
+
});
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Main stress test orchestrator
|
| 42 |
+
*/
|
| 43 |
+
async function main() {
|
| 44 |
+
console.log('๐ Production Backend API Stress Test\n');
|
| 45 |
+
|
| 46 |
+
// Load configuration
|
| 47 |
+
const config = loadConfig();
|
| 48 |
+
|
| 49 |
+
console.log('Configuration:');
|
| 50 |
+
console.log(` Base URL: ${config.baseURL}`);
|
| 51 |
+
console.log(` Concurrent Users: ${config.concurrentUsers}`);
|
| 52 |
+
console.log(` Duration: ${config.durationMinutes} minutes`);
|
| 53 |
+
console.log(` Scenarios: ${config.scenarios.join(', ')}`);
|
| 54 |
+
console.log(` Dry Run: ${config.dryRun ? 'Yes' : 'No'}`);
|
| 55 |
+
console.log(` Cleanup: ${config.cleanup ? 'Yes' : 'No'}`);
|
| 56 |
+
console.log(` Max Error Rate: ${(config.maxErrorRate * 100).toFixed(0)}%`);
|
| 57 |
+
console.log('');
|
| 58 |
+
|
| 59 |
+
// Confirmation for production
|
| 60 |
+
if (config.baseURL.includes('hf.space') || config.baseURL.includes('production')) {
|
| 61 |
+
console.log('โ ๏ธ WARNING: Running against PRODUCTION environment!');
|
| 62 |
+
const confirmed = await promptConfirm('Continue?');
|
| 63 |
+
if (!confirmed) {
|
| 64 |
+
console.log('Aborted.');
|
| 65 |
+
process.exit(0);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (config.dryRun) {
|
| 70 |
+
console.log('โ Dry run mode - Configuration validated. No requests will be sent.');
|
| 71 |
+
process.exit(0);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const metrics = new MetricsCollector();
|
| 75 |
+
const startTime = Date.now();
|
| 76 |
+
|
| 77 |
+
let usernames: string[] = [];
|
| 78 |
+
let conversations: Array<{ id: string; username: string }> = [];
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
// Scenario 1: User Registration
|
| 82 |
+
if (config.scenarios.includes('user-registration')) {
|
| 83 |
+
const result = await userRegistrationScenario(config, metrics);
|
| 84 |
+
usernames = result.usernames;
|
| 85 |
+
|
| 86 |
+
// Check error rate (circuit breaker)
|
| 87 |
+
const scenarioMetrics = metrics.getScenarioMetrics('User Registration');
|
| 88 |
+
if (scenarioMetrics && scenarioMetrics.errorRate > config.maxErrorRate) {
|
| 89 |
+
console.log(
|
| 90 |
+
`\nโ Error rate (${(scenarioMetrics.errorRate * 100).toFixed(1)}%) exceeds threshold (${(config.maxErrorRate * 100).toFixed(0)}%). Stopping.`
|
| 91 |
+
);
|
| 92 |
+
process.exit(1);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Scenario 2: Conversation Creation
|
| 97 |
+
if (config.scenarios.includes('conversation-flow') && usernames.length > 0) {
|
| 98 |
+
const result = await conversationFlowScenario(config, metrics, usernames);
|
| 99 |
+
conversations = result.conversations;
|
| 100 |
+
|
| 101 |
+
// Check error rate
|
| 102 |
+
const scenarioMetrics = metrics.getScenarioMetrics('Conversation Creation');
|
| 103 |
+
if (scenarioMetrics && scenarioMetrics.errorRate > config.maxErrorRate) {
|
| 104 |
+
console.log(
|
| 105 |
+
`\nโ Error rate (${(scenarioMetrics.errorRate * 100).toFixed(1)}%) exceeds threshold (${(config.maxErrorRate * 100).toFixed(0)}%). Stopping.`
|
| 106 |
+
);
|
| 107 |
+
process.exit(1);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Scenario 3: Concurrent Messages (WARNING: Slow due to LLM)
|
| 112 |
+
if (config.scenarios.includes('concurrent-messages') && conversations.length > 0) {
|
| 113 |
+
await concurrentMessagesScenario(config, metrics, conversations);
|
| 114 |
+
|
| 115 |
+
// Check error rate
|
| 116 |
+
const scenarioMetrics = metrics.getScenarioMetrics('Concurrent Messages');
|
| 117 |
+
if (scenarioMetrics && scenarioMetrics.errorRate > config.maxErrorRate) {
|
| 118 |
+
console.log(
|
| 119 |
+
`\nโ Error rate (${(scenarioMetrics.errorRate * 100).toFixed(1)}%) exceeds threshold (${(config.maxErrorRate * 100).toFixed(0)}%). Stopping.`
|
| 120 |
+
);
|
| 121 |
+
process.exit(1);
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Scenario 4: Admin Monitoring
|
| 126 |
+
if (config.scenarios.includes('admin-monitoring')) {
|
| 127 |
+
const expectedUsers = usernames.length;
|
| 128 |
+
const expectedConversations = conversations.length;
|
| 129 |
+
const expectedMessages = 0; // Messages sent in scenario 3
|
| 130 |
+
|
| 131 |
+
await adminMonitoringScenario(
|
| 132 |
+
config,
|
| 133 |
+
metrics,
|
| 134 |
+
expectedUsers,
|
| 135 |
+
expectedConversations,
|
| 136 |
+
expectedMessages
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Summary
|
| 141 |
+
const totalDuration = Date.now() - startTime;
|
| 142 |
+
console.log('\n' + '='.repeat(60));
|
| 143 |
+
console.log(`โ Stress test completed in ${(totalDuration / 1000 / 60).toFixed(1)} minutes`);
|
| 144 |
+
console.log('='.repeat(60));
|
| 145 |
+
|
| 146 |
+
// Generate HTML report
|
| 147 |
+
metrics.generateHTMLReport('./stress-test-report.html');
|
| 148 |
+
|
| 149 |
+
// Overall summary
|
| 150 |
+
const allMetrics = metrics.getAllMetrics();
|
| 151 |
+
const totalRequests = allMetrics.reduce((sum, m) => sum + m.totalRequests, 0);
|
| 152 |
+
const totalSuccessful = allMetrics.reduce((sum, m) => sum + m.successfulRequests, 0);
|
| 153 |
+
const totalFailed = allMetrics.reduce((sum, m) => sum + m.failedRequests, 0);
|
| 154 |
+
const overallSuccessRate =
|
| 155 |
+
totalRequests > 0 ? (totalSuccessful / totalRequests) * 100 : 0;
|
| 156 |
+
|
| 157 |
+
console.log(`\nOverall Statistics:`);
|
| 158 |
+
console.log(` Total Requests: ${totalRequests}`);
|
| 159 |
+
console.log(` Successful: ${totalSuccessful}`);
|
| 160 |
+
console.log(` Failed: ${totalFailed}`);
|
| 161 |
+
console.log(` Success Rate: ${overallSuccessRate.toFixed(1)}%`);
|
| 162 |
+
|
| 163 |
+
// Exit with error if overall success rate is too low
|
| 164 |
+
if (overallSuccessRate < (1 - config.maxErrorRate) * 100) {
|
| 165 |
+
console.log(`\nโ Overall success rate too low. Test FAILED.`);
|
| 166 |
+
process.exit(1);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
console.log(`\nโ Test PASSED`);
|
| 170 |
+
} catch (error: any) {
|
| 171 |
+
console.error('\nโ Stress test failed:', error.message);
|
| 172 |
+
if (config.verbose && error.stack) {
|
| 173 |
+
console.error(error.stack);
|
| 174 |
+
}
|
| 175 |
+
process.exit(1);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
main().catch(error => {
|
| 180 |
+
console.error('Fatal error:', error);
|
| 181 |
+
process.exit(1);
|
| 182 |
+
});
|
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Metrics Collection and Reporting
|
| 3 |
+
* Tracks performance metrics and generates reports
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { RequestTiming, percentile, formatDuration } from './utils';
|
| 7 |
+
import * as fs from 'fs';
|
| 8 |
+
import * as path from 'path';
|
| 9 |
+
|
| 10 |
+
export interface ScenarioMetrics {
|
| 11 |
+
name: string;
|
| 12 |
+
startTime: number;
|
| 13 |
+
endTime: number;
|
| 14 |
+
duration: number;
|
| 15 |
+
totalRequests: number;
|
| 16 |
+
successfulRequests: number;
|
| 17 |
+
failedRequests: number;
|
| 18 |
+
successRate: number;
|
| 19 |
+
errorRate: number;
|
| 20 |
+
timings: {
|
| 21 |
+
min: number;
|
| 22 |
+
max: number;
|
| 23 |
+
avg: number;
|
| 24 |
+
p50: number;
|
| 25 |
+
p95: number;
|
| 26 |
+
p99: number;
|
| 27 |
+
};
|
| 28 |
+
requestsPerSecond: number;
|
| 29 |
+
errors: Array<{ message: string; count: number }>;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export class MetricsCollector {
|
| 33 |
+
private scenarios: Map<string, ScenarioMetrics> = new Map();
|
| 34 |
+
private currentScenario: string | null = null;
|
| 35 |
+
private scenarioStartTime: number = 0;
|
| 36 |
+
private timings: RequestTiming[] = [];
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Start tracking a scenario
|
| 40 |
+
*/
|
| 41 |
+
startScenario(name: string): void {
|
| 42 |
+
this.currentScenario = name;
|
| 43 |
+
this.scenarioStartTime = Date.now();
|
| 44 |
+
this.timings = [];
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Add timing data
|
| 49 |
+
*/
|
| 50 |
+
addTiming(timing: RequestTiming): void {
|
| 51 |
+
this.timings.push(timing);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Add multiple timings
|
| 56 |
+
*/
|
| 57 |
+
addTimings(timings: RequestTiming[]): void {
|
| 58 |
+
this.timings.push(...timings);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* End tracking current scenario
|
| 63 |
+
*/
|
| 64 |
+
endScenario(): void {
|
| 65 |
+
if (!this.currentScenario) return;
|
| 66 |
+
|
| 67 |
+
const endTime = Date.now();
|
| 68 |
+
const duration = endTime - this.scenarioStartTime;
|
| 69 |
+
|
| 70 |
+
const successfulRequests = this.timings.filter(t => t.success).length;
|
| 71 |
+
const failedRequests = this.timings.filter(t => !t.success).length;
|
| 72 |
+
const totalRequests = this.timings.length;
|
| 73 |
+
|
| 74 |
+
const durations = this.timings.map(t => t.duration);
|
| 75 |
+
|
| 76 |
+
const metrics: ScenarioMetrics = {
|
| 77 |
+
name: this.currentScenario,
|
| 78 |
+
startTime: this.scenarioStartTime,
|
| 79 |
+
endTime,
|
| 80 |
+
duration,
|
| 81 |
+
totalRequests,
|
| 82 |
+
successfulRequests,
|
| 83 |
+
failedRequests,
|
| 84 |
+
successRate: totalRequests > 0 ? successfulRequests / totalRequests : 0,
|
| 85 |
+
errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
|
| 86 |
+
timings: {
|
| 87 |
+
min: durations.length > 0 ? Math.min(...durations) : 0,
|
| 88 |
+
max: durations.length > 0 ? Math.max(...durations) : 0,
|
| 89 |
+
avg: durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0,
|
| 90 |
+
p50: percentile(durations, 0.5),
|
| 91 |
+
p95: percentile(durations, 0.95),
|
| 92 |
+
p99: percentile(durations, 0.99),
|
| 93 |
+
},
|
| 94 |
+
requestsPerSecond: duration > 0 ? (totalRequests / duration) * 1000 : 0,
|
| 95 |
+
errors: this.aggregateErrors(),
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
this.scenarios.set(this.currentScenario, metrics);
|
| 99 |
+
this.currentScenario = null;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Aggregate error messages
|
| 104 |
+
*/
|
| 105 |
+
private aggregateErrors(): Array<{ message: string; count: number }> {
|
| 106 |
+
const errorCounts = new Map<string, number>();
|
| 107 |
+
|
| 108 |
+
for (const timing of this.timings) {
|
| 109 |
+
if (timing.error) {
|
| 110 |
+
const count = errorCounts.get(timing.error) || 0;
|
| 111 |
+
errorCounts.set(timing.error, count + 1);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return Array.from(errorCounts.entries())
|
| 116 |
+
.map(([message, count]) => ({ message, count }))
|
| 117 |
+
.sort((a, b) => b.count - a.count);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Get metrics for a scenario
|
| 122 |
+
*/
|
| 123 |
+
getScenarioMetrics(name: string): ScenarioMetrics | undefined {
|
| 124 |
+
return this.scenarios.get(name);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Get all scenario metrics
|
| 129 |
+
*/
|
| 130 |
+
getAllMetrics(): ScenarioMetrics[] {
|
| 131 |
+
return Array.from(this.scenarios.values());
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* Print scenario summary to console
|
| 136 |
+
*/
|
| 137 |
+
printScenarioSummary(name: string): void {
|
| 138 |
+
const metrics = this.scenarios.get(name);
|
| 139 |
+
if (!metrics) return;
|
| 140 |
+
|
| 141 |
+
console.log(`\nโ [${name}] Completed in ${formatDuration(metrics.duration)}`);
|
| 142 |
+
console.log(` Total Requests: ${metrics.totalRequests}`);
|
| 143 |
+
console.log(
|
| 144 |
+
` Success Rate: ${(metrics.successRate * 100).toFixed(1)}% (${metrics.successfulRequests}/${metrics.totalRequests})`
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
if (metrics.failedRequests > 0) {
|
| 148 |
+
console.log(` โ Failed: ${metrics.failedRequests}`);
|
| 149 |
+
metrics.errors.slice(0, 3).forEach(err => {
|
| 150 |
+
console.log(` - ${err.message} (${err.count}x)`);
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
console.log(` Response Time:`);
|
| 155 |
+
console.log(` min: ${formatDuration(metrics.timings.min)}`);
|
| 156 |
+
console.log(` avg: ${formatDuration(metrics.timings.avg)}`);
|
| 157 |
+
console.log(` max: ${formatDuration(metrics.timings.max)}`);
|
| 158 |
+
console.log(` p95: ${formatDuration(metrics.timings.p95)}`);
|
| 159 |
+
console.log(` p99: ${formatDuration(metrics.timings.p99)}`);
|
| 160 |
+
console.log(` Throughput: ${metrics.requestsPerSecond.toFixed(2)} req/s`);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Generate HTML report
|
| 165 |
+
*/
|
| 166 |
+
generateHTMLReport(outputPath: string): void {
|
| 167 |
+
const allMetrics = this.getAllMetrics();
|
| 168 |
+
|
| 169 |
+
const html = `
|
| 170 |
+
<!DOCTYPE html>
|
| 171 |
+
<html lang="en">
|
| 172 |
+
<head>
|
| 173 |
+
<meta charset="UTF-8">
|
| 174 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 175 |
+
<title>Stress Test Report</title>
|
| 176 |
+
<style>
|
| 177 |
+
body {
|
| 178 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 179 |
+
max-width: 1200px;
|
| 180 |
+
margin: 0 auto;
|
| 181 |
+
padding: 20px;
|
| 182 |
+
background: #f5f5f5;
|
| 183 |
+
}
|
| 184 |
+
h1 { color: #333; }
|
| 185 |
+
h2 { color: #555; margin-top: 30px; }
|
| 186 |
+
.summary {
|
| 187 |
+
background: white;
|
| 188 |
+
padding: 20px;
|
| 189 |
+
border-radius: 8px;
|
| 190 |
+
margin-bottom: 20px;
|
| 191 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 192 |
+
}
|
| 193 |
+
.scenario {
|
| 194 |
+
background: white;
|
| 195 |
+
padding: 20px;
|
| 196 |
+
border-radius: 8px;
|
| 197 |
+
margin-bottom: 20px;
|
| 198 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 199 |
+
}
|
| 200 |
+
.metric-row {
|
| 201 |
+
display: flex;
|
| 202 |
+
justify-content: space-between;
|
| 203 |
+
padding: 8px 0;
|
| 204 |
+
border-bottom: 1px solid #eee;
|
| 205 |
+
}
|
| 206 |
+
.metric-label { font-weight: 600; color: #666; }
|
| 207 |
+
.metric-value { color: #333; }
|
| 208 |
+
.success { color: #22c55e; }
|
| 209 |
+
.error { color: #ef4444; }
|
| 210 |
+
.warning { color: #f59e0b; }
|
| 211 |
+
table {
|
| 212 |
+
width: 100%;
|
| 213 |
+
border-collapse: collapse;
|
| 214 |
+
margin-top: 15px;
|
| 215 |
+
}
|
| 216 |
+
th, td {
|
| 217 |
+
text-align: left;
|
| 218 |
+
padding: 12px;
|
| 219 |
+
border-bottom: 1px solid #eee;
|
| 220 |
+
}
|
| 221 |
+
th {
|
| 222 |
+
background: #f9fafb;
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
color: #666;
|
| 225 |
+
}
|
| 226 |
+
.timestamp {
|
| 227 |
+
color: #999;
|
| 228 |
+
font-size: 0.9em;
|
| 229 |
+
}
|
| 230 |
+
</style>
|
| 231 |
+
</head>
|
| 232 |
+
<body>
|
| 233 |
+
<h1>๐ Stress Test Report</h1>
|
| 234 |
+
<div class="timestamp">Generated: ${new Date().toLocaleString()}</div>
|
| 235 |
+
|
| 236 |
+
<div class="summary">
|
| 237 |
+
<h2>๐ Overall Summary</h2>
|
| 238 |
+
${allMetrics
|
| 239 |
+
.map(
|
| 240 |
+
m => `
|
| 241 |
+
<div class="metric-row">
|
| 242 |
+
<span class="metric-label">${m.name}</span>
|
| 243 |
+
<span class="metric-value">
|
| 244 |
+
${m.successfulRequests}/${m.totalRequests} requests
|
| 245 |
+
(${(m.successRate * 100).toFixed(1)}% success)
|
| 246 |
+
in ${formatDuration(m.duration)}
|
| 247 |
+
</span>
|
| 248 |
+
</div>
|
| 249 |
+
`
|
| 250 |
+
)
|
| 251 |
+
.join('')}
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
${allMetrics
|
| 255 |
+
.map(
|
| 256 |
+
m => `
|
| 257 |
+
<div class="scenario">
|
| 258 |
+
<h2>${m.name}</h2>
|
| 259 |
+
|
| 260 |
+
<table>
|
| 261 |
+
<tr>
|
| 262 |
+
<th>Metric</th>
|
| 263 |
+
<th>Value</th>
|
| 264 |
+
</tr>
|
| 265 |
+
<tr>
|
| 266 |
+
<td>Duration</td>
|
| 267 |
+
<td>${formatDuration(m.duration)}</td>
|
| 268 |
+
</tr>
|
| 269 |
+
<tr>
|
| 270 |
+
<td>Total Requests</td>
|
| 271 |
+
<td>${m.totalRequests}</td>
|
| 272 |
+
</tr>
|
| 273 |
+
<tr>
|
| 274 |
+
<td>Successful</td>
|
| 275 |
+
<td class="success">${m.successfulRequests}</td>
|
| 276 |
+
</tr>
|
| 277 |
+
<tr>
|
| 278 |
+
<td>Failed</td>
|
| 279 |
+
<td class="${m.failedRequests > 0 ? 'error' : ''}">${m.failedRequests}</td>
|
| 280 |
+
</tr>
|
| 281 |
+
<tr>
|
| 282 |
+
<td>Success Rate</td>
|
| 283 |
+
<td class="${m.successRate >= 0.95 ? 'success' : m.successRate >= 0.8 ? 'warning' : 'error'}">
|
| 284 |
+
${(m.successRate * 100).toFixed(1)}%
|
| 285 |
+
</td>
|
| 286 |
+
</tr>
|
| 287 |
+
<tr>
|
| 288 |
+
<td>Requests per Second</td>
|
| 289 |
+
<td>${m.requestsPerSecond.toFixed(2)}</td>
|
| 290 |
+
</tr>
|
| 291 |
+
<tr>
|
| 292 |
+
<td>Response Time (min)</td>
|
| 293 |
+
<td>${formatDuration(m.timings.min)}</td>
|
| 294 |
+
</tr>
|
| 295 |
+
<tr>
|
| 296 |
+
<td>Response Time (avg)</td>
|
| 297 |
+
<td>${formatDuration(m.timings.avg)}</td>
|
| 298 |
+
</tr>
|
| 299 |
+
<tr>
|
| 300 |
+
<td>Response Time (max)</td>
|
| 301 |
+
<td>${formatDuration(m.timings.max)}</td>
|
| 302 |
+
</tr>
|
| 303 |
+
<tr>
|
| 304 |
+
<td>Response Time (p95)</td>
|
| 305 |
+
<td>${formatDuration(m.timings.p95)}</td>
|
| 306 |
+
</tr>
|
| 307 |
+
<tr>
|
| 308 |
+
<td>Response Time (p99)</td>
|
| 309 |
+
<td>${formatDuration(m.timings.p99)}</td>
|
| 310 |
+
</tr>
|
| 311 |
+
</table>
|
| 312 |
+
|
| 313 |
+
${
|
| 314 |
+
m.errors.length > 0
|
| 315 |
+
? `
|
| 316 |
+
<h3>Errors</h3>
|
| 317 |
+
<table>
|
| 318 |
+
<tr>
|
| 319 |
+
<th>Error Message</th>
|
| 320 |
+
<th>Count</th>
|
| 321 |
+
</tr>
|
| 322 |
+
${m.errors
|
| 323 |
+
.map(
|
| 324 |
+
err => `
|
| 325 |
+
<tr>
|
| 326 |
+
<td>${err.message}</td>
|
| 327 |
+
<td>${err.count}</td>
|
| 328 |
+
</tr>
|
| 329 |
+
`
|
| 330 |
+
)
|
| 331 |
+
.join('')}
|
| 332 |
+
</table>
|
| 333 |
+
`
|
| 334 |
+
: ''
|
| 335 |
+
}
|
| 336 |
+
</div>
|
| 337 |
+
`
|
| 338 |
+
)
|
| 339 |
+
.join('')}
|
| 340 |
+
|
| 341 |
+
</body>
|
| 342 |
+
</html>
|
| 343 |
+
`;
|
| 344 |
+
|
| 345 |
+
fs.writeFileSync(outputPath, html, 'utf-8');
|
| 346 |
+
console.log(`\n๐ Report saved to: ${path.resolve(outputPath)}`);
|
| 347 |
+
}
|
| 348 |
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scenario 4: Admin Monitoring
|
| 3 |
+
* Simulates admin endpoints under load
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { StressTestConfig } from '../config';
|
| 7 |
+
import { HTTPClient, sleep } from '../utils';
|
| 8 |
+
import { MetricsCollector } from '../metrics';
|
| 9 |
+
|
| 10 |
+
export interface AdminMonitoringResult {
|
| 11 |
+
healthChecks: number;
|
| 12 |
+
statsChecks: number;
|
| 13 |
+
timings: any[];
|
| 14 |
+
finalHealth: any;
|
| 15 |
+
finalStats: any;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function adminMonitoringScenario(
|
| 19 |
+
config: StressTestConfig,
|
| 20 |
+
metrics: MetricsCollector,
|
| 21 |
+
expectedUsers: number,
|
| 22 |
+
expectedConversations: number,
|
| 23 |
+
expectedMessages: number
|
| 24 |
+
): Promise<AdminMonitoringResult> {
|
| 25 |
+
const scenarioName = 'Admin Monitoring';
|
| 26 |
+
const durationSeconds = Math.min(config.durationMinutes * 60, 120); // Max 2 minutes for this scenario
|
| 27 |
+
const checkIntervalMs = 5000; // Check every 5 seconds
|
| 28 |
+
|
| 29 |
+
console.log(`\n[${scenarioName}] Monitoring admin endpoints for ${durationSeconds}s...`);
|
| 30 |
+
|
| 31 |
+
metrics.startScenario(scenarioName);
|
| 32 |
+
|
| 33 |
+
const client = new HTTPClient(config);
|
| 34 |
+
let healthChecks = 0;
|
| 35 |
+
let statsChecks = 0;
|
| 36 |
+
let finalHealth: any = null;
|
| 37 |
+
let finalStats: any = null;
|
| 38 |
+
|
| 39 |
+
const startTime = Date.now();
|
| 40 |
+
|
| 41 |
+
while (Date.now() - startTime < durationSeconds * 1000) {
|
| 42 |
+
const promises: Promise<void>[] = [];
|
| 43 |
+
|
| 44 |
+
// Health check
|
| 45 |
+
promises.push(
|
| 46 |
+
client
|
| 47 |
+
.get('/api/admin/health')
|
| 48 |
+
.then(data => {
|
| 49 |
+
healthChecks++;
|
| 50 |
+
finalHealth = data;
|
| 51 |
+
|
| 52 |
+
if (config.verbose) {
|
| 53 |
+
console.log(
|
| 54 |
+
` Health: users=${data.tables.find((t: any) => t.name === 'users')?.rowCount || 0}, ` +
|
| 55 |
+
`conversations=${data.tables.find((t: any) => t.name === 'conversations')?.rowCount || 0}, ` +
|
| 56 |
+
`messages=${data.tables.find((t: any) => t.name === 'messages')?.rowCount || 0}`
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
})
|
| 60 |
+
.catch(error => {
|
| 61 |
+
console.error(` โ Health check failed:`, error.message);
|
| 62 |
+
})
|
| 63 |
+
);
|
| 64 |
+
|
| 65 |
+
// Stats check
|
| 66 |
+
promises.push(
|
| 67 |
+
client
|
| 68 |
+
.get('/api/admin/stats')
|
| 69 |
+
.then(data => {
|
| 70 |
+
statsChecks++;
|
| 71 |
+
finalStats = data;
|
| 72 |
+
|
| 73 |
+
if (config.verbose) {
|
| 74 |
+
console.log(
|
| 75 |
+
` Stats: users=${data.users?.total || 0}, ` +
|
| 76 |
+
`conversations=${data.conversations?.total || 0}, ` +
|
| 77 |
+
`messages=${data.messages?.total || 0}`
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
})
|
| 81 |
+
.catch(error => {
|
| 82 |
+
console.error(` โ Stats check failed:`, error.message);
|
| 83 |
+
})
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
await Promise.all(promises);
|
| 87 |
+
await sleep(checkIntervalMs);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const timings = client.getTimings();
|
| 91 |
+
metrics.addTimings(timings);
|
| 92 |
+
metrics.endScenario();
|
| 93 |
+
|
| 94 |
+
metrics.printScenarioSummary(scenarioName);
|
| 95 |
+
|
| 96 |
+
// Verify admin data
|
| 97 |
+
console.log(`\n[${scenarioName}] Verifying admin endpoints...`);
|
| 98 |
+
if (finalHealth && finalStats) {
|
| 99 |
+
const healthUsers = finalHealth.tables.find((t: any) => t.name === 'users')?.rowCount || 0;
|
| 100 |
+
const healthConvos =
|
| 101 |
+
finalHealth.tables.find((t: any) => t.name === 'conversations')?.rowCount || 0;
|
| 102 |
+
const healthMessages = finalHealth.tables.find((t: any) => t.name === 'messages')?.rowCount || 0;
|
| 103 |
+
|
| 104 |
+
const statsUsers = finalStats.users?.total || 0;
|
| 105 |
+
const statsConvos = finalStats.conversations?.total || 0;
|
| 106 |
+
const statsMessages = finalStats.messages?.total || 0;
|
| 107 |
+
|
| 108 |
+
console.log(` Health API:`);
|
| 109 |
+
console.log(` Users: ${healthUsers} (expected: ${expectedUsers})`);
|
| 110 |
+
console.log(` Conversations: ${healthConvos} (expected: ${expectedConversations})`);
|
| 111 |
+
console.log(` Messages: ${healthMessages} (expected: โฅ${expectedMessages})`);
|
| 112 |
+
|
| 113 |
+
console.log(` Stats API:`);
|
| 114 |
+
console.log(` Users: ${statsUsers}`);
|
| 115 |
+
console.log(` Conversations: ${statsConvos}`);
|
| 116 |
+
console.log(` Messages: ${statsMessages}`);
|
| 117 |
+
|
| 118 |
+
// Verify consistency
|
| 119 |
+
if (healthUsers === statsUsers && healthConvos === statsConvos) {
|
| 120 |
+
console.log(` โ Health and Stats APIs are consistent`);
|
| 121 |
+
} else {
|
| 122 |
+
console.log(` โ ๏ธ WARNING: Health and Stats APIs show different values`);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Verify data matches expectations
|
| 126 |
+
if (
|
| 127 |
+
healthUsers >= expectedUsers &&
|
| 128 |
+
healthConvos >= expectedConversations &&
|
| 129 |
+
healthMessages >= expectedMessages
|
| 130 |
+
) {
|
| 131 |
+
console.log(` โ Database growth verified`);
|
| 132 |
+
} else {
|
| 133 |
+
console.log(` โ ๏ธ WARNING: Database counts lower than expected`);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
return { healthChecks, statsChecks, timings, finalHealth, finalStats };
|
| 138 |
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scenario 3: Concurrent Messages
|
| 3 |
+
* Simulates concurrent message sending (LLM calls)
|
| 4 |
+
* WARNING: This will be slow due to LLM response times (60-90s each)
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { StressTestConfig } from '../config';
|
| 8 |
+
import { HTTPClient, authHeader, generateMessage } from '../utils';
|
| 9 |
+
import { MetricsCollector } from '../metrics';
|
| 10 |
+
|
| 11 |
+
export interface ConcurrentMessagesResult {
|
| 12 |
+
messagesSent: number;
|
| 13 |
+
timings: any[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export async function concurrentMessagesScenario(
|
| 17 |
+
config: StressTestConfig,
|
| 18 |
+
metrics: MetricsCollector,
|
| 19 |
+
conversations: Array<{ id: string; username: string }>
|
| 20 |
+
): Promise<ConcurrentMessagesResult> {
|
| 21 |
+
const scenarioName = 'Concurrent Messages';
|
| 22 |
+
const maxConcurrent = Math.min(20, conversations.length); // Limit to 20 concurrent LLM calls
|
| 23 |
+
|
| 24 |
+
console.log(`\n[${scenarioName}] Sending ${maxConcurrent} concurrent messages...`);
|
| 25 |
+
console.log(` โ ๏ธ WARNING: This will be slow (60-90s per LLM call)`);
|
| 26 |
+
|
| 27 |
+
metrics.startScenario(scenarioName);
|
| 28 |
+
|
| 29 |
+
const client = new HTTPClient(config);
|
| 30 |
+
const promises: Promise<void>[] = [];
|
| 31 |
+
let messagesSent = 0;
|
| 32 |
+
|
| 33 |
+
// Select random subset of conversations
|
| 34 |
+
const selectedConvos = conversations
|
| 35 |
+
.sort(() => Math.random() - 0.5)
|
| 36 |
+
.slice(0, maxConcurrent);
|
| 37 |
+
|
| 38 |
+
for (let i = 0; i < selectedConvos.length; i++) {
|
| 39 |
+
const { id: conversationId, username } = selectedConvos[i];
|
| 40 |
+
const messageText = generateMessage();
|
| 41 |
+
|
| 42 |
+
const promise = client
|
| 43 |
+
.post(
|
| 44 |
+
`/api/conversations/${conversationId}/message`,
|
| 45 |
+
{
|
| 46 |
+
messages: [
|
| 47 |
+
{
|
| 48 |
+
id: `stress-test-${Date.now()}-${i}`,
|
| 49 |
+
role: 'user',
|
| 50 |
+
parts: [{ type: 'text', text: messageText }],
|
| 51 |
+
metadata: { speaker: 'student' },
|
| 52 |
+
},
|
| 53 |
+
],
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
Authorization: authHeader(username, config.password),
|
| 57 |
+
'Content-Type': 'application/json',
|
| 58 |
+
},
|
| 59 |
+
90000 // 90s timeout for LLM
|
| 60 |
+
)
|
| 61 |
+
.then(() => {
|
| 62 |
+
messagesSent++;
|
| 63 |
+
console.log(` โ Message ${messagesSent}/${maxConcurrent} completed`);
|
| 64 |
+
})
|
| 65 |
+
.catch(error => {
|
| 66 |
+
console.error(` โ Failed to send message ${i + 1}:`, error.message);
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
promises.push(promise);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
await Promise.all(promises);
|
| 73 |
+
|
| 74 |
+
const timings = client.getTimings();
|
| 75 |
+
metrics.addTimings(timings);
|
| 76 |
+
metrics.endScenario();
|
| 77 |
+
|
| 78 |
+
metrics.printScenarioSummary(scenarioName);
|
| 79 |
+
|
| 80 |
+
return { messagesSent, timings };
|
| 81 |
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scenario 2: Conversation Creation
|
| 3 |
+
* Simulates users creating multiple conversations
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { StressTestConfig, STUDENT_PERSONALITIES, COACH_TYPES } from '../config';
|
| 7 |
+
import { HTTPClient, authHeader, randomElement } from '../utils';
|
| 8 |
+
import { MetricsCollector } from '../metrics';
|
| 9 |
+
|
| 10 |
+
export interface ConversationFlowResult {
|
| 11 |
+
conversations: Array<{ id: string; username: string }>;
|
| 12 |
+
timings: any[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function conversationFlowScenario(
|
| 16 |
+
config: StressTestConfig,
|
| 17 |
+
metrics: MetricsCollector,
|
| 18 |
+
usernames: string[]
|
| 19 |
+
): Promise<ConversationFlowResult> {
|
| 20 |
+
const scenarioName = 'Conversation Creation';
|
| 21 |
+
const conversationsPerUser = 2;
|
| 22 |
+
const totalConversations = usernames.length * conversationsPerUser;
|
| 23 |
+
|
| 24 |
+
console.log(
|
| 25 |
+
`\n[${scenarioName}] Creating ${totalConversations} conversations (${conversationsPerUser} per user)...`
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
metrics.startScenario(scenarioName);
|
| 29 |
+
|
| 30 |
+
const client = new HTTPClient(config);
|
| 31 |
+
const conversations: Array<{ id: string; username: string }> = [];
|
| 32 |
+
const promises: Promise<void>[] = [];
|
| 33 |
+
|
| 34 |
+
for (const username of usernames) {
|
| 35 |
+
for (let i = 0; i < conversationsPerUser; i++) {
|
| 36 |
+
const studentPromptId = randomElement(STUDENT_PERSONALITIES);
|
| 37 |
+
const coachPromptId = randomElement(COACH_TYPES);
|
| 38 |
+
|
| 39 |
+
const promise = client
|
| 40 |
+
.post(
|
| 41 |
+
'/api/conversations/create',
|
| 42 |
+
{
|
| 43 |
+
studentPromptId,
|
| 44 |
+
coachPromptId,
|
| 45 |
+
include3ConversationSummary: false,
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
Authorization: authHeader(username, config.password),
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
.then(data => {
|
| 52 |
+
const conversationId = data.conversation?.id;
|
| 53 |
+
if (conversationId) {
|
| 54 |
+
conversations.push({ id: conversationId, username });
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
.catch(error => {
|
| 58 |
+
console.error(` โ Failed to create conversation for ${username}:`, error.message);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
promises.push(promise);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
await Promise.all(promises);
|
| 66 |
+
|
| 67 |
+
const timings = client.getTimings();
|
| 68 |
+
metrics.addTimings(timings);
|
| 69 |
+
metrics.endScenario();
|
| 70 |
+
|
| 71 |
+
metrics.printScenarioSummary(scenarioName);
|
| 72 |
+
|
| 73 |
+
return { conversations, timings };
|
| 74 |
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scenario 1: User Registration
|
| 3 |
+
* Simulates mass user registration
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { StressTestConfig } from '../config';
|
| 7 |
+
import { HTTPClient, generateUsername, authHeader } from '../utils';
|
| 8 |
+
import { MetricsCollector } from '../metrics';
|
| 9 |
+
|
| 10 |
+
export interface UserRegistrationResult {
|
| 11 |
+
usernames: string[];
|
| 12 |
+
timings: any[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function userRegistrationScenario(
|
| 16 |
+
config: StressTestConfig,
|
| 17 |
+
metrics: MetricsCollector
|
| 18 |
+
): Promise<UserRegistrationResult> {
|
| 19 |
+
const scenarioName = 'User Registration';
|
| 20 |
+
console.log(`\n[${scenarioName}] Registering ${config.concurrentUsers} users...`);
|
| 21 |
+
|
| 22 |
+
metrics.startScenario(scenarioName);
|
| 23 |
+
|
| 24 |
+
const client = new HTTPClient(config);
|
| 25 |
+
const usernames: string[] = [];
|
| 26 |
+
const promises: Promise<void>[] = [];
|
| 27 |
+
|
| 28 |
+
// Register users concurrently
|
| 29 |
+
for (let i = 0; i < config.concurrentUsers; i++) {
|
| 30 |
+
const username = generateUsername();
|
| 31 |
+
usernames.push(username);
|
| 32 |
+
|
| 33 |
+
const promise = client
|
| 34 |
+
.post('/api/auth/register', { username })
|
| 35 |
+
.then(() => {
|
| 36 |
+
if (config.verbose && i % 10 === 0) {
|
| 37 |
+
console.log(` Registered ${i + 1}/${config.concurrentUsers} users`);
|
| 38 |
+
}
|
| 39 |
+
})
|
| 40 |
+
.catch(error => {
|
| 41 |
+
console.error(` โ Failed to register ${username}:`, error.message);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
promises.push(promise);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
await Promise.all(promises);
|
| 48 |
+
|
| 49 |
+
const timings = client.getTimings();
|
| 50 |
+
metrics.addTimings(timings);
|
| 51 |
+
metrics.endScenario();
|
| 52 |
+
|
| 53 |
+
metrics.printScenarioSummary(scenarioName);
|
| 54 |
+
|
| 55 |
+
return { usernames, timings };
|
| 56 |
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Utility Functions for Stress Testing
|
| 3 |
+
* HTTP client, authentication, random data generation
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { StressTestConfig } from './config';
|
| 7 |
+
|
| 8 |
+
export interface RequestTiming {
|
| 9 |
+
url: string;
|
| 10 |
+
method: string;
|
| 11 |
+
startTime: number;
|
| 12 |
+
endTime: number;
|
| 13 |
+
duration: number;
|
| 14 |
+
status: number;
|
| 15 |
+
success: boolean;
|
| 16 |
+
error?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Generate Basic Auth header
|
| 21 |
+
*/
|
| 22 |
+
export function authHeader(username: string, password: string): string {
|
| 23 |
+
return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Generate unique test username
|
| 28 |
+
*/
|
| 29 |
+
export function generateUsername(prefix: string = 'stress_test'): string {
|
| 30 |
+
const timestamp = Date.now();
|
| 31 |
+
const random = Math.random().toString(36).substring(7);
|
| 32 |
+
return `${prefix}_${timestamp}_${random}`;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Generate random Chinese message
|
| 37 |
+
*/
|
| 38 |
+
export function generateMessage(): string {
|
| 39 |
+
const messages = [
|
| 40 |
+
'ไฝ ๅฅฝ',
|
| 41 |
+
'ๆ้่ฆๅธฎๅฉ',
|
| 42 |
+
'่ฟไธชไฝไธๅฅฝ้พ',
|
| 43 |
+
'ๆไธๆ็ฝ',
|
| 44 |
+
'ๅฏไปฅๅ่งฃ้ไธๆฌกๅ๏ผ',
|
| 45 |
+
'่ฐข่ฐข่ๅธ',
|
| 46 |
+
'ๆๆ็ฝไบ',
|
| 47 |
+
'่ฟๆ้ฎ้ข',
|
| 48 |
+
];
|
| 49 |
+
return messages[Math.floor(Math.random() * messages.length)];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* HTTP client with timing and retry logic
|
| 54 |
+
*/
|
| 55 |
+
export class HTTPClient {
|
| 56 |
+
private config: StressTestConfig;
|
| 57 |
+
private timings: RequestTiming[] = [];
|
| 58 |
+
|
| 59 |
+
constructor(config: StressTestConfig) {
|
| 60 |
+
this.config = config;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Make HTTP request with timing
|
| 65 |
+
*/
|
| 66 |
+
async request(
|
| 67 |
+
method: string,
|
| 68 |
+
path: string,
|
| 69 |
+
options: {
|
| 70 |
+
headers?: Record<string, string>;
|
| 71 |
+
body?: any;
|
| 72 |
+
timeout?: number;
|
| 73 |
+
retries?: number;
|
| 74 |
+
} = {}
|
| 75 |
+
): Promise<{ data: any; timing: RequestTiming }> {
|
| 76 |
+
const url = `${this.config.baseURL}${path}`;
|
| 77 |
+
const startTime = Date.now();
|
| 78 |
+
const timeout = options.timeout || 30000; // 30s default
|
| 79 |
+
const retries = options.retries || 0;
|
| 80 |
+
|
| 81 |
+
let lastError: Error | null = null;
|
| 82 |
+
|
| 83 |
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
| 84 |
+
try {
|
| 85 |
+
const controller = new AbortController();
|
| 86 |
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
| 87 |
+
|
| 88 |
+
const response = await fetch(url, {
|
| 89 |
+
method,
|
| 90 |
+
headers: {
|
| 91 |
+
'Content-Type': 'application/json',
|
| 92 |
+
...options.headers,
|
| 93 |
+
},
|
| 94 |
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
| 95 |
+
signal: controller.signal,
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
clearTimeout(timeoutId);
|
| 99 |
+
|
| 100 |
+
const endTime = Date.now();
|
| 101 |
+
const duration = endTime - startTime;
|
| 102 |
+
|
| 103 |
+
let data: any;
|
| 104 |
+
const contentType = response.headers.get('content-type');
|
| 105 |
+
if (contentType?.includes('application/json')) {
|
| 106 |
+
data = await response.json();
|
| 107 |
+
} else {
|
| 108 |
+
data = await response.text();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const timing: RequestTiming = {
|
| 112 |
+
url,
|
| 113 |
+
method,
|
| 114 |
+
startTime,
|
| 115 |
+
endTime,
|
| 116 |
+
duration,
|
| 117 |
+
status: response.status,
|
| 118 |
+
success: response.ok,
|
| 119 |
+
error: response.ok ? undefined : `HTTP ${response.status}`,
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
this.timings.push(timing);
|
| 123 |
+
|
| 124 |
+
if (!response.ok) {
|
| 125 |
+
throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return { data, timing };
|
| 129 |
+
} catch (error: any) {
|
| 130 |
+
lastError = error;
|
| 131 |
+
|
| 132 |
+
if (attempt < retries) {
|
| 133 |
+
// Exponential backoff
|
| 134 |
+
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
|
| 135 |
+
if (this.config.verbose) {
|
| 136 |
+
console.log(
|
| 137 |
+
` Retry ${attempt + 1}/${retries} after ${delay}ms for ${method} ${path}`
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
await sleep(delay);
|
| 141 |
+
continue;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// Final attempt failed
|
| 145 |
+
const endTime = Date.now();
|
| 146 |
+
const duration = endTime - startTime;
|
| 147 |
+
|
| 148 |
+
const timing: RequestTiming = {
|
| 149 |
+
url,
|
| 150 |
+
method,
|
| 151 |
+
startTime,
|
| 152 |
+
endTime,
|
| 153 |
+
duration,
|
| 154 |
+
status: 0,
|
| 155 |
+
success: false,
|
| 156 |
+
error: error.message,
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
this.timings.push(timing);
|
| 160 |
+
|
| 161 |
+
throw error;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
throw lastError!;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* GET request
|
| 170 |
+
*/
|
| 171 |
+
async get(path: string, headers?: Record<string, string>): Promise<any> {
|
| 172 |
+
const { data } = await this.request('GET', path, { headers });
|
| 173 |
+
return data;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* POST request
|
| 178 |
+
*/
|
| 179 |
+
async post(
|
| 180 |
+
path: string,
|
| 181 |
+
body: any,
|
| 182 |
+
headers?: Record<string, string>,
|
| 183 |
+
timeout?: number
|
| 184 |
+
): Promise<any> {
|
| 185 |
+
const { data } = await this.request('POST', path, { headers, body, timeout });
|
| 186 |
+
return data;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* PUT request
|
| 191 |
+
*/
|
| 192 |
+
async put(
|
| 193 |
+
path: string,
|
| 194 |
+
body: any,
|
| 195 |
+
headers?: Record<string, string>
|
| 196 |
+
): Promise<any> {
|
| 197 |
+
const { data } = await this.request('PUT', path, { headers, body });
|
| 198 |
+
return data;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/**
|
| 202 |
+
* DELETE request
|
| 203 |
+
*/
|
| 204 |
+
async delete(path: string, headers?: Record<string, string>): Promise<any> {
|
| 205 |
+
const { data } = await this.request('DELETE', path, { headers });
|
| 206 |
+
return data;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/**
|
| 210 |
+
* Get all request timings
|
| 211 |
+
*/
|
| 212 |
+
getTimings(): RequestTiming[] {
|
| 213 |
+
return this.timings;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* Clear timings
|
| 218 |
+
*/
|
| 219 |
+
clearTimings(): void {
|
| 220 |
+
this.timings = [];
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* Sleep utility
|
| 226 |
+
*/
|
| 227 |
+
export function sleep(ms: number): Promise<void> {
|
| 228 |
+
return new Promise(resolve => setTimeout(resolve, ms));
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/**
|
| 232 |
+
* Calculate percentile
|
| 233 |
+
*/
|
| 234 |
+
export function percentile(values: number[], p: number): number {
|
| 235 |
+
if (values.length === 0) return 0;
|
| 236 |
+
const sorted = values.slice().sort((a, b) => a - b);
|
| 237 |
+
const index = Math.ceil(sorted.length * p) - 1;
|
| 238 |
+
return sorted[Math.max(0, index)];
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* Format duration in ms to human readable
|
| 243 |
+
*/
|
| 244 |
+
export function formatDuration(ms: number): string {
|
| 245 |
+
if (ms < 1000) return `${ms}ms`;
|
| 246 |
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
| 247 |
+
return `${(ms / 60000).toFixed(1)}m`;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Random element from array
|
| 252 |
+
*/
|
| 253 |
+
export function randomElement<T>(arr: T[]): T {
|
| 254 |
+
return arr[Math.floor(Math.random() * arr.length)];
|
| 255 |
+
}
|