@lowdep/req-test 1.0.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 +176 -0
- package/bin/req-test.js +395 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rushabh Shah
|
|
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,176 @@
|
|
|
1
|
+
# req-test
|
|
2
|
+
|
|
3
|
+
   
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
File-based HTTP test runner. Define your API tests in a `requests.json` file and run them like a test suite. Zero dependencies.
|
|
7
|
+
|
|
8
|
+
Like Postman/Newman, but a single Node.js file you can commit to your repo.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g req-test
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or without installing:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx req-test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Create a starter requests.json
|
|
30
|
+
req-test init
|
|
31
|
+
|
|
32
|
+
# Run the tests
|
|
33
|
+
req-test
|
|
34
|
+
|
|
35
|
+
# Run a specific file
|
|
36
|
+
req-test ./tests/api.json
|
|
37
|
+
|
|
38
|
+
# Verbose output (show all assertions)
|
|
39
|
+
req-test --verbose
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Test File Format (`requests.json`)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"vars": {
|
|
49
|
+
"base": "http://localhost:3000",
|
|
50
|
+
"token": "my-auth-token"
|
|
51
|
+
},
|
|
52
|
+
"timeout": 5000,
|
|
53
|
+
"tests": [
|
|
54
|
+
{
|
|
55
|
+
"name": "Health check",
|
|
56
|
+
"method": "GET",
|
|
57
|
+
"url": "{{base}}/health",
|
|
58
|
+
"expect": {
|
|
59
|
+
"status": 200,
|
|
60
|
+
"json": { "status": "ok" },
|
|
61
|
+
"maxLatency": 200
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Login and get token",
|
|
66
|
+
"method": "POST",
|
|
67
|
+
"url": "{{base}}/auth/login",
|
|
68
|
+
"body": { "email": "test@example.com", "password": "secret" },
|
|
69
|
+
"expect": { "status": 200 },
|
|
70
|
+
"extract": { "authToken": "$.token" }
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "Get protected resource",
|
|
74
|
+
"method": "GET",
|
|
75
|
+
"url": "{{base}}/api/profile",
|
|
76
|
+
"headers": { "Authorization": "Bearer {{authToken}}" },
|
|
77
|
+
"expect": {
|
|
78
|
+
"status": 200,
|
|
79
|
+
"jsonPath": { "$.email": "test@example.com" }
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "Unauthenticated request is rejected",
|
|
84
|
+
"method": "GET",
|
|
85
|
+
"url": "{{base}}/api/profile",
|
|
86
|
+
"expect": { "statusRange": "4xx" },
|
|
87
|
+
"skip": false
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Example Output
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
req-test requests.json
|
|
99
|
+
4 test(s) · base: http://localhost:3000
|
|
100
|
+
|
|
101
|
+
✓ Health check 200 · 12ms
|
|
102
|
+
✓ Login and get token 200 · 45ms
|
|
103
|
+
✓ Get protected resource 200 · 18ms
|
|
104
|
+
✓ Unauthenticated request is rejected 401 · 8ms
|
|
105
|
+
|
|
106
|
+
4 passed of 4 tests
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**With failures:**
|
|
110
|
+
```
|
|
111
|
+
✘ Get protected resource 500 · 203ms
|
|
112
|
+
✘ status 500 != 200
|
|
113
|
+
✘ latency 203ms > 200ms
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Assertions
|
|
119
|
+
|
|
120
|
+
| Assertion | Type | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `status` | `number` | Exact HTTP status code |
|
|
123
|
+
| `statusRange` | `"2xx"` \| `"4xx"` \| `"5xx"` | Status code class |
|
|
124
|
+
| `json` | `object` | Partial JSON match (nested ok) |
|
|
125
|
+
| `jsonPath` | `object` | `{ "$.user.name": "Alice" }` |
|
|
126
|
+
| `bodyContains` | `string` | String present in response body |
|
|
127
|
+
| `bodyNotContains` | `string` | String absent from response body |
|
|
128
|
+
| `header` | `object` | `{ "content-type": "application/json" }` |
|
|
129
|
+
| `maxLatency` | `number` | Max response time in milliseconds |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Variables & Chaining
|
|
134
|
+
|
|
135
|
+
Use `{{varName}}` in any string field. Define `vars` at suite level, or extract from responses with `extract`:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"extract": { "userId": "$.data.id" }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Extracted values are available in all subsequent tests as `{{userId}}`.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## CI Integration
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- name: API Tests
|
|
151
|
+
run: npx req-test ./tests/integration.json
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Exit code is `1` if any test fails.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Keywords
|
|
165
|
+
|
|
166
|
+
`api testing` · `http test runner` · `postman alternative` · `newman alternative` · `rest client` · `api assertions` · `integration test` · `curl test` · `zero dependencies` · `ci`
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
<div align="center">
|
|
171
|
+
|
|
172
|
+
**Built to solve, shared to help — Rushabh Shah 🛠️✨**
|
|
173
|
+
|
|
174
|
+
<sub>One of 40+ zero-dependency developer CLI tools — no <code>node_modules</code>, ever.</sub>
|
|
175
|
+
|
|
176
|
+
</div>
|
package/bin/req-test.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const url = require('url');
|
|
9
|
+
|
|
10
|
+
const VERSION = '1.0.0';
|
|
11
|
+
const CFG_FILE = 'requests.json';
|
|
12
|
+
|
|
13
|
+
// ─── ANSI ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
const isTTY = process.stdout.isTTY;
|
|
15
|
+
const c = (code, t) => isTTY ? `\x1b[${code}m${t}\x1b[0m` : t;
|
|
16
|
+
const bold = t => c('1', t);
|
|
17
|
+
const dim = t => c('2', t);
|
|
18
|
+
const red = t => c('31', t);
|
|
19
|
+
const green = t => c('32', t);
|
|
20
|
+
const yellow = t => c('33', t);
|
|
21
|
+
const cyan = t => c('36', t);
|
|
22
|
+
|
|
23
|
+
// ─── HTTP fetch ───────────────────────────────────────────────────────────────
|
|
24
|
+
function fetchRequest(opts) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const { method = 'GET', url: rawUrl, headers = {}, body, timeout = 10000 } = opts;
|
|
27
|
+
const parsed = new url.URL(rawUrl);
|
|
28
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
29
|
+
|
|
30
|
+
const reqOpts = {
|
|
31
|
+
hostname: parsed.hostname,
|
|
32
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
33
|
+
path: parsed.pathname + parsed.search,
|
|
34
|
+
method: method.toUpperCase(),
|
|
35
|
+
headers: { 'User-Agent': `req-test/${VERSION}`, ...headers },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let bodyStr = '';
|
|
39
|
+
if (body) {
|
|
40
|
+
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
41
|
+
if (!reqOpts.headers['Content-Type']) reqOpts.headers['Content-Type'] = 'application/json';
|
|
42
|
+
reqOpts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const start = Date.now();
|
|
46
|
+
const req = lib.request(reqOpts, res => {
|
|
47
|
+
let raw = '';
|
|
48
|
+
res.setEncoding('utf8');
|
|
49
|
+
res.on('data', d => { raw += d; });
|
|
50
|
+
res.on('end', () => {
|
|
51
|
+
let json = null;
|
|
52
|
+
try { json = JSON.parse(raw); } catch {}
|
|
53
|
+
resolve({ status: res.statusCode, headers: res.headers, body: raw, json, latency: Date.now() - start });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
req.setTimeout(timeout, () => { req.destroy(); reject(new Error(`Timeout after ${timeout}ms`)); });
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
if (bodyStr) req.write(bodyStr);
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Assertions ───────────────────────────────────────────────────────────────
|
|
65
|
+
// Returns array of { pass, message }
|
|
66
|
+
function runAssertions(expect, response) {
|
|
67
|
+
const results = [];
|
|
68
|
+
|
|
69
|
+
// Status
|
|
70
|
+
if (expect.status !== undefined) {
|
|
71
|
+
const pass = response.status === expect.status;
|
|
72
|
+
results.push({ pass, message: `status ${response.status} ${pass ? '==' : '!='} ${expect.status}` });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Status range: "2xx", "4xx"
|
|
76
|
+
if (expect.statusRange) {
|
|
77
|
+
const code = String(response.status)[0];
|
|
78
|
+
const range = String(expect.statusRange)[0];
|
|
79
|
+
const pass = code === range;
|
|
80
|
+
results.push({ pass, message: `status ${response.status} ${pass ? 'is' : 'is not'} ${expect.statusRange}` });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Body contains string
|
|
84
|
+
if (expect.bodyContains !== undefined) {
|
|
85
|
+
const pass = response.body.includes(String(expect.bodyContains));
|
|
86
|
+
results.push({ pass, message: `body ${pass ? 'contains' : 'does not contain'} "${expect.bodyContains}"` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Body does not contain
|
|
90
|
+
if (expect.bodyNotContains !== undefined) {
|
|
91
|
+
const pass = !response.body.includes(String(expect.bodyNotContains));
|
|
92
|
+
results.push({ pass, message: `body ${pass ? 'does not contain' : 'contains'} "${expect.bodyNotContains}"` });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// JSON deep partial match
|
|
96
|
+
if (expect.json !== undefined) {
|
|
97
|
+
const { pass, msg } = jsonPartialMatch(response.json, expect.json, '');
|
|
98
|
+
results.push({ pass, message: msg });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// JSON path assertions: { "$.user.name": "Alice" }
|
|
102
|
+
if (expect.jsonPath) {
|
|
103
|
+
for (const [jsonPath, expected] of Object.entries(expect.jsonPath)) {
|
|
104
|
+
const actual = getJsonPath(response.json, jsonPath);
|
|
105
|
+
const pass = deepEqual(actual, expected);
|
|
106
|
+
results.push({ pass, message: `${jsonPath}: ${JSON.stringify(actual)} ${pass ? '==' : '!='} ${JSON.stringify(expected)}` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Response header
|
|
111
|
+
if (expect.header) {
|
|
112
|
+
for (const [key, val] of Object.entries(expect.header)) {
|
|
113
|
+
const actual = response.headers[key.toLowerCase()] || '';
|
|
114
|
+
const pass = val instanceof RegExp ? val.test(actual) : actual.includes(String(val));
|
|
115
|
+
results.push({ pass, message: `header "${key}": "${actual}" ${pass ? 'matches' : 'does not match'} "${val}"` });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Max latency
|
|
120
|
+
if (expect.maxLatency !== undefined) {
|
|
121
|
+
const pass = response.latency <= expect.maxLatency;
|
|
122
|
+
results.push({ pass, message: `latency ${response.latency}ms ${pass ? '<=' : '>'} ${expect.maxLatency}ms` });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function jsonPartialMatch(actual, expected, path) {
|
|
129
|
+
if (expected === null || expected === undefined) {
|
|
130
|
+
return { pass: actual === expected, msg: `${path || 'root'}: ${JSON.stringify(actual)} == ${JSON.stringify(expected)}` };
|
|
131
|
+
}
|
|
132
|
+
if (typeof expected !== 'object' || Array.isArray(expected)) {
|
|
133
|
+
const pass = deepEqual(actual, expected);
|
|
134
|
+
return { pass, msg: `${path || 'root'}: ${JSON.stringify(actual)} ${pass ? '==' : '!='} ${JSON.stringify(expected)}` };
|
|
135
|
+
}
|
|
136
|
+
if (typeof actual !== 'object' || actual === null) {
|
|
137
|
+
return { pass: false, msg: `${path || 'root'}: expected object, got ${JSON.stringify(actual)}` };
|
|
138
|
+
}
|
|
139
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
140
|
+
const subPath = path ? `${path}.${k}` : k;
|
|
141
|
+
const result = jsonPartialMatch(actual[k], v, subPath);
|
|
142
|
+
if (!result.pass) return result;
|
|
143
|
+
}
|
|
144
|
+
return { pass: true, msg: `json structure matches` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getJsonPath(obj, jsonPath) {
|
|
148
|
+
const keys = jsonPath.replace(/^\$\.?/, '').split(/[.\[\]]+/).filter(Boolean);
|
|
149
|
+
let current = obj;
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
if (current == null) return undefined;
|
|
152
|
+
current = current[key];
|
|
153
|
+
}
|
|
154
|
+
return current;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deepEqual(a, b) {
|
|
158
|
+
if (a === b) return true;
|
|
159
|
+
if (typeof a !== typeof b) return false;
|
|
160
|
+
if (typeof a !== 'object') return false;
|
|
161
|
+
if (a === null || b === null) return false;
|
|
162
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
163
|
+
if (ka.length !== kb.length) return false;
|
|
164
|
+
return ka.every(k => deepEqual(a[k], b[k]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Variable substitution ────────────────────────────────────────────────────
|
|
168
|
+
function substitute(value, vars) {
|
|
169
|
+
if (typeof value === 'string') {
|
|
170
|
+
return value.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === 'object' && value !== null) {
|
|
173
|
+
if (Array.isArray(value)) return value.map(v => substitute(v, vars));
|
|
174
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, vars)]));
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Test runner ──────────────────────────────────────────────────────────────
|
|
180
|
+
async function runSuite(suite, vars = {}) {
|
|
181
|
+
const results = [];
|
|
182
|
+
const allVars = { ...vars };
|
|
183
|
+
|
|
184
|
+
for (const test of suite.tests) {
|
|
185
|
+
if (test.skip) {
|
|
186
|
+
results.push({ name: test.name, skipped: true });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const t = substitute(test, allVars);
|
|
191
|
+
let response;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
response = await fetchRequest({
|
|
195
|
+
method: t.method || 'GET',
|
|
196
|
+
url: t.url,
|
|
197
|
+
headers: t.headers || {},
|
|
198
|
+
body: t.body,
|
|
199
|
+
timeout: t.timeout || suite.timeout || 10000,
|
|
200
|
+
});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
results.push({ name: test.name, error: e.message, assertions: [] });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const assertions = t.expect ? runAssertions(t.expect, response) : [];
|
|
207
|
+
|
|
208
|
+
// Extract variables from response for use in later tests
|
|
209
|
+
if (t.extract) {
|
|
210
|
+
for (const [varName, jsonPath] of Object.entries(t.extract)) {
|
|
211
|
+
const extracted = getJsonPath(response.json, jsonPath);
|
|
212
|
+
if (extracted !== undefined) allVars[varName] = extracted;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
results.push({
|
|
217
|
+
name: test.name,
|
|
218
|
+
status: response.status,
|
|
219
|
+
latency: response.latency,
|
|
220
|
+
assertions,
|
|
221
|
+
passed: assertions.every(a => a.pass),
|
|
222
|
+
error: null,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return results;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
230
|
+
const args = process.argv.slice(2);
|
|
231
|
+
const flags = new Set(args.filter(a => a.startsWith('-')));
|
|
232
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
233
|
+
|
|
234
|
+
if (flags.has('--version') || flags.has('-v')) {
|
|
235
|
+
console.log(`req-test v${VERSION}`); process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (flags.has('--help') || flags.has('-h')) {
|
|
239
|
+
console.log(`
|
|
240
|
+
${bold('req-test')} — File-based HTTP test runner
|
|
241
|
+
|
|
242
|
+
${bold('USAGE')}
|
|
243
|
+
req-test [file] Run tests from a file (default: requests.json)
|
|
244
|
+
req-test init Create a starter requests.json
|
|
245
|
+
|
|
246
|
+
${bold('OPTIONS')}
|
|
247
|
+
--verbose Show all assertions, not just failures
|
|
248
|
+
--version Show version
|
|
249
|
+
--help Show this help
|
|
250
|
+
|
|
251
|
+
${bold('TEST FILE FORMAT')} (requests.json)
|
|
252
|
+
{
|
|
253
|
+
"vars": { "base": "http://localhost:3000" },
|
|
254
|
+
"timeout": 5000,
|
|
255
|
+
"tests": [
|
|
256
|
+
{
|
|
257
|
+
"name": "Get user",
|
|
258
|
+
"method": "GET",
|
|
259
|
+
"url": "{{base}}/api/users/1",
|
|
260
|
+
"expect": {
|
|
261
|
+
"status": 200,
|
|
262
|
+
"json": { "id": 1 },
|
|
263
|
+
"maxLatency": 500
|
|
264
|
+
},
|
|
265
|
+
"extract": { "userId": "$.id" }
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
${bold('ASSERTIONS')}
|
|
271
|
+
status Exact HTTP status code
|
|
272
|
+
statusRange Status class: "2xx", "4xx", "5xx"
|
|
273
|
+
json Partial JSON match (nested keys)
|
|
274
|
+
jsonPath JSONPath assertions: { "$.user.name": "Alice" }
|
|
275
|
+
bodyContains String present in response body
|
|
276
|
+
header Response header match: { "content-type": "application/json" }
|
|
277
|
+
maxLatency Maximum allowed response time in ms
|
|
278
|
+
`);
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// init sub-command
|
|
283
|
+
if (positional[0] === 'init') {
|
|
284
|
+
const dest = path.join(process.cwd(), CFG_FILE);
|
|
285
|
+
if (fs.existsSync(dest)) {
|
|
286
|
+
console.log(yellow(`\n${CFG_FILE} already exists.\n`));
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
const starter = {
|
|
290
|
+
vars: { base: 'https://jsonplaceholder.typicode.com' },
|
|
291
|
+
timeout: 5000,
|
|
292
|
+
tests: [
|
|
293
|
+
{
|
|
294
|
+
name: 'Get post returns 200',
|
|
295
|
+
method: 'GET',
|
|
296
|
+
url: '{{base}}/posts/1',
|
|
297
|
+
expect: { status: 200, json: { id: 1 }, maxLatency: 2000 },
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'Create post',
|
|
301
|
+
method: 'POST',
|
|
302
|
+
url: '{{base}}/posts',
|
|
303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
304
|
+
body: { title: 'Test', body: 'Hello', userId: 1 },
|
|
305
|
+
expect: { status: 201, json: { title: 'Test' } },
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'Non-existent post returns 404',
|
|
309
|
+
method: 'GET',
|
|
310
|
+
url: '{{base}}/posts/9999',
|
|
311
|
+
expect: { status: 404 },
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
fs.writeFileSync(dest, JSON.stringify(starter, null, 2));
|
|
316
|
+
console.log(green(`\n✓ Created ${dest}\n`));
|
|
317
|
+
console.log(dim(' Edit the file then run: req-test\n'));
|
|
318
|
+
process.exit(0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const cfgFile = positional[0]
|
|
322
|
+
? path.resolve(positional[0])
|
|
323
|
+
: path.join(process.cwd(), CFG_FILE);
|
|
324
|
+
|
|
325
|
+
if (!fs.existsSync(cfgFile)) {
|
|
326
|
+
console.error(red(`\nTest file not found: ${cfgFile}`));
|
|
327
|
+
console.error(dim(` Run \`req-test init\` to create a starter ${CFG_FILE}\n`));
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let suite;
|
|
332
|
+
try {
|
|
333
|
+
suite = JSON.parse(fs.readFileSync(cfgFile, 'utf8'));
|
|
334
|
+
} catch (e) {
|
|
335
|
+
console.error(red(`\nFailed to parse ${cfgFile}: ${e.message}\n`));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!suite.tests || !suite.tests.length) {
|
|
340
|
+
console.error(red('\nNo tests found in the suite.\n'));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const verbose = flags.has('--verbose');
|
|
345
|
+
|
|
346
|
+
console.log(`\n${bold('req-test')} ${cyan(path.basename(cfgFile))}`);
|
|
347
|
+
console.log(dim(` ${suite.tests.length} test(s) · base: ${(suite.vars || {}).base || ''}\n`));
|
|
348
|
+
|
|
349
|
+
runSuite(suite, suite.vars || {}).then(results => {
|
|
350
|
+
let passed = 0, failed = 0, skipped = 0, errors = 0;
|
|
351
|
+
|
|
352
|
+
for (const r of results) {
|
|
353
|
+
if (r.skipped) {
|
|
354
|
+
skipped++;
|
|
355
|
+
console.log(` ${dim('○')} ${dim(r.name)} ${dim('(skipped)')}`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (r.error) {
|
|
359
|
+
errors++;
|
|
360
|
+
console.log(` ${red('✘')} ${bold(r.name)} ${red('ERROR: ' + r.error)}`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (r.passed) {
|
|
364
|
+
passed++;
|
|
365
|
+
console.log(` ${green('✓')} ${r.name} ${dim(r.status + ' · ' + r.latency + 'ms')}`);
|
|
366
|
+
if (verbose && r.assertions.length) {
|
|
367
|
+
for (const a of r.assertions)
|
|
368
|
+
console.log(` ${green('·')} ${dim(a.message)}`);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
failed++;
|
|
372
|
+
console.log(` ${red('✘')} ${bold(r.name)} ${dim(r.status + ' · ' + r.latency + 'ms')}`);
|
|
373
|
+
for (const a of r.assertions) {
|
|
374
|
+
if (!a.pass) console.log(` ${red('✘')} ${a.message}`);
|
|
375
|
+
else if (verbose) console.log(` ${green('✓')} ${dim(a.message)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log();
|
|
381
|
+
const total = passed + failed + errors;
|
|
382
|
+
const summary = [
|
|
383
|
+
passed > 0 ? green(`${passed} passed`) : null,
|
|
384
|
+
failed > 0 ? red(`${failed} failed`) : null,
|
|
385
|
+
errors > 0 ? red(`${errors} error(s)`) : null,
|
|
386
|
+
skipped > 0 ? dim(`${skipped} skipped`) : null,
|
|
387
|
+
].filter(Boolean).join(' ');
|
|
388
|
+
|
|
389
|
+
console.log(` ${summary} ${dim('of ' + (total + skipped) + ' tests')}\n`);
|
|
390
|
+
|
|
391
|
+
process.exit(failed > 0 || errors > 0 ? 1 : 0);
|
|
392
|
+
}).catch(e => {
|
|
393
|
+
console.error(red(`\nUnexpected error: ${e.message}\n`));
|
|
394
|
+
process.exit(1);
|
|
395
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lowdep/req-test",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "File-based HTTP test runner — define tests in JSON, run them like a test suite, zero dependencies",
|
|
5
|
+
"bin": {
|
|
6
|
+
"req-test": "bin/req-test.js"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"http",
|
|
10
|
+
"testing",
|
|
11
|
+
"api",
|
|
12
|
+
"rest",
|
|
13
|
+
"cli",
|
|
14
|
+
"postman",
|
|
15
|
+
"newman",
|
|
16
|
+
"developer-tools",
|
|
17
|
+
"api testing",
|
|
18
|
+
"http test runner",
|
|
19
|
+
"postman alternative",
|
|
20
|
+
"newman alternative",
|
|
21
|
+
"rest client",
|
|
22
|
+
"api assertions",
|
|
23
|
+
"integration test",
|
|
24
|
+
"curl test",
|
|
25
|
+
"zero dependencies",
|
|
26
|
+
"ci"
|
|
27
|
+
],
|
|
28
|
+
"author": "Rushabh Shah",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=14"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/Rushabh5000/req-test.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/Rushabh5000/req-test/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/Rushabh5000/req-test#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|