dripfeed 0.1.0 → 0.1.2

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/AGENTS.md ADDED
@@ -0,0 +1,195 @@
1
+ # dripfeed
2
+
3
+ Soak test (endurance test) CLI and library. Sends HTTP requests to your endpoints at intervals for hours or days, logs every response to SQLite, reports latency percentiles and uptime, fails CI on threshold breaches.
4
+
5
+ > Read the full method reference and config options below before writing any code.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install dripfeed
11
+ # Node.js SQLite users also need:
12
+ npm install better-sqlite3
13
+ ```
14
+
15
+ On Bun, no extra dependencies needed (native TS config loading and bun:sqlite).
16
+
17
+ ## CLI commands
18
+
19
+ ```bash
20
+ dripfeed init # Generate starter config (dripfeed.config.ts)
21
+ dripfeed init --format json # Generate JSON config instead
22
+ dripfeed run # Run indefinitely (Ctrl+C to stop)
23
+ dripfeed run --duration 10m # Run for 10 minutes
24
+ dripfeed run --duration 2h --quiet # Suppress live output
25
+ dripfeed run --report json # Output JSON summary (auto-suppresses console)
26
+ dripfeed run --report markdown -o r.md # Write markdown report to file
27
+ dripfeed report --db results.db # Generate report from existing database
28
+ dripfeed report --format json # Report as JSON
29
+ dripfeed export --format csv -o out.csv # Export raw results as CSV
30
+ dripfeed export --format json # Export as JSON
31
+ ```
32
+
33
+ Short aliases: `-d` (duration), `-i` (interval), `-r` (report), `-o` (output), `-q` (quiet).
34
+
35
+ Note: `report` and `export` commands only read SQLite databases. They do not work with JSON or memory storage.
36
+
37
+ ## Config file
38
+
39
+ Auto-discovered as `dripfeed.config.{ts,js,json,yaml,toml}`, `.dripfeedrc`, or `.config/dripfeed.*`.
40
+
41
+ ```typescript
42
+ import type { DripfeedConfig } from 'dripfeed';
43
+
44
+ const config: DripfeedConfig = {
45
+ interval: '3s', // Time between requests (min 100ms)
46
+ timeout: '30s', // Per-request timeout
47
+ storage: 'sqlite', // sqlite | json | memory
48
+ db: 'results.db', // SQLite file path (default: dripfeed-results.db)
49
+ rotation: 'weighted-random', // weighted-random | round-robin | sequential
50
+ headers: { // Global headers for all requests
51
+ Authorization: 'Bearer ${API_TOKEN}', // ${VAR} interpolated from process.env
52
+ },
53
+ endpoints: [
54
+ {
55
+ name: 'health',
56
+ url: 'https://api.example.com/health',
57
+ },
58
+ {
59
+ name: 'create-order',
60
+ url: 'https://api.example.com/v1/orders',
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: { product_id: 'sku-123', quantity: 1 },
64
+ weight: 3, // 3x more likely to be selected
65
+ },
66
+ ],
67
+ thresholds: {
68
+ error_rate: '< 1%',
69
+ p95: '< 500ms',
70
+ p99: '< 2000ms',
71
+ },
72
+ };
73
+
74
+ export default config;
75
+ ```
76
+
77
+ ### Config options
78
+
79
+ | Option | Type | Default | Description |
80
+ |--------|------|---------|-------------|
81
+ | `interval` | string | `"3s"` | Time between requests. Formats: 500ms, 1s, 3s, 30s, 1m |
82
+ | `timeout` | string | `"30s"` | Request timeout |
83
+ | `storage` | string | `"sqlite"` | Storage: sqlite, json, memory |
84
+ | `db` | string | `"dripfeed-results.db"` | SQLite/JSON file path |
85
+ | `rotation` | string | `"weighted-random"` | Endpoint selection strategy |
86
+ | `headers` | object | `{}` | Global headers applied to all requests |
87
+ | `endpoints` | array | required | At least one endpoint with name and url |
88
+ | `thresholds` | object | none | Pass/fail criteria for CI |
89
+
90
+ ### Endpoint options
91
+
92
+ | Option | Type | Default |
93
+ |--------|------|---------|
94
+ | `name` | string | required |
95
+ | `url` | string | required |
96
+ | `method` | string | GET |
97
+ | `headers` | object | {} |
98
+ | `body` | any | none |
99
+ | `timeout` | string | global |
100
+ | `weight` | number | 1 |
101
+
102
+ ## Library API
103
+
104
+ ```typescript
105
+ import {
106
+ createSoakTest,
107
+ parseConfig,
108
+ createConsoleReporter,
109
+ createJsonReporter,
110
+ createMarkdownReporter,
111
+ createMemoryStorage,
112
+ } from 'dripfeed';
113
+ ```
114
+
115
+ ### parseConfig(raw)
116
+
117
+ Validates a raw config object, applies Zod defaults, returns `ParsedConfig`. Always use this before `createSoakTest`.
118
+
119
+ ```typescript
120
+ const config = parseConfig({
121
+ interval: '3s',
122
+ storage: 'memory',
123
+ endpoints: [{ name: 'health', url: 'https://api.example.com/health' }],
124
+ thresholds: { error_rate: '< 1%', p95: '< 500ms' },
125
+ });
126
+ ```
127
+
128
+ ### createSoakTest(config, reporters)
129
+
130
+ Returns `{ start(), stop(), run({ duration }) }`.
131
+
132
+ ```typescript
133
+ const test = createSoakTest(config, [createConsoleReporter()]);
134
+
135
+ // Fixed duration (returns stats when done)
136
+ const stats = await test.run({ duration: '10m' });
137
+
138
+ // Or manual start/stop
139
+ await test.start();
140
+ // ... later
141
+ const stats = await test.stop();
142
+ ```
143
+
144
+ ### Return type: SoakStats
145
+
146
+ ```typescript
147
+ interface SoakStats {
148
+ duration_s: number;
149
+ total_requests: number;
150
+ success_count: number;
151
+ failure_count: number;
152
+ uptime_pct: number;
153
+ latency: { min: number; avg: number; p50: number; p95: number; p99: number; max: number };
154
+ status_codes: Record<number, number>;
155
+ endpoints: Array<{ name: string; requests: number; avg_ms: number; p95_ms: number; error_count: number }>;
156
+ errors: Array<{ endpoint: string; status: number | null; count: number; sample_body: string | null }>;
157
+ thresholds?: Array<{ name: string; target: string; actual: string; passed: boolean }>;
158
+ }
159
+ ```
160
+
161
+ ### Reporters
162
+
163
+ - `createConsoleReporter()` - ANSI colored live output
164
+ - `createJsonReporter(outputPath?)` - JSON summary to file or stdout
165
+ - `createMarkdownReporter(outputPath?)` - Markdown report to file or stdout
166
+
167
+ ### Utilities
168
+
169
+ - `parseDuration('3s')` - parse human duration to milliseconds
170
+ - `isSuccess(status)` - true for status 100-399
171
+ - `percentile(sorted, p)` - compute percentile from sorted array
172
+ - `computeStats(results, startTime, thresholds?)` - compute SoakStats from RequestResult array
173
+ - `timedFetch(endpoint, globalHeaders?, timeout?)` - single timed request, returns RequestResult
174
+ - `isBun` / `isNode` / `isDeno` - runtime detection booleans
175
+
176
+ ## Behavior notes
177
+
178
+ - Success = status 100-399 (includes redirects)
179
+ - Error response bodies captured on status >= 400 or null (timeout/network)
180
+ - SQLite database appends across runs. Delete the .db file for fresh results
181
+ - Exit code 1 when any threshold fails
182
+ - `${VAR}` in config strings interpolated from process.env
183
+ - Minimum interval enforced at 100ms
184
+ - Bun auto-detects bun:sqlite. Node.js requires better-sqlite3 for SQLite storage.
185
+
186
+ ## Common tasks
187
+
188
+ | Task | Code |
189
+ |------|------|
190
+ | Quick 10m soak test | `npx dripfeed run -d 10m` |
191
+ | CI pipeline test | `npx dripfeed run -d 5m -q` (exit code 1 on failure) |
192
+ | JSON report to file | `npx dripfeed run -d 10m -r json -o report.json` |
193
+ | Programmatic test | `parseConfig({...})` then `createSoakTest(config).run({duration: '5m'})` |
194
+ | Query failures | `sqlite3 dripfeed-results.db "SELECT * FROM results WHERE status >= 400"` |
195
+ | Serverless/tests | Use `storage: 'memory'` (no file system needed) |
package/README.md CHANGED
@@ -9,7 +9,9 @@ Soak test your API. One request every few seconds, for hours. Logs every failure
9
9
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
10
10
  [![Bun](https://img.shields.io/badge/bun-%3E%3D1.0-black)](https://bun.sh)
11
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.
12
+ **dripfeed** sends HTTP requests to your 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, memory leaks, resource exhaustion, and silent outages that load tests and uptime pings miss.
13
+
14
+ This is **soak testing** (also called endurance testing): sustained, low-volume traffic over extended periods to evaluate stability and reliability. It surfaces problems that only appear under real-world conditions, like performance degradation over time, connection pool exhaustion, and errors that happen once every thousand requests.
13
15
 
14
16
  ## When to use dripfeed
15
17
 
package/dist/cli.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  const require_runner = require("./runner-Dc1JRBps.cjs");
3
+ let node_module = require("node:module");
3
4
  let citty = require("citty");
4
5
  //#region src/cli.ts
6
+ const { version } = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
5
7
  const VALID_REPORT_FORMATS = [
6
8
  "console",
7
9
  "json",
@@ -14,106 +16,99 @@ const validateFormat = (format, valid, command) => {
14
16
  process.exit(1);
15
17
  }
16
18
  };
17
- (0, citty.runMain)((0, citty.defineCommand)({
19
+ const run = (0, citty.defineCommand)({
18
20
  meta: {
19
- name: "dripfeed",
20
- version: "0.1.0",
21
- description: "SQLite-native API soak testing. Drip, not firehose."
21
+ name: "run",
22
+ description: "Start a soak test"
22
23
  },
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';
24
+ args: {
25
+ duration: {
26
+ type: "string",
27
+ alias: "d",
28
+ description: "Test duration (e.g. \"30s\", \"5m\", \"2h\")"
29
+ },
30
+ interval: {
31
+ type: "string",
32
+ alias: "i",
33
+ description: "Request interval (e.g. \"3s\", \"500ms\")"
34
+ },
35
+ db: {
36
+ type: "string",
37
+ description: "SQLite database path"
38
+ },
39
+ report: {
40
+ type: "string",
41
+ alias: "r",
42
+ description: "Report format: console, json, markdown",
43
+ default: "console"
44
+ },
45
+ output: {
46
+ type: "string",
47
+ alias: "o",
48
+ description: "Report output file path"
49
+ },
50
+ quiet: {
51
+ type: "boolean",
52
+ alias: "q",
53
+ description: "Suppress live output",
54
+ default: false
55
+ }
56
+ },
57
+ run: async ({ args }) => {
58
+ const reportFormat = args.report ?? "console";
59
+ validateFormat(reportFormat, VALID_REPORT_FORMATS, "report");
60
+ const overrides = {};
61
+ if (args.duration) overrides.duration = args.duration;
62
+ if (args.interval) overrides.interval = args.interval;
63
+ if (args.db) overrides.db = args.db;
64
+ let config;
65
+ try {
66
+ config = await require_runner.loadDripfeedConfig(overrides);
67
+ } catch (err) {
68
+ if (err && typeof err === "object" && "issues" in err) {
69
+ process.stderr.write("Invalid config. Run `dripfeed init` to create a starter config.\n");
70
+ const { issues } = err;
71
+ process.stderr.write(`Details: ${JSON.stringify(issues, null, 2)}\n`);
72
+ } else process.stderr.write(`Config error: ${err instanceof Error ? err.message : err}\n`);
73
+ process.exit(1);
74
+ }
75
+ const reporters = [];
76
+ const shouldQuiet = args.quiet || reportFormat !== "console";
77
+ if (!shouldQuiet) reporters.push(require_runner.createConsoleReporter());
78
+ if (reportFormat === "json") reporters.push(require_runner.createJsonReporter(args.output));
79
+ else if (reportFormat === "markdown") reporters.push(require_runner.createMarkdownReporter(args.output));
80
+ if (!shouldQuiet) {
81
+ const interval = config.interval ?? "3s";
82
+ const duration = args.duration ? ` for ${args.duration}` : "";
83
+ process.stdout.write(`\ndripfeed v${version} | every ${interval}${duration} | Ctrl+C to stop\n\n`);
84
+ }
85
+ if ((await require_runner.createSoakTest(config, reporters).run({ duration: args.duration })).thresholds?.some((t) => !t.passed)) process.exit(1);
86
+ }
87
+ });
88
+ const init = (0, citty.defineCommand)({
89
+ meta: {
90
+ name: "init",
91
+ description: "Generate a starter dripfeed config file"
92
+ },
93
+ args: { format: {
94
+ type: "string",
95
+ description: "Config format: ts, json",
96
+ default: "ts"
97
+ } },
98
+ run: async ({ args }) => {
99
+ const { writeFile, access } = await import("node:fs/promises");
100
+ const format = args.format ?? "ts";
101
+ if (format !== "ts" && format !== "json") {
102
+ process.stderr.write(`Unsupported format "${format}". Use: ts, json\n`);
103
+ process.exit(1);
104
+ }
105
+ const filename = format === "ts" ? "dripfeed.config.ts" : "dripfeed.config.json";
106
+ try {
107
+ await access(filename);
108
+ process.stderr.write(`${filename} already exists. Delete it first or use a different format.\n`);
109
+ process.exit(1);
110
+ } catch {}
111
+ if (format === "ts") await writeFile(filename, `import type { DripfeedConfig } from 'dripfeed';
117
112
 
