dripfeed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/dist/cli.cjs +270 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +273 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +187 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +187 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/runner-ByEGj869.mjs +647 -0
- package/dist/runner-ByEGj869.mjs.map +1 -0
- package/dist/runner-Dc1JRBps.cjs +758 -0
- package/package.json +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dripfeed contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# dripfeed
|
|
2
|
+
|
|
3
|
+
Soak test your API. One request every few seconds, for hours. Logs every failure.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dripfeed)
|
|
6
|
+
[](https://www.npmjs.com/package/dripfeed)
|
|
7
|
+
[](https://github.com/ph33nx/dripfeed/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/ph33nx/dripfeed/blob/main/LICENSE)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](https://bun.sh)
|
|
11
|
+
|
|
12
|
+
**dripfeed** hits your API endpoints at regular intervals (every 1 to 30 seconds) and logs every response to a local SQLite database. Run it for hours or days to catch intermittent failures, latency degradation, and silent outages that load tests and uptime pings miss. This is soak testing: sustained, low-volume traffic over long periods to surface problems that only appear under real-world conditions.
|
|
13
|
+
|
|
14
|
+
## When to use dripfeed
|
|
15
|
+
|
|
16
|
+
| Scenario | Tool |
|
|
17
|
+
|----------|------|
|
|
18
|
+
| "Can my server handle 10,000 concurrent users?" | k6, artillery, vegeta |
|
|
19
|
+
| "Is my API up right now?" | Uptime Kuma, Better Stack, Pingdom |
|
|
20
|
+
| "Did my API silently degrade overnight?" | **dripfeed** |
|
|
21
|
+
| "Does my API return errors under sustained real-world usage?" | **dripfeed** |
|
|
22
|
+
| "I need a queryable history of every API response for the last 24 hours" | **dripfeed** |
|
|
23
|
+
|
|
24
|
+
## Why dripfeed?
|
|
25
|
+
|
|
26
|
+
- **Zero infrastructure.** No Docker, no Grafana, no InfluxDB. One CLI command, one SQLite file
|
|
27
|
+
- **SQLite-first.** Every request logged to a queryable database. `SELECT * FROM results WHERE status >= 500`
|
|
28
|
+
- **Multi-endpoint rotation.** Weighted random or round-robin across your full API surface
|
|
29
|
+
- **POST bodies + headers.** Not just GET pings. Test real API payloads with auth tokens
|
|
30
|
+
- **Error body capture.** Logs the full response body on non-2xx so you know *why* it failed
|
|
31
|
+
- **CI/CD ready.** Threshold assertions with non-zero exit codes. Fail the pipeline if p95 > 500ms
|
|
32
|
+
- **Runtime-agnostic.** Works on Node.js (20+), Bun, and Deno. Bun gets zero-dep SQLite via `bun:sqlite`
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Generate a starter config
|
|
38
|
+
npx dripfeed init
|
|
39
|
+
|
|
40
|
+
# Edit dripfeed.config.ts with your endpoints
|
|
41
|
+
|
|
42
|
+
# Run indefinitely (Ctrl+C to stop)
|
|
43
|
+
npx dripfeed run
|
|
44
|
+
|
|
45
|
+
# Run for a fixed duration
|
|
46
|
+
npx dripfeed run --duration 2h
|
|
47
|
+
|
|
48
|
+
# Run in CI with thresholds
|
|
49
|
+
npx dripfeed run --duration 10m --quiet
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Global
|
|
56
|
+
npm install -g dripfeed
|
|
57
|
+
|
|
58
|
+
# Project dependency
|
|
59
|
+
npm install dripfeed
|
|
60
|
+
|
|
61
|
+
# Or with other package managers
|
|
62
|
+
pnpm add dripfeed
|
|
63
|
+
yarn add dripfeed
|
|
64
|
+
bun add dripfeed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Create a `dripfeed.config.ts` (or `.json`, `.yaml`, `.toml`):
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import type { DripfeedConfig } from 'dripfeed';
|
|
73
|
+
|
|
74
|
+
const config: DripfeedConfig = {
|
|
75
|
+
interval: '3s',
|
|
76
|
+
timeout: '30s',
|
|
77
|
+
storage: 'sqlite',
|
|
78
|
+
rotation: 'weighted-random',
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: 'Bearer ${API_TOKEN}',
|
|
81
|
+
},
|
|
82
|
+
endpoints: [
|
|
83
|
+
{
|
|
84
|
+
name: 'get-users',
|
|
85
|
+
url: 'https://api.example.com/v1/users',
|
|
86
|
+
weight: 2,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'create-order',
|
|
90
|
+
url: 'https://api.example.com/v1/orders',
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: { product_id: 'sku-123', quantity: 1 },
|
|
94
|
+
weight: 1,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
thresholds: {
|
|
98
|
+
error_rate: '< 1%',
|
|
99
|
+
p95: '< 500ms',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default config;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Environment variables are interpolated via `${VAR}` syntax from `process.env`.
|
|
107
|
+
|
|
108
|
+
## CLI
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Usage: dripfeed <command> [options]
|
|
112
|
+
|
|
113
|
+
Commands:
|
|
114
|
+
run Start a soak test
|
|
115
|
+
init Generate a starter config file
|
|
116
|
+
report Generate a report from an existing SQLite database
|
|
117
|
+
export Export results to CSV or JSON
|
|
118
|
+
|
|
119
|
+
Run options:
|
|
120
|
+
--duration, -d Run duration (e.g. 30s, 10m, 2h). Omit for indefinite
|
|
121
|
+
--interval, -i Override config interval
|
|
122
|
+
--db SQLite database path (default: dripfeed-results.db)
|
|
123
|
+
--report, -r Report format: console, json, markdown
|
|
124
|
+
--output, -o Report output file path
|
|
125
|
+
--quiet, -q Suppress live console output
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
> **Note:** Using `--report json` or `--report markdown` automatically suppresses live console output so the report output is clean and parseable.
|
|
129
|
+
|
|
130
|
+
## Live Output
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
dripfeed v0.1.0 — every 3s | Ctrl+C to stop
|
|
134
|
+
|
|
135
|
+
✓ #1 get-users 200 142ms | ok:1 fail:0 (100.0%)
|
|
136
|
+
✓ #2 create-order 201 387ms | ok:2 fail:0 (100.0%)
|
|
137
|
+
✗ #3 get-users 500 891ms | ok:2 fail:1 (66.7%) | Internal Server Error
|
|
138
|
+
✓ #4 create-order 201 245ms | ok:3 fail:1 (75.0%)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
On Ctrl+C, prints a summary with per-endpoint latency percentiles, error counts, and threshold pass/fail results.
|
|
142
|
+
|
|
143
|
+
## Library API
|
|
144
|
+
|
|
145
|
+
Use dripfeed programmatically in any Node.js/Bun application:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { createSoakTest, parseConfig, createConsoleReporter } from 'dripfeed';
|
|
149
|
+
|
|
150
|
+
// parseConfig validates and applies defaults to a raw config object
|
|
151
|
+
const config = parseConfig({
|
|
152
|
+
interval: '3s',
|
|
153
|
+
endpoints: [
|
|
154
|
+
{ name: 'health', url: 'https://api.example.com/health' },
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const test = createSoakTest(config, [createConsoleReporter()]);
|
|
159
|
+
|
|
160
|
+
// Run for a fixed duration
|
|
161
|
+
const stats = await test.run({ duration: '10m' });
|
|
162
|
+
console.log(`Uptime: ${stats.uptime_pct}%`);
|
|
163
|
+
|
|
164
|
+
// Or start/stop manually
|
|
165
|
+
await test.start();
|
|
166
|
+
// ... later
|
|
167
|
+
const stats = await test.stop();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Express.js
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import express from 'express';
|
|
174
|
+
import { createSoakTest, createMemoryStorage } from 'dripfeed';
|
|
175
|
+
|
|
176
|
+
const app = express();
|
|
177
|
+
|
|
178
|
+
// Start soak test alongside your server
|
|
179
|
+
const test = createSoakTest({
|
|
180
|
+
interval: '10s',
|
|
181
|
+
storage: 'memory',
|
|
182
|
+
endpoints: [
|
|
183
|
+
{ name: 'self-health', url: 'http://localhost:3000/health' },
|
|
184
|
+
],
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
test.start();
|
|
188
|
+
|
|
189
|
+
app.get('/health', (req, res) => res.json({ ok: true }));
|
|
190
|
+
app.get('/soak-status', async (req, res) => {
|
|
191
|
+
const stats = await test.stop();
|
|
192
|
+
res.json(stats);
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Next.js (API Route)
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// app/api/soak/route.ts
|
|
200
|
+
import { createSoakTest } from 'dripfeed';
|
|
201
|
+
|
|
202
|
+
export async function POST() {
|
|
203
|
+
const test = createSoakTest({
|
|
204
|
+
interval: '1s',
|
|
205
|
+
storage: 'memory',
|
|
206
|
+
endpoints: [
|
|
207
|
+
{ name: 'api', url: 'https://api.example.com/health' },
|
|
208
|
+
],
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const stats = await test.run({ duration: '30s' });
|
|
212
|
+
return Response.json(stats);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Hono
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
import { Hono } from 'hono';
|
|
220
|
+
import { createSoakTest } from 'dripfeed';
|
|
221
|
+
|
|
222
|
+
const app = new Hono();
|
|
223
|
+
|
|
224
|
+
app.post('/soak', async (c) => {
|
|
225
|
+
const test = createSoakTest({
|
|
226
|
+
interval: '2s',
|
|
227
|
+
storage: 'memory',
|
|
228
|
+
endpoints: [
|
|
229
|
+
{ name: 'health', url: 'https://api.example.com/health' },
|
|
230
|
+
],
|
|
231
|
+
}, []);
|
|
232
|
+
|
|
233
|
+
const stats = await test.run({ duration: '1m' });
|
|
234
|
+
return c.json(stats);
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Query Results
|
|
239
|
+
|
|
240
|
+
The SQLite database is the primary artifact. Query it after a run:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
# Error count by endpoint
|
|
244
|
+
sqlite3 dripfeed-results.db "
|
|
245
|
+
SELECT endpoint, COUNT(*) as errors
|
|
246
|
+
FROM results WHERE status >= 400 OR status IS NULL
|
|
247
|
+
GROUP BY endpoint ORDER BY errors DESC"
|
|
248
|
+
|
|
249
|
+
# Latency over time (1-minute buckets)
|
|
250
|
+
sqlite3 dripfeed-results.db "
|
|
251
|
+
SELECT strftime('%H:%M', timestamp) as minute,
|
|
252
|
+
ROUND(AVG(duration_ms)) as avg_ms, MAX(duration_ms) as max_ms
|
|
253
|
+
FROM results GROUP BY minute ORDER BY minute"
|
|
254
|
+
|
|
255
|
+
# All error response bodies
|
|
256
|
+
sqlite3 dripfeed-results.db "
|
|
257
|
+
SELECT timestamp, endpoint, status, response_body
|
|
258
|
+
FROM results WHERE status >= 400 OR status IS NULL"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Reports
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
# Generate from existing database
|
|
265
|
+
npx dripfeed report --db dripfeed-results.db --format json --output report.json
|
|
266
|
+
npx dripfeed report --format markdown --output report.md
|
|
267
|
+
|
|
268
|
+
# Export raw data
|
|
269
|
+
npx dripfeed export --format csv --output results.csv
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## CI/CD Integration
|
|
273
|
+
|
|
274
|
+
### GitHub Actions
|
|
275
|
+
|
|
276
|
+
```yaml
|
|
277
|
+
- name: Soak test staging
|
|
278
|
+
run: npx dripfeed run --duration 10m --quiet
|
|
279
|
+
env:
|
|
280
|
+
API_TOKEN: ${{ secrets.API_TOKEN }}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Threshold failures produce a non-zero exit code, failing the pipeline automatically.
|
|
284
|
+
|
|
285
|
+
## Thresholds
|
|
286
|
+
|
|
287
|
+
Define pass/fail criteria in your config:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
thresholds: {
|
|
291
|
+
error_rate: '< 1%', // fail if error rate exceeds 1%
|
|
292
|
+
p95: '< 500ms', // fail if p95 latency exceeds 500ms
|
|
293
|
+
p99: '< 2000ms', // fail if p99 exceeds 2 seconds
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Storage Adapters
|
|
298
|
+
|
|
299
|
+
| Adapter | Runtime | Dependencies | When to use |
|
|
300
|
+
|---------|---------|-------------|-------------|
|
|
301
|
+
| **SQLite** | Bun | Zero (`bun:sqlite` built-in) | Default on Bun |
|
|
302
|
+
| **SQLite** | Node.js | `better-sqlite3` (optional peer dep) | Default on Node |
|
|
303
|
+
| **JSON** | Any | Zero | Fallback if no SQLite available |
|
|
304
|
+
| **Memory** | Any | Zero | Tests and programmatic use |
|
|
305
|
+
|
|
306
|
+
Storage is auto-detected based on your runtime. Override with `storage: 'json'` in config.
|
|
307
|
+
|
|
308
|
+
## Configuration Reference
|
|
309
|
+
|
|
310
|
+
| Option | Type | Default | Description |
|
|
311
|
+
|--------|------|---------|-------------|
|
|
312
|
+
| `interval` | `string` | `"3s"` | Time between requests (`500ms`, `1s`, `3s`, `5s`, `30s`, `1m`) |
|
|
313
|
+
| `timeout` | `string` | `"30s"` | Request timeout |
|
|
314
|
+
| `storage` | `string` | `"sqlite"` | Storage adapter: `sqlite`, `json`, `memory` |
|
|
315
|
+
| `db` | `string` | `"dripfeed-results.db"` | SQLite database path |
|
|
316
|
+
| `rotation` | `string` | `"weighted-random"` | Endpoint selection: `weighted-random`, `round-robin`, `sequential` |
|
|
317
|
+
| `headers` | `object` | `{}` | Global headers for all requests |
|
|
318
|
+
| `endpoints` | `array` | required | Endpoint definitions (see below) |
|
|
319
|
+
| `thresholds` | `object` | none | Pass/fail criteria |
|
|
320
|
+
|
|
321
|
+
### Endpoint options
|
|
322
|
+
|
|
323
|
+
| Option | Type | Default | Description |
|
|
324
|
+
|--------|------|---------|-------------|
|
|
325
|
+
| `name` | `string` | required | Human-readable label |
|
|
326
|
+
| `url` | `string` | required | Full URL |
|
|
327
|
+
| `method` | `string` | `"GET"` | HTTP method |
|
|
328
|
+
| `headers` | `object` | `{}` | Per-endpoint headers (merged with global) |
|
|
329
|
+
| `body` | `any` | none | JSON request body |
|
|
330
|
+
| `timeout` | `string` | global timeout | Per-endpoint timeout override |
|
|
331
|
+
| `weight` | `number` | `1` | Selection probability (higher = more frequent) |
|
|
332
|
+
|
|
333
|
+
## Good to Know
|
|
334
|
+
|
|
335
|
+
- **SQLite database location:** Created in the current working directory (default: `dripfeed-results.db`). Override with `db` config option or `--db` flag.
|
|
336
|
+
- **Multiple runs append:** Subsequent runs append to the same SQLite file. Delete the `.db` file between runs for fresh results, or use a unique `--db` path per run.
|
|
337
|
+
- **Minimum interval:** 100ms enforced floor to prevent accidental DoS. The tool is designed for 1-60 second intervals.
|
|
338
|
+
- **Serverless:** Use `storage: 'memory'` in serverless environments (Vercel, Lambda) where the filesystem is ephemeral. Pass a short `duration` to stay within function timeout limits.
|
|
339
|
+
- **HTML/PDF reports:** Not yet supported. Use the HTML print-to-PDF workflow: generate markdown, render in a browser, print to PDF.
|
|
340
|
+
- **Library API:** Use `parseConfig()` to validate raw config objects before passing to `createSoakTest()`. This applies Zod defaults (`interval`, `timeout`, `rotation`, etc.) that the runner requires.
|
|
341
|
+
|
|
342
|
+
## Contributing
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
git clone https://github.com/ph33nx/dripfeed.git
|
|
346
|
+
cd dripfeed
|
|
347
|
+
bun install
|
|
348
|
+
bun run test # run tests
|
|
349
|
+
bun typecheck # type check
|
|
350
|
+
bun run check # lint + format
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## License
|
|
354
|
+
|
|
355
|
+
MIT
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const require_runner = require("./runner-Dc1JRBps.cjs");
|
|
3
|
+
let citty = require("citty");
|
|
4
|
+
//#region src/cli.ts
|
|
5
|
+
const VALID_REPORT_FORMATS = [
|
|
6
|
+
"console",
|
|
7
|
+
"json",
|
|
8
|
+
"markdown"
|
|
9
|
+
];
|
|
10
|
+
const VALID_EXPORT_FORMATS = ["csv", "json"];
|
|
11
|
+
const validateFormat = (format, valid, command) => {
|
|
12
|
+
if (!valid.includes(format)) {
|
|
13
|
+
process.stderr.write(`Unsupported format "${format}" for ${command}. Use: ${valid.join(", ")}\n`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
(0, citty.runMain)((0, citty.defineCommand)({
|
|
18
|
+
meta: {
|
|
19
|
+
name: "dripfeed",
|
|
20
|
+
version: "0.1.0",
|
|
21
|
+
description: "SQLite-native API soak testing. Drip, not firehose."
|
|
22
|
+
},
|
|
23
|
+
subCommands: {
|
|
24
|
+
run: (0, citty.defineCommand)({
|
|
25
|
+
meta: {
|
|
26
|
+
name: "run",
|
|
27
|
+
description: "Start a soak test"
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
duration: {
|
|
31
|
+
type: "string",
|
|
32
|
+
alias: "d",
|
|
33
|
+
description: "Test duration (e.g. \"30s\", \"5m\", \"2h\")"
|
|
34
|
+
},
|
|
35
|
+
interval: {
|
|
36
|
+
type: "string",
|
|
37
|
+
alias: "i",
|
|
38
|
+
description: "Request interval (e.g. \"3s\", \"500ms\")"
|
|
39
|
+
},
|
|
40
|
+
db: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "SQLite database path"
|
|
43
|
+
},
|
|
44
|
+
report: {
|
|
45
|
+
type: "string",
|
|
46
|
+
alias: "r",
|
|
47
|
+
description: "Report format: console, json, markdown",
|
|
48
|
+
default: "console"
|
|
49
|
+
},
|
|
50
|
+
output: {
|
|
51
|
+
type: "string",
|
|
52
|
+
alias: "o",
|
|
53
|
+
description: "Report output file path"
|
|
54
|
+
},
|
|
55
|
+
quiet: {
|
|
56
|
+
type: "boolean",
|
|
57
|
+
alias: "q",
|
|
58
|
+
description: "Suppress live output",
|
|
59
|
+
default: false
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
run: async ({ args }) => {
|
|
63
|
+
const reportFormat = args.report ?? "console";
|
|
64
|
+
validateFormat(reportFormat, VALID_REPORT_FORMATS, "report");
|
|
65
|
+
const overrides = {};
|
|
66
|
+
if (args.duration) overrides.duration = args.duration;
|
|
67
|
+
if (args.interval) overrides.interval = args.interval;
|
|
68
|
+
if (args.db) overrides.db = args.db;
|
|
69
|
+
let config;
|
|
70
|
+
try {
|
|
71
|
+
config = await require_runner.loadDripfeedConfig(overrides);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err && typeof err === "object" && "issues" in err) {
|
|
74
|
+
process.stderr.write("Invalid config. Run `dripfeed init` to create a starter config.\n");
|
|
75
|
+
const { issues } = err;
|
|
76
|
+
process.stderr.write(`Details: ${JSON.stringify(issues, null, 2)}\n`);
|
|
77
|
+
} else process.stderr.write(`Config error: ${err instanceof Error ? err.message : err}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const reporters = [];
|
|
81
|
+
const shouldQuiet = args.quiet || reportFormat !== "console";
|
|
82
|
+
if (!shouldQuiet) reporters.push(require_runner.createConsoleReporter());
|
|
83
|
+
if (reportFormat === "json") reporters.push(require_runner.createJsonReporter(args.output));
|
|
84
|
+
else if (reportFormat === "markdown") reporters.push(require_runner.createMarkdownReporter(args.output));
|
|
85
|
+
if (!shouldQuiet) {
|
|
86
|
+
const interval = config.interval ?? "3s";
|
|
87
|
+
const duration = args.duration ? ` for ${args.duration}` : "";
|
|
88
|
+
process.stdout.write(`\ndripfeed v0.1.0 — every ${interval}${duration} | Ctrl+C to stop\n\n`);
|
|
89
|
+
}
|
|
90
|
+
if ((await require_runner.createSoakTest(config, reporters).run({ duration: args.duration })).thresholds?.some((t) => !t.passed)) process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
init: (0, citty.defineCommand)({
|
|
94
|
+
meta: {
|
|
95
|
+
name: "init",
|
|
96
|
+
description: "Generate a starter dripfeed config file"
|
|
97
|
+
},
|
|
98
|
+
args: { format: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Config format: ts, json",
|
|
101
|
+
default: "ts"
|
|
102
|
+
} },
|
|
103
|
+
run: async ({ args }) => {
|
|
104
|
+
const { writeFile, access } = await import("node:fs/promises");
|
|
105
|
+
const format = args.format ?? "ts";
|
|
106
|
+
if (format !== "ts" && format !== "json") {
|
|
107
|
+
process.stderr.write(`Unsupported format "${format}". Use: ts, json\n`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const filename = format === "ts" ? "dripfeed.config.ts" : "dripfeed.config.json";
|
|
111
|
+
try {
|
|
112
|
+
await access(filename);
|
|
113
|
+
process.stderr.write(`${filename} already exists. Delete it first or use a different format.\n`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
} catch {}
|
|
116
|
+
if (format === "ts") await writeFile(filename, `import type { DripfeedConfig } from 'dripfeed';
|
|
117
|
+
|
|
118
|
+
const config: DripfeedConfig = {
|
|
119
|
+
\tinterval: '3s',
|
|
120
|
+
\ttimeout: '30s',
|
|
121
|
+
\tstorage: 'sqlite',
|
|
122
|
+
\trotation: 'weighted-random',
|
|
123
|
+
\tendpoints: [
|
|
124
|
+
\t\t{
|
|
125
|
+
\t\t\tname: 'health',
|
|
126
|
+
\t\t\turl: 'https://api.example.com/health',
|
|
127
|
+
\t\t},
|
|
128
|
+
\t\t{
|
|
129
|
+
\t\t\tname: 'users',
|
|
130
|
+
\t\t\turl: 'https://api.example.com/v1/users',
|
|
131
|
+
\t\t\tweight: 3,
|
|
132
|
+
\t\t},
|
|
133
|
+
\t],
|
|
134
|
+
\tthresholds: {
|
|
135
|
+
\t\terror_rate: '< 1%',
|
|
136
|
+
\t\tp95: '< 500ms',
|
|
137
|
+
\t},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default config;
|
|
141
|
+
`);
|
|
142
|
+
else await writeFile(filename, JSON.stringify({
|
|
143
|
+
interval: "3s",
|
|
144
|
+
timeout: "30s",
|
|
145
|
+
storage: "sqlite",
|
|
146
|
+
rotation: "weighted-random",
|
|
147
|
+
endpoints: [{
|
|
148
|
+
name: "health",
|
|
149
|
+
url: "https://api.example.com/health"
|
|
150
|
+
}, {
|
|
151
|
+
name: "users",
|
|
152
|
+
url: "https://api.example.com/v1/users",
|
|
153
|
+
weight: 3
|
|
154
|
+
}],
|
|
155
|
+
thresholds: {
|
|
156
|
+
error_rate: "< 1%",
|
|
157
|
+
p95: "< 500ms"
|
|
158
|
+
}
|
|
159
|
+
}, null, 2));
|
|
160
|
+
process.stdout.write(`Created ${filename}\n`);
|
|
161
|
+
}
|
|
162
|
+
}),
|
|
163
|
+
report: (0, citty.defineCommand)({
|
|
164
|
+
meta: {
|
|
165
|
+
name: "report",
|
|
166
|
+
description: "Generate a report from an existing SQLite database"
|
|
167
|
+
},
|
|
168
|
+
args: {
|
|
169
|
+
db: {
|
|
170
|
+
type: "string",
|
|
171
|
+
description: "SQLite database path",
|
|
172
|
+
default: "dripfeed-results.db"
|
|
173
|
+
},
|
|
174
|
+
format: {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "Report format: console, json, markdown",
|
|
177
|
+
default: "console"
|
|
178
|
+
},
|
|
179
|
+
output: {
|
|
180
|
+
type: "string",
|
|
181
|
+
alias: "o",
|
|
182
|
+
description: "Output file path"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
run: async ({ args }) => {
|
|
186
|
+
const format = args.format ?? "console";
|
|
187
|
+
validateFormat(format, VALID_REPORT_FORMATS, "report");
|
|
188
|
+
const storage = require_runner.createSqliteStorage(args.db ?? "dripfeed-results.db");
|
|
189
|
+
await storage.init();
|
|
190
|
+
const results = await storage.getAll();
|
|
191
|
+
await storage.close();
|
|
192
|
+
if (results.length === 0) {
|
|
193
|
+
process.stdout.write("No results found in database.\n");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const stats = require_runner.computeStats(results, new Date(results[0]?.timestamp ?? Date.now()), void 0, new Date(results[results.length - 1]?.timestamp ?? Date.now()));
|
|
197
|
+
if (format === "console") require_runner.createConsoleReporter().onComplete(stats);
|
|
198
|
+
else if (format === "json") require_runner.createJsonReporter(args.output).onComplete(stats);
|
|
199
|
+
else if (format === "markdown") require_runner.createMarkdownReporter(args.output).onComplete(stats);
|
|
200
|
+
if (args.output) process.stdout.write(`Report written to ${args.output}\n`);
|
|
201
|
+
}
|
|
202
|
+
}),
|
|
203
|
+
export: (0, citty.defineCommand)({
|
|
204
|
+
meta: {
|
|
205
|
+
name: "export",
|
|
206
|
+
description: "Export results from SQLite to CSV or JSON"
|
|
207
|
+
},
|
|
208
|
+
args: {
|
|
209
|
+
db: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "SQLite database path",
|
|
212
|
+
default: "dripfeed-results.db"
|
|
213
|
+
},
|
|
214
|
+
format: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "Export format: csv, json",
|
|
217
|
+
default: "csv"
|
|
218
|
+
},
|
|
219
|
+
output: {
|
|
220
|
+
type: "string",
|
|
221
|
+
alias: "o",
|
|
222
|
+
description: "Output file path"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
run: async ({ args }) => {
|
|
226
|
+
const format = args.format ?? "csv";
|
|
227
|
+
validateFormat(format, VALID_EXPORT_FORMATS, "export");
|
|
228
|
+
const { writeFile } = await import("node:fs/promises");
|
|
229
|
+
const storage = require_runner.createSqliteStorage(args.db ?? "dripfeed-results.db");
|
|
230
|
+
await storage.init();
|
|
231
|
+
const results = await storage.getAll();
|
|
232
|
+
await storage.close();
|
|
233
|
+
let output;
|
|
234
|
+
if (format === "json") output = JSON.stringify(results, null, 2);
|
|
235
|
+
else {
|
|
236
|
+
const headers = [
|
|
237
|
+
"timestamp",
|
|
238
|
+
"endpoint",
|
|
239
|
+
"method",
|
|
240
|
+
"url",
|
|
241
|
+
"status",
|
|
242
|
+
"duration_ms",
|
|
243
|
+
"error",
|
|
244
|
+
"response_body"
|
|
245
|
+
];
|
|
246
|
+
const escapeCsv = (s) => {
|
|
247
|
+
if (s === null) return "";
|
|
248
|
+
return s.includes(",") || s.includes("\"") || s.includes("\n") ? `"${s.replace(/"/g, "\"\"")}"` : s;
|
|
249
|
+
};
|
|
250
|
+
const rows = results.map((r) => [
|
|
251
|
+
r.timestamp,
|
|
252
|
+
r.endpoint,
|
|
253
|
+
r.method,
|
|
254
|
+
r.url,
|
|
255
|
+
r.status ?? "",
|
|
256
|
+
r.duration_ms,
|
|
257
|
+
escapeCsv(r.error),
|
|
258
|
+
escapeCsv(r.response_body)
|
|
259
|
+
].join(","));
|
|
260
|
+
output = [headers.join(","), ...rows].join("\n");
|
|
261
|
+
}
|
|
262
|
+
if (args.output) {
|
|
263
|
+
await writeFile(args.output, output);
|
|
264
|
+
process.stdout.write(`Exported ${results.length} results to ${args.output}\n`);
|
|
265
|
+
} else process.stdout.write(`${output}\n`);
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
//#endregion
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|