118
113
  const config: DripfeedConfig = {
119
114
  \tinterval: '3s',
@@ -139,132 +134,143 @@ const config: DripfeedConfig = {
139
134
 
140
135
  export default config;
141
136
  `);
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`);
137
+ else await writeFile(filename, JSON.stringify({
138
+ interval: "3s",
139
+ timeout: "30s",
140
+ storage: "sqlite",
141
+ rotation: "weighted-random",
142
+ endpoints: [{
143
+ name: "health",
144
+ url: "https://api.example.com/health"
145
+ }, {
146
+ name: "users",
147
+ url: "https://api.example.com/v1/users",
148
+ weight: 3
149
+ }],
150
+ thresholds: {
151
+ error_rate: "< 1%",
152
+ p95: "< 500ms"
201
153
  }
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
- })
154
+ }, null, 2));
155
+ process.stdout.write(`Created ${filename}\n`);
156
+ }
157
+ });
158
+ const report = (0, citty.defineCommand)({
159
+ meta: {
160
+ name: "report",
161
+ description: "Generate a report from an existing SQLite database"
162
+ },
163
+ args: {
164
+ db: {
165
+ type: "string",
166
+ description: "SQLite database path",
167
+ default: "dripfeed-results.db"
168
+ },
169
+ format: {
170
+ type: "string",
171
+ description: "Report format: console, json, markdown",
172
+ default: "console"
173
+ },
174
+ output: {
175
+ type: "string",
176
+ alias: "o",
177
+ description: "Output file path"
178
+ }
179
+ },
180
+ run: async ({ args }) => {
181
+ const format = args.format ?? "console";
182
+ validateFormat(format, VALID_REPORT_FORMATS, "report");
183
+ const storage = require_runner.createSqliteStorage(args.db ?? "dripfeed-results.db");
184
+ await storage.init();
185
+ const results = await storage.getAll();
186
+ await storage.close();
187
+ if (results.length === 0) {
188
+ process.stdout.write("No results found in database.\n");
189
+ return;
190
+ }
191
+ const stats = require_runner.computeStats(results, new Date(results[0]?.timestamp ?? Date.now()), void 0, new Date(results[results.length - 1]?.timestamp ?? Date.now()));
192
+ if (format === "console") require_runner.createConsoleReporter().onComplete(stats);
193
+ else if (format === "json") require_runner.createJsonReporter(args.output).onComplete(stats);
194
+ else if (format === "markdown") require_runner.createMarkdownReporter(args.output).onComplete(stats);
195
+ if (args.output) process.stdout.write(`Report written to ${args.output}\n`);
196
+ }
197
+ });
198
+ const exportCmd = (0, citty.defineCommand)({
199
+ meta: {
200
+ name: "export",
201
+ description: "Export results from SQLite to CSV or JSON"
202
+ },
203
+ args: {
204
+ db: {
205
+ type: "string",
206
+ description: "SQLite database path",
207
+ default: "dripfeed-results.db"
208
+ },
209
+ format: {
210
+ type: "string",
211
+ description: "Export format: csv, json",
212
+ default: "csv"
213
+ },
214
+ output: {
215
+ type: "string",
216
+ alias: "o",
217
+ description: "Output file path"
218
+ }
219
+ },
220
+ run: async ({ args }) => {
221
+ const format = args.format ?? "csv";
222
+ validateFormat(format, VALID_EXPORT_FORMATS, "export");
223
+ const { writeFile } = await import("node:fs/promises");
224
+ const storage = require_runner.createSqliteStorage(args.db ?? "dripfeed-results.db");
225
+ await storage.init();
226
+ const results = await storage.getAll();
227
+ await storage.close();
228
+ let output;
229
+ if (format === "json") output = JSON.stringify(results, null, 2);
230
+ else {
231
+ const headers = [
232
+ "timestamp",
233
+ "endpoint",
234
+ "method",
235
+ "url",
236
+ "status",
237
+ "duration_ms",
238
+ "error",
239
+ "response_body"
240
+ ];
241
+ const escapeCsv = (s) => {
242
+ if (s === null) return "";
243
+ return s.includes(",") || s.includes("\"") || s.includes("\n") ? `"${s.replace(/"/g, "\"\"")}"` : s;
244
+ };
245
+ const rows = results.map((r) => [
246
+ r.timestamp,
247
+ r.endpoint,
248
+ r.method,
249
+ r.url,
250
+ r.status ?? "",
251
+ r.duration_ms,
252
+ escapeCsv(r.error),
253
+ escapeCsv(r.response_body)
254
+ ].join(","));
255
+ output = [headers.join(","), ...rows].join("\n");
256
+ }
257
+ if (args.output) {
258
+ await writeFile(args.output, output);
259
+ process.stdout.write(`Exported ${results.length} results to ${args.output}\n`);
260
+ } else process.stdout.write(`${output}\n`);
261
+ }
262
+ });
263
+ (0, citty.runMain)((0, citty.defineCommand)({
264
+ meta: {
265
+ name: "dripfeed",
266
+ version,
267
+ description: "Soak test CLI for APIs. Hits endpoints at intervals, logs results to SQLite."
268
+ },
269
+ subCommands: {
270
+ run,
271
+ init,
272
+ report,
273
+ export: exportCmd
268
274
  }
269
275
  }));
270
276
  //#endregion
package/dist/cli.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { _ as createJsonReporter, g as createMarkdownReporter, n as computeStats, s as loadDripfeedConfig, t as createSoakTest, u as createSqliteStorage, v as createConsoleReporter } from "./runner-ByEGj869.mjs";
3
+ import { createRequire } from "node:module";
3
4
  import { defineCommand, runMain } from "citty";
4
5
  //#region src/cli.ts
6
+ const { version } = createRequire(import.meta.url)("../package.json");
5
7
  const VALID_REPORT_FORMATS = [
6
8
  "console",
7
9
  "json",
@@ -14,106 +16,99 @@ const validateFormat = (format, valid, command) => {
14
16
  process.exit(1);
15
17
  }
16
18
  };
17
- runMain(defineCommand({
19
+ const run = defineCommand({
18
20
  meta: {
19
- name: "dripfeed",
20
- version: "0.1.0",
21
- description: "SQLite-native API soak testing. Drip, not firehose."
21
+ name: "run",
22
+ description: "Start a soak test"
22
23
  },
23
- subCommands: {
24
- run: 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 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(createConsoleReporter());
83
- if (reportFormat === "json") reporters.push(createJsonReporter(args.output));
84
- else if (reportFormat === "markdown") reporters.push(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 createSoakTest(config, reporters).run({ duration: args.duration })).thresholds?.some((t) => !t.passed)) process.exit(1);
91
- }
92
- }),
93
- init: 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';
24
+ args: {
25
+ duration: {
26
+ type: "string",
27
+ alias: "d",
28
+ description: "Test duration (e.g. \"30s\", \"5m\", \"2h\")"
29
+ },
30
+ interval: {
31
+ type: "string",
32
+ alias: "i",
33
+ description: "Request interval (e.g. \"3s\", \"500ms\")"
34
+ },
35
+ db: {
36
+ type: "string",
37
+ description: "SQLite database path"
38
+ },
39
+ report: {
40
+ type: "string",
41
+ alias: "r",
42
+ description: "Report format: console, json, markdown",
43
+ default: "console"
44
+ },
45
+ output: {
46
+ type: "string",
47
+ alias: "o",
48
+ description: "Report output file path"
49
+ },
50
+ quiet: {
51
+ type: "boolean",
52
+ alias: "q",
53
+ description: "Suppress live output",
54
+ default: false
55
+ }
56
+ },
57
+ run: async ({ args }) => {
58
+ const reportFormat = args.report ?? "console";
59
+ validateFormat(reportFormat, VALID_REPORT_FORMATS, "report");
60
+ const overrides = {};
61
+ if (args.duration) overrides.duration = args.duration;
62
+ if (args.interval) overrides.interval = args.interval;
63
+ if (args.db) overrides.db = args.db;
64
+ let config;
65
+ try {
66
+ config = await loadDripfeedConfig(overrides);
67
+ } catch (err) {
68
+ if (err && typeof err === "object" && "issues" in err) {
69
+ process.stderr.write("Invalid config. Run `dripfeed init` to create a starter config.\n");
70
+ const { issues } = err;
71
+ process.stderr.write(`Details: ${JSON.stringify(issues, null, 2)}\n`);
72
+ } else process.stderr.write(`Config error: ${err instanceof Error ? err.message : err}\n`);
73
+ process.exit(1);
74
+ }
75
+ const reporters = [];
76
+ const shouldQuiet = args.quiet || reportFormat !== "console";
77
+ if (!shouldQuiet) reporters.push(createConsoleReporter());
78
+ if (reportFormat === "json") reporters.push(createJsonReporter(args.output));
79
+ else if (reportFormat === "markdown") reporters.push(createMarkdownReporter(args.output));
80
+ if (!shouldQuiet) {
81
+ const interval = config.interval ?? "3s";
82
+ const duration = args.duration ? ` for ${args.duration}` : "";
83
+ process.stdout.write(`\ndripfeed v${version} | every ${interval}${duration} | Ctrl+C to stop\n\n`);
84
+ }
85
+ if ((await createSoakTest(config, reporters).run({ duration: args.duration })).thresholds?.some((t) => !t.passed)) process.exit(1);
86
+ }
87
+ });
88
+ const init = defineCommand({
89
+ meta: {
90
+ name: "init",
91
+ description: "Generate a starter dripfeed config file"
92
+ },
93
+ args: { format: {
94
+ type: "string",
95
+ description: "Config format: ts, json",
96
+ default: "ts"
97
+ } },
98
+ run: async ({ args }) => {
99
+ const { writeFile, access } = await import("node:fs/promises");
100
+ const format = args.format ?? "ts";
101
+ if (format !== "ts" && format !== "json") {
102
+ process.stderr.write(`Unsupported format "${format}". Use: ts, json\n`);
103
+ process.exit(1);
104
+ }
105
+ const filename = format === "ts" ? "dripfeed.config.ts" : "dripfeed.config.json";
106
+ try {
107
+ await access(filename);
108
+ process.stderr.write(`${filename} already exists. Delete it first or use a different format.\n`);
109
+ process.exit(1);
110
+ } catch {}
111
+ if (format === "ts") await writeFile(filename, `import type { DripfeedConfig } from 'dripfeed';
117
112
 
118
113
  const config: DripfeedConfig = {
119
114
  \tinterval: '3s',
@@ -139,132 +134,143 @@ const config: DripfeedConfig = {
139
134
 
140
135
  export default config;
141
136
  `);
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: 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 = 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 = computeStats(results, new Date(results[0]?.timestamp ?? Date.now()), void 0, new Date(results[results.length - 1]?.timestamp ?? Date.now()));
197
- if (format === "console") createConsoleReporter().onComplete(stats);
198
- else if (format === "json") createJsonReporter(args.output).onComplete(stats);
199
- else if (format === "markdown") createMarkdownReporter(args.output).onComplete(stats);
200
- if (args.output) process.stdout.write(`Report written to ${args.output}\n`);
137
+ else await writeFile(filename, JSON.stringify({
138
+ interval: "3s",
139
+ timeout: "30s",
140
+ storage: "sqlite",
141
+ rotation: "weighted-random",
142
+ endpoints: [{
143
+ name: "health",
144
+ url: "https://api.example.com/health"
145
+ }, {
146
+ name: "users",
147
+ url: "https://api.example.com/v1/users",
148
+ weight: 3
149
+ }],
150
+ thresholds: {
151
+ error_rate: "< 1%",
152
+ p95: "< 500ms"
201
153
  }
202
- }),
203
- export: 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 = 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
- })
154
+ }, null, 2));
155
+ process.stdout.write(`Created ${filename}\n`);
156
+ }
157
+ });
158
+ const report = defineCommand({
159
+ meta: {
160
+ name: "report",
161
+ description: "Generate a report from an existing SQLite database"
162
+ },
163
+ args: {
164
+ db: {
165
+ type: "string",
166
+ description: "SQLite database path",
167
+ default: "dripfeed-results.db"
168
+ },
169
+ format: {
170
+ type: "string",
171
+ description: "Report format: console, json, markdown",
172
+ default: "console"
173
+ },
174
+ output: {
175
+ type: "string",
176
+ alias: "o",
177
+ description: "Output file path"
178
+ }
179
+ },
180
+ run: async ({ args }) => {
181
+ const format = args.format ?? "console";
182
+ validateFormat(format, VALID_REPORT_FORMATS, "report");
183
+ const storage = createSqliteStorage(args.db ?? "dripfeed-results.db");
184
+ await storage.init();
185
+ const results = await storage.getAll();
186
+ await storage.close();
187
+ if (results.length === 0) {
188
+ process.stdout.write("No results found in database.\n");
189
+ return;
190
+ }
191
+ const stats = computeStats(results, new Date(results[0]?.timestamp ?? Date.now()), void 0, new Date(results[results.length - 1]?.timestamp ?? Date.now()));
192
+ if (format === "console") createConsoleReporter().onComplete(stats);
193
+ else if (format === "json") createJsonReporter(args.output).onComplete(stats);
194
+ else if (format === "markdown") createMarkdownReporter(args.output).onComplete(stats);
195
+ if (args.output) process.stdout.write(`Report written to ${args.output}\n`);
196
+ }
197
+ });
198
+ const exportCmd = defineCommand({
199
+ meta: {
200
+ name: "export",
201
+ description: "Export results from SQLite to CSV or JSON"
202
+ },
203
+ args: {
204
+ db: {
205
+ type: "string",
206
+ description: "SQLite database path",
207
+ default: "dripfeed-results.db"
208
+ },
209
+ format: {
210
+ type: "string",
211
+ description: "Export format: csv, json",
212
+ default: "csv"
213
+ },
214
+ output: {
215
+ type: "string",
216
+ alias: "o",
217
+ description: "Output file path"
218
+ }
219
+ },
220
+ run: async ({ args }) => {
221
+ const format = args.format ?? "csv";
222
+ validateFormat(format, VALID_EXPORT_FORMATS, "export");
223
+ const { writeFile } = await import("node:fs/promises");
224
+ const storage = createSqliteStorage(args.db ?? "dripfeed-results.db");
225
+ await storage.init();
226
+ const results = await storage.getAll();
227
+ await storage.close();
228
+ let output;
229
+ if (format === "json") output = JSON.stringify(results, null, 2);
230
+ else {
231
+ const headers = [
232
+ "timestamp",
233
+ "endpoint",
234
+ "method",
235
+ "url",
236
+ "status",
237
+ "duration_ms",
238
+ "error",
239
+ "response_body"
240
+ ];
241
+ const escapeCsv = (s) => {
242
+ if (s === null) return "";
243
+ return s.includes(",") || s.includes("\"") || s.includes("\n") ? `"${s.replace(/"/g, "\"\"")}"` : s;
244
+ };
245
+ const rows = results.map((r) => [
246
+ r.timestamp,
247
+ r.endpoint,
248
+ r.method,
249
+ r.url,
250
+ r.status ?? "",
251
+ r.duration_ms,
252
+ escapeCsv(r.error),
253
+ escapeCsv(r.response_body)
254
+ ].join(","));
255
+ output = [headers.join(","), ...rows].join("\n");
256
+ }
257
+ if (args.output) {
258
+ await writeFile(args.output, output);
259
+ process.stdout.write(`Exported ${results.length} results to ${args.output}\n`);
260
+ } else process.stdout.write(`${output}\n`);
261
+ }
262
+ });
263
+ runMain(defineCommand({
264
+ meta: {
265
+ name: "dripfeed",
266
+ version,
267
+ description: "Soak test CLI for APIs. Hits endpoints at intervals, logs results to SQLite."
268
+ },
269
+ subCommands: {
270
+ run,
271
+ init,
272
+ report,
273
+ export: exportCmd
268
274
  }
269
275
  }));
270
276
  //#endregion
package/dist/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { defineCommand, runMain } from 'citty';\nimport { createConsoleReporter } from './adapters/reporters/console.js';\nimport type { Reporter } from './adapters/reporters/interface.js';\nimport { createJsonReporter } from './adapters/reporters/json.js';\nimport { createMarkdownReporter } from './adapters/reporters/markdown.js';\nimport { createSqliteStorage } from './adapters/storage/sqlite.js';\nimport { loadDripfeedConfig, type ParsedConfig } from './core/config.js';\nimport { createSoakTest } from './core/runner.js';\nimport { computeStats } from './utils/stats.js';\n\nconst VALID_REPORT_FORMATS = ['console', 'json', 'markdown'] as const;\nconst VALID_EXPORT_FORMATS = ['csv', 'json'] as const;\n\nconst validateFormat = (format: string, valid: readonly string[], command: string) => {\n\tif (!valid.includes(format)) {\n\t\tprocess.stderr.write(\n\t\t\t`Unsupported format \"${format}\" for ${command}. Use: ${valid.join(', ')}\\n`,\n\t\t);\n\t\tprocess.exit(1);\n\t}\n};\n\nconst run = defineCommand({\n\tmeta: { name: 'run', description: 'Start a soak test' },\n\targs: {\n\t\tduration: { type: 'string', alias: 'd', description: 'Test duration (e.g. \"30s\", \"5m\", \"2h\")' },\n\t\tinterval: { type: 'string', alias: 'i', description: 'Request interval (e.g. \"3s\", \"500ms\")' },\n\t\tdb: { type: 'string', description: 'SQLite database path' },\n\t\treport: {\n\t\t\ttype: 'string',\n\t\t\talias: 'r',\n\t\t\tdescription: 'Report format: console, json, markdown',\n\t\t\tdefault: 'console',\n\t\t},\n\t\toutput: { type: 'string', alias: 'o', description: 'Report output file path' },\n\t\tquiet: { type: 'boolean', alias: 'q', description: 'Suppress live output', default: false },\n\t},\n\trun: async ({ args }) => {\n\t\tconst reportFormat = args.report ?? 'console';\n\t\tvalidateFormat(reportFormat, VALID_REPORT_FORMATS, 'report');\n\n\t\tconst overrides: Record<string, unknown> = {};\n\t\tif (args.duration) overrides.duration = args.duration;\n\t\tif (args.interval) overrides.interval = args.interval;\n\t\tif (args.db) overrides.db = args.db;\n\n\t\tlet config: ParsedConfig | undefined;\n\t\ttry {\n\t\t\tconfig = await loadDripfeedConfig(overrides);\n\t\t} catch (err) {\n\t\t\tif (err && typeof err === 'object' && 'issues' in err) {\n\t\t\t\tprocess.stderr.write('Invalid config. Run `dripfeed init` to create a starter config.\\n');\n\t\t\t\tconst { issues } = err as { issues: unknown };\n\t\t\t\tprocess.stderr.write(`Details: ${JSON.stringify(issues, null, 2)}\\n`);\n\t\t\t} else {\n\t\t\t\tprocess.stderr.write(`Config error: ${err instanceof Error ? err.message : err}\\n`);\n\t\t\t}\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst reporters: Reporter[] = [];\n\t\t// Auto-quiet when using json/markdown report to avoid mixing outputs\n\t\tconst shouldQuiet = args.quiet || reportFormat !== 'console';\n\t\tif (!shouldQuiet) {\n\t\t\treporters.push(createConsoleReporter());\n\t\t}\n\n\t\tif (reportFormat === 'json') {\n\t\t\treporters.push(createJsonReporter(args.output));\n\t\t} else if (reportFormat === 'markdown') {\n\t\t\treporters.push(createMarkdownReporter(args.output));\n\t\t}\n\n\t\t// Startup banner (only in console mode)\n\t\tif (!shouldQuiet) {\n\t\t\tconst interval = config.interval ?? '3s';\n\t\t\tconst duration = args.duration ? ` for ${args.duration}` : '';\n\t\t\tprocess.stdout.write(`\\ndripfeed v0.1.0 — every ${interval}${duration} | Ctrl+C to stop\\n\\n`);\n\t\t}\n\n\t\tconst test = createSoakTest(config, reporters);\n\t\tconst stats = await test.run({ duration: args.duration });\n\n\t\tif (stats.thresholds?.some((t) => !t.passed)) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t},\n});\n\nconst init = defineCommand({\n\tmeta: { name: 'init', description: 'Generate a starter dripfeed config file' },\n\targs: {\n\t\tformat: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Config format: ts, json',\n\t\t\tdefault: 'ts',\n\t\t},\n\t},\n\trun: async ({ args }) => {\n\t\tconst { writeFile, access } = await import('node:fs/promises');\n\t\tconst format = args.format ?? 'ts';\n\n\t\tif (format !== 'ts' && format !== 'json') {\n\t\t\tprocess.stderr.write(`Unsupported format \"${format}\". Use: ts, json\\n`);\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst filename = format === 'ts' ? 'dripfeed.config.ts' : 'dripfeed.config.json';\n\n\t\t// Check if file exists\n\t\ttry {\n\t\t\tawait access(filename);\n\t\t\tprocess.stderr.write(\n\t\t\t\t`${filename} already exists. Delete it first or use a different format.\\n`,\n\t\t\t);\n\t\t\tprocess.exit(1);\n\t\t} catch {\n\t\t\t// File doesn't exist, proceed\n\t\t}\n\n\t\tif (format === 'ts') {\n\t\t\tconst content = `import type { DripfeedConfig } from 'dripfeed';\n\nconst config: DripfeedConfig = {\n\\tinterval: '3s',\n\\ttimeout: '30s',\n\\tstorage: 'sqlite',\n\\trotation: 'weighted-random',\n\\tendpoints: [\n\\t\\t{\n\\t\\t\\tname: 'health',\n\\t\\t\\turl: 'https://api.example.com/health',\n\\t\\t},\n\\t\\t{\n\\t\\t\\tname: 'users',\n\\t\\t\\turl: 'https://api.example.com/v1/users',\n\\t\\t\\tweight: 3,\n\\t\\t},\n\\t],\n\\tthresholds: {\n\\t\\terror_rate: '< 1%',\n\\t\\tp95: '< 500ms',\n\\t},\n};\n\nexport default config;\n`;\n\t\t\tawait writeFile(filename, content);\n\t\t} else {\n\t\t\tconst content = {\n\t\t\t\tinterval: '3s',\n\t\t\t\ttimeout: '30s',\n\t\t\t\tstorage: 'sqlite',\n\t\t\t\trotation: 'weighted-random',\n\t\t\t\tendpoints: [\n\t\t\t\t\t{ name: 'health', url: 'https://api.example.com/health' },\n\t\t\t\t\t{ name: 'users', url: 'https://api.example.com/v1/users', weight: 3 },\n\t\t\t\t],\n\t\t\t\tthresholds: { error_rate: '< 1%', p95: '< 500ms' },\n\t\t\t};\n\t\t\tawait writeFile(filename, JSON.stringify(content, null, 2));\n\t\t}\n\n\t\tprocess.stdout.write(`Created ${filename}\\n`);\n\t},\n});\n\nconst report = defineCommand({\n\tmeta: { name: 'report', description: 'Generate a report from an existing SQLite database' },\n\targs: {\n\t\tdb: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'SQLite database path',\n\t\t\tdefault: 'dripfeed-results.db',\n\t\t},\n\t\tformat: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Report format: console, json, markdown',\n\t\t\tdefault: 'console',\n\t\t},\n\t\toutput: { type: 'string', alias: 'o', description: 'Output file path' },\n\t},\n\trun: async ({ args }) => {\n\t\tconst format = args.format ?? 'console';\n\t\tvalidateFormat(format, VALID_REPORT_FORMATS, 'report');\n\n\t\tconst dbPath = args.db ?? 'dripfeed-results.db';\n\t\tconst storage = createSqliteStorage(dbPath);\n\t\tawait storage.init();\n\t\tconst results = await storage.getAll();\n\t\tawait storage.close();\n\n\t\tif (results.length === 0) {\n\t\t\tprocess.stdout.write('No results found in database.\\n');\n\t\t\treturn;\n\t\t}\n\n\t\t// Use first result timestamp as start, last as end for accurate duration\n\t\tconst firstTimestamp = new Date(results[0]?.timestamp ?? Date.now());\n\t\tconst lastTimestamp = new Date(results[results.length - 1]?.timestamp ?? Date.now());\n\t\tconst stats = computeStats(results, firstTimestamp, undefined, lastTimestamp);\n\n\t\tif (format === 'console') {\n\t\t\tcreateConsoleReporter().onComplete(stats);\n\t\t} else if (format === 'json') {\n\t\t\tcreateJsonReporter(args.output).onComplete(stats);\n\t\t} else if (format === 'markdown') {\n\t\t\tcreateMarkdownReporter(args.output).onComplete(stats);\n\t\t}\n\n\t\tif (args.output) {\n\t\t\tprocess.stdout.write(`Report written to ${args.output}\\n`);\n\t\t}\n\t},\n});\n\nconst exportCmd = defineCommand({\n\tmeta: { name: 'export', description: 'Export results from SQLite to CSV or JSON' },\n\targs: {\n\t\tdb: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'SQLite database path',\n\t\t\tdefault: 'dripfeed-results.db',\n\t\t},\n\t\tformat: { type: 'string', description: 'Export format: csv, json', default: 'csv' },\n\t\toutput: { type: 'string', alias: 'o', description: 'Output file path' },\n\t},\n\trun: async ({ args }) => {\n\t\tconst format = args.format ?? 'csv';\n\t\tvalidateFormat(format, VALID_EXPORT_FORMATS, 'export');\n\n\t\tconst { writeFile } = await import('node:fs/promises');\n\t\tconst dbPath = args.db ?? 'dripfeed-results.db';\n\t\tconst storage = createSqliteStorage(dbPath);\n\t\tawait storage.init();\n\t\tconst results = await storage.getAll();\n\t\tawait storage.close();\n\n\t\tlet output: string;\n\n\t\tif (format === 'json') {\n\t\t\toutput = JSON.stringify(results, null, 2);\n\t\t} else {\n\t\t\tconst headers = [\n\t\t\t\t'timestamp',\n\t\t\t\t'endpoint',\n\t\t\t\t'method',\n\t\t\t\t'url',\n\t\t\t\t'status',\n\t\t\t\t'duration_ms',\n\t\t\t\t'error',\n\t\t\t\t'response_body',\n\t\t\t];\n\t\t\tconst escapeCsv = (s: string | null) => {\n\t\t\t\tif (s === null) return '';\n\t\t\t\treturn s.includes(',') || s.includes('\"') || s.includes('\\n')\n\t\t\t\t\t? `\"${s.replace(/\"/g, '\"\"')}\"`\n\t\t\t\t\t: s;\n\t\t\t};\n\t\t\tconst rows = results.map((r) =>\n\t\t\t\t[\n\t\t\t\t\tr.timestamp,\n\t\t\t\t\tr.endpoint,\n\t\t\t\t\tr.method,\n\t\t\t\t\tr.url,\n\t\t\t\t\tr.status ?? '',\n\t\t\t\t\tr.duration_ms,\n\t\t\t\t\tescapeCsv(r.error),\n\t\t\t\t\tescapeCsv(r.response_body),\n\t\t\t\t].join(','),\n\t\t\t);\n\t\t\toutput = [headers.join(','), ...rows].join('\\n');\n\t\t}\n\n\t\tif (args.output) {\n\t\t\tawait writeFile(args.output, output);\n\t\t\tprocess.stdout.write(`Exported ${results.length} results to ${args.output}\\n`);\n\t\t} else {\n\t\t\tprocess.stdout.write(`${output}\\n`);\n\t\t}\n\t},\n});\n\nconst main = defineCommand({\n\tmeta: {\n\t\tname: 'dripfeed',\n\t\tversion: '0.1.0',\n\t\tdescription: 'SQLite-native API soak testing. Drip, not firehose.',\n\t},\n\tsubCommands: { run, init, report, export: exportCmd },\n});\n\nrunMain(main);\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { createRequire } from 'node:module';\nimport { defineCommand, runMain } from 'citty';\nimport { createConsoleReporter } from './adapters/reporters/console.js';\nimport type { Reporter } from './adapters/reporters/interface.js';\n\nconst require = createRequire(import.meta.url);\nconst { version } = require('../package.json') as { version: string };\n\nimport { createJsonReporter } from './adapters/reporters/json.js';\nimport { createMarkdownReporter } from './adapters/reporters/markdown.js';\nimport { createSqliteStorage } from './adapters/storage/sqlite.js';\nimport { loadDripfeedConfig, type ParsedConfig } from './core/config.js';\nimport { createSoakTest } from './core/runner.js';\nimport { computeStats } from './utils/stats.js';\n\nconst VALID_REPORT_FORMATS = ['console', 'json', 'markdown'] as const;\nconst VALID_EXPORT_FORMATS = ['csv', 'json'] as const;\n\nconst validateFormat = (format: string, valid: readonly string[], command: string) => {\n\tif (!valid.includes(format)) {\n\t\tprocess.stderr.write(\n\t\t\t`Unsupported format \"${format}\" for ${command}. Use: ${valid.join(', ')}\\n`,\n\t\t);\n\t\tprocess.exit(1);\n\t}\n};\n\nconst run = defineCommand({\n\tmeta: { name: 'run', description: 'Start a soak test' },\n\targs: {\n\t\tduration: { type: 'string', alias: 'd', description: 'Test duration (e.g. \"30s\", \"5m\", \"2h\")' },\n\t\tinterval: { type: 'string', alias: 'i', description: 'Request interval (e.g. \"3s\", \"500ms\")' },\n\t\tdb: { type: 'string', description: 'SQLite database path' },\n\t\treport: {\n\t\t\ttype: 'string',\n\t\t\talias: 'r',\n\t\t\tdescription: 'Report format: console, json, markdown',\n\t\t\tdefault: 'console',\n\t\t},\n\t\toutput: { type: 'string', alias: 'o', description: 'Report output file path' },\n\t\tquiet: { type: 'boolean', alias: 'q', description: 'Suppress live output', default: false },\n\t},\n\trun: async ({ args }) => {\n\t\tconst reportFormat = args.report ?? 'console';\n\t\tvalidateFormat(reportFormat, VALID_REPORT_FORMATS, 'report');\n\n\t\tconst overrides: Record<string, unknown> = {};\n\t\tif (args.duration) overrides.duration = args.duration;\n\t\tif (args.interval) overrides.interval = args.interval;\n\t\tif (args.db) overrides.db = args.db;\n\n\t\tlet config: ParsedConfig | undefined;\n\t\ttry {\n\t\t\tconfig = await loadDripfeedConfig(overrides);\n\t\t} catch (err) {\n\t\t\tif (err && typeof err === 'object' && 'issues' in err) {\n\t\t\t\tprocess.stderr.write('Invalid config. Run `dripfeed init` to create a starter config.\\n');\n\t\t\t\tconst { issues } = err as { issues: unknown };\n\t\t\t\tprocess.stderr.write(`Details: ${JSON.stringify(issues, null, 2)}\\n`);\n\t\t\t} else {\n\t\t\t\tprocess.stderr.write(`Config error: ${err instanceof Error ? err.message : err}\\n`);\n\t\t\t}\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst reporters: Reporter[] = [];\n\t\t// Auto-quiet when using json/markdown report to avoid mixing outputs\n\t\tconst shouldQuiet = args.quiet || reportFormat !== 'console';\n\t\tif (!shouldQuiet) {\n\t\t\treporters.push(createConsoleReporter());\n\t\t}\n\n\t\tif (reportFormat === 'json') {\n\t\t\treporters.push(createJsonReporter(args.output));\n\t\t} else if (reportFormat === 'markdown') {\n\t\t\treporters.push(createMarkdownReporter(args.output));\n\t\t}\n\n\t\t// Startup banner (only in console mode)\n\t\tif (!shouldQuiet) {\n\t\t\tconst interval = config.interval ?? '3s';\n\t\t\tconst duration = args.duration ? ` for ${args.duration}` : '';\n\t\t\tprocess.stdout.write(\n\t\t\t\t`\\ndripfeed v${version} | every ${interval}${duration} | Ctrl+C to stop\\n\\n`,\n\t\t\t);\n\t\t}\n\n\t\tconst test = createSoakTest(config, reporters);\n\t\tconst stats = await test.run({ duration: args.duration });\n\n\t\tif (stats.thresholds?.some((t) => !t.passed)) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t},\n});\n\nconst init = defineCommand({\n\tmeta: { name: 'init', description: 'Generate a starter dripfeed config file' },\n\targs: {\n\t\tformat: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Config format: ts, json',\n\t\t\tdefault: 'ts',\n\t\t},\n\t},\n\trun: async ({ args }) => {\n\t\tconst { writeFile, access } = await import('node:fs/promises');\n\t\tconst format = args.format ?? 'ts';\n\n\t\tif (format !== 'ts' && format !== 'json') {\n\t\t\tprocess.stderr.write(`Unsupported format \"${format}\". Use: ts, json\\n`);\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tconst filename = format === 'ts' ? 'dripfeed.config.ts' : 'dripfeed.config.json';\n\n\t\t// Check if file exists\n\t\ttry {\n\t\t\tawait access(filename);\n\t\t\tprocess.stderr.write(\n\t\t\t\t`${filename} already exists. Delete it first or use a different format.\\n`,\n\t\t\t);\n\t\t\tprocess.exit(1);\n\t\t} catch {\n\t\t\t// File doesn't exist, proceed\n\t\t}\n\n\t\tif (format === 'ts') {\n\t\t\tconst content = `import type { DripfeedConfig } from 'dripfeed';\n\nconst config: DripfeedConfig = {\n\\tinterval: '3s',\n\\ttimeout: '30s',\n\\tstorage: 'sqlite',\n\\trotation: 'weighted-random',\n\\tendpoints: [\n\\t\\t{\n\\t\\t\\tname: 'health',\n\\t\\t\\turl: 'https://api.example.com/health',\n\\t\\t},\n\\t\\t{\n\\t\\t\\tname: 'users',\n\\t\\t\\turl: 'https://api.example.com/v1/users',\n\\t\\t\\tweight: 3,\n\\t\\t},\n\\t],\n\\tthresholds: {\n\\t\\terror_rate: '< 1%',\n\\t\\tp95: '< 500ms',\n\\t},\n};\n\nexport default config;\n`;\n\t\t\tawait writeFile(filename, content);\n\t\t} else {\n\t\t\tconst content = {\n\t\t\t\tinterval: '3s',\n\t\t\t\ttimeout: '30s',\n\t\t\t\tstorage: 'sqlite',\n\t\t\t\trotation: 'weighted-random',\n\t\t\t\tendpoints: [\n\t\t\t\t\t{ name: 'health', url: 'https://api.example.com/health' },\n\t\t\t\t\t{ name: 'users', url: 'https://api.example.com/v1/users', weight: 3 },\n\t\t\t\t],\n\t\t\t\tthresholds: { error_rate: '< 1%', p95: '< 500ms' },\n\t\t\t};\n\t\t\tawait writeFile(filename, JSON.stringify(content, null, 2));\n\t\t}\n\n\t\tprocess.stdout.write(`Created ${filename}\\n`);\n\t},\n});\n\nconst report = defineCommand({\n\tmeta: { name: 'report', description: 'Generate a report from an existing SQLite database' },\n\targs: {\n\t\tdb: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'SQLite database path',\n\t\t\tdefault: 'dripfeed-results.db',\n\t\t},\n\t\tformat: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'Report format: console, json, markdown',\n\t\t\tdefault: 'console',\n\t\t},\n\t\toutput: { type: 'string', alias: 'o', description: 'Output file path' },\n\t},\n\trun: async ({ args }) => {\n\t\tconst format = args.format ?? 'console';\n\t\tvalidateFormat(format, VALID_REPORT_FORMATS, 'report');\n\n\t\tconst dbPath = args.db ?? 'dripfeed-results.db';\n\t\tconst storage = createSqliteStorage(dbPath);\n\t\tawait storage.init();\n\t\tconst results = await storage.getAll();\n\t\tawait storage.close();\n\n\t\tif (results.length === 0) {\n\t\t\tprocess.stdout.write('No results found in database.\\n');\n\t\t\treturn;\n\t\t}\n\n\t\t// Use first result timestamp as start, last as end for accurate duration\n\t\tconst firstTimestamp = new Date(results[0]?.timestamp ?? Date.now());\n\t\tconst lastTimestamp = new Date(results[results.length - 1]?.timestamp ?? Date.now());\n\t\tconst stats = computeStats(results, firstTimestamp, undefined, lastTimestamp);\n\n\t\tif (format === 'console') {\n\t\t\tcreateConsoleReporter().onComplete(stats);\n\t\t} else if (format === 'json') {\n\t\t\tcreateJsonReporter(args.output).onComplete(stats);\n\t\t} else if (format === 'markdown') {\n\t\t\tcreateMarkdownReporter(args.output).onComplete(stats);\n\t\t}\n\n\t\tif (args.output) {\n\t\t\tprocess.stdout.write(`Report written to ${args.output}\\n`);\n\t\t}\n\t},\n});\n\nconst exportCmd = defineCommand({\n\tmeta: { name: 'export', description: 'Export results from SQLite to CSV or JSON' },\n\targs: {\n\t\tdb: {\n\t\t\ttype: 'string',\n\t\t\tdescription: 'SQLite database path',\n\t\t\tdefault: 'dripfeed-results.db',\n\t\t},\n\t\tformat: { type: 'string', description: 'Export format: csv, json', default: 'csv' },\n\t\toutput: { type: 'string', alias: 'o', description: 'Output file path' },\n\t},\n\trun: async ({ args }) => {\n\t\tconst format = args.format ?? 'csv';\n\t\tvalidateFormat(format, VALID_EXPORT_FORMATS, 'export');\n\n\t\tconst { writeFile } = await import('node:fs/promises');\n\t\tconst dbPath = args.db ?? 'dripfeed-results.db';\n\t\tconst storage = createSqliteStorage(dbPath);\n\t\tawait storage.init();\n\t\tconst results = await storage.getAll();\n\t\tawait storage.close();\n\n\t\tlet output: string;\n\n\t\tif (format === 'json') {\n\t\t\toutput = JSON.stringify(results, null, 2);\n\t\t} else {\n\t\t\tconst headers = [\n\t\t\t\t'timestamp',\n\t\t\t\t'endpoint',\n\t\t\t\t'method',\n\t\t\t\t'url',\n\t\t\t\t'status',\n\t\t\t\t'duration_ms',\n\t\t\t\t'error',\n\t\t\t\t'response_body',\n\t\t\t];\n\t\t\tconst escapeCsv = (s: string | null) => {\n\t\t\t\tif (s === null) return '';\n\t\t\t\treturn s.includes(',') || s.includes('\"') || s.includes('\\n')\n\t\t\t\t\t? `\"${s.replace(/\"/g, '\"\"')}\"`\n\t\t\t\t\t: s;\n\t\t\t};\n\t\t\tconst rows = results.map((r) =>\n\t\t\t\t[\n\t\t\t\t\tr.timestamp,\n\t\t\t\t\tr.endpoint,\n\t\t\t\t\tr.method,\n\t\t\t\t\tr.url,\n\t\t\t\t\tr.status ?? '',\n\t\t\t\t\tr.duration_ms,\n\t\t\t\t\tescapeCsv(r.error),\n\t\t\t\t\tescapeCsv(r.response_body),\n\t\t\t\t].join(','),\n\t\t\t);\n\t\t\toutput = [headers.join(','), ...rows].join('\\n');\n\t\t}\n\n\t\tif (args.output) {\n\t\t\tawait writeFile(args.output, output);\n\t\t\tprocess.stdout.write(`Exported ${results.length} results to ${args.output}\\n`);\n\t\t} else {\n\t\t\tprocess.stdout.write(`${output}\\n`);\n\t\t}\n\t},\n});\n\nconst main = defineCommand({\n\tmeta: {\n\t\tname: 'dripfeed',\n\t\tversion,\n\t\tdescription: 'Soak test CLI for APIs. Hits endpoints at intervals, logs results to SQLite.',\n\t},\n\tsubCommands: { run, init, report, export: exportCmd },\n});\n\nrunMain(main);\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dripfeed",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Soak test CLI for APIs. Hits endpoints at intervals for hours, logs results to SQLite, fails CI on threshold breaches.",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  "./package.json": "./package.json"
24
24
  },
25
25
  "files": [
26
- "dist"
26
+ "dist",
27
+ "AGENTS.md"
27
28
  ],
28
29
  "engines": {
29
30
  "node": ">=20"
@@ -62,7 +63,14 @@
62
63
  "latency-monitor",
63
64
  "cli",
64
65
  "devops",
65
- "ci-cd"
66
+ "ci-cd",
67
+ "performance-testing",
68
+ "endurance-testing",
69
+ "stability-testing",
70
+ "memory-leak-detection",
71
+ "resource-exhaustion",
72
+ "latency-degradation",
73
+ "http-client"
66
74
  ],
67
75
  "scripts": {
68
76
  "build": "tsdown",
@@ -74,8 +82,9 @@
74
82
  "prepublishOnly": "bun run build"
75
83
  },
76
84
  "dependencies": {
77
- "citty": "^0.2.1",
78
85
  "c12": "^4.0.0-beta.4",
86
+ "citty": "^0.2.1",
87
+ "jiti": "^2.6.1",
79
88
  "zod": "^4.3.6"
80
89
  },
81
90
  "peerDependencies": {