yamltest 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/README.md +470 -0
- package/package.json +45 -0
- package/src/cli.js +204 -0
- package/src/core.js +2161 -0
- package/src/index.js +46 -0
- package/src/runner.js +158 -0
package/README.md
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# YAMLTest
|
|
2
|
+
|
|
3
|
+
Declarative YAML-based test runner for HTTP endpoints, shell commands, and Kubernetes resources.
|
|
4
|
+
|
|
5
|
+
Define tests in YAML, run them from the CLI or import them programmatically. No test framework boilerplate required.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
**From npm (once published):**
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g yamltest
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**From a local clone (before publishing):**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Install globally from the repo directory
|
|
21
|
+
npm install -g /path/to/YAMLTest
|
|
22
|
+
|
|
23
|
+
# Or run directly without installing
|
|
24
|
+
node /path/to/YAMLTest/src/cli.js -f your-tests.yaml
|
|
25
|
+
|
|
26
|
+
# Or link it for development (makes YAMLTests available system-wide, auto-updates)
|
|
27
|
+
cd /path/to/YAMLTest && npm link
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**As a dev dependency in another project:**
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install --save-dev /path/to/YAMLTest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
YAMLTests -f - <<EOF
|
|
42
|
+
- name: httpbin returns 200
|
|
43
|
+
http:
|
|
44
|
+
url: "https://httpbin.org"
|
|
45
|
+
method: GET
|
|
46
|
+
path: "/get"
|
|
47
|
+
source:
|
|
48
|
+
type: local
|
|
49
|
+
expect:
|
|
50
|
+
statusCode: 200
|
|
51
|
+
bodyContains: "httpbin.org"
|
|
52
|
+
EOF
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Output:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
✓ httpbin returns 200 312ms
|
|
59
|
+
|
|
60
|
+
1 passed | 1 total
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
USAGE
|
|
69
|
+
YAMLTests -f <file.yaml>
|
|
70
|
+
YAMLTests -f - # read from stdin (heredoc)
|
|
71
|
+
|
|
72
|
+
OPTIONS
|
|
73
|
+
-f, --file <path|-> YAML file to run, or - for stdin
|
|
74
|
+
-h, --help Show this help
|
|
75
|
+
|
|
76
|
+
ENVIRONMENT
|
|
77
|
+
DEBUG_MODE=true Enable verbose debug logging
|
|
78
|
+
NO_COLOR=1 Disable ANSI colour output
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Exit codes: `0` = all passed, `1` = one or more failed.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Programmatic API
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const { runTests, executeTest } = require('yamltest');
|
|
89
|
+
|
|
90
|
+
// Run one or more tests from a YAML string (array or single object)
|
|
91
|
+
const result = await runTests(yamlString);
|
|
92
|
+
console.log(result.passed, result.failed, result.skipped, result.total);
|
|
93
|
+
// result.results → [{name, passed, error, durationMs, attempts}]
|
|
94
|
+
|
|
95
|
+
// Run a single test (low-level)
|
|
96
|
+
await executeTest(yamlString); // returns true or throws
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Test format
|
|
102
|
+
|
|
103
|
+
Tests are defined as flat YAML objects (or an array of them). All fields except the test type key (`http`, `command`, `wait`, `httpBodyComparison`) are optional.
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
- name: my-test # optional display name
|
|
107
|
+
retries: 3 # retry up to N times on failure (default: 0)
|
|
108
|
+
http: ... # ← test type
|
|
109
|
+
source:
|
|
110
|
+
type: local # local | pod
|
|
111
|
+
expect:
|
|
112
|
+
statusCode: 200
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Test types
|
|
118
|
+
|
|
119
|
+
### HTTP test
|
|
120
|
+
|
|
121
|
+
Test any HTTP endpoint locally or from within a Kubernetes pod.
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
- name: health check
|
|
125
|
+
http:
|
|
126
|
+
url: "https://api.example.com" # required (or auto-discovered for Service selectors)
|
|
127
|
+
method: GET # GET (default) | POST | PUT | DELETE | PATCH
|
|
128
|
+
path: "/health" # default: /
|
|
129
|
+
headers:
|
|
130
|
+
Authorization: "Bearer ${API_TOKEN}"
|
|
131
|
+
Content-Type: application/json
|
|
132
|
+
params:
|
|
133
|
+
key: value # query parameters
|
|
134
|
+
body: '{"foo":"bar"}' # request body (string or object)
|
|
135
|
+
skipSslVerification: true # disable TLS verification
|
|
136
|
+
maxRedirects: 0 # redirects to follow (default: 0)
|
|
137
|
+
cert: /path/to/cert.pem # mTLS client certificate
|
|
138
|
+
key: /path/to/key.pem
|
|
139
|
+
ca: /path/to/ca.pem
|
|
140
|
+
source:
|
|
141
|
+
type: local
|
|
142
|
+
expect:
|
|
143
|
+
statusCode: 200 # or [200, 201, 202]
|
|
144
|
+
body: "exact body match"
|
|
145
|
+
bodyContains: "substring" # or array, or {value, negate, matchword}
|
|
146
|
+
bodyRegex: "pattern.*" # or {value, negate}
|
|
147
|
+
bodyJsonPath:
|
|
148
|
+
- path: "$.user.id"
|
|
149
|
+
comparator: equals
|
|
150
|
+
value: 42
|
|
151
|
+
headers:
|
|
152
|
+
- name: content-type
|
|
153
|
+
comparator: contains
|
|
154
|
+
value: application/json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Environment variable substitution
|
|
158
|
+
|
|
159
|
+
Any `$VAR` or `${VAR}` in the `url` field is resolved from the environment:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
http:
|
|
163
|
+
url: "${API_BASE_URL}"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Pod-based HTTP test
|
|
167
|
+
|
|
168
|
+
Execute the HTTP request from inside a Kubernetes pod (useful for internal service testing):
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
source:
|
|
172
|
+
type: pod
|
|
173
|
+
selector:
|
|
174
|
+
kind: Pod
|
|
175
|
+
metadata:
|
|
176
|
+
namespace: production
|
|
177
|
+
labels:
|
|
178
|
+
app: test-client
|
|
179
|
+
context: my-cluster # optional kubectl context
|
|
180
|
+
container: my-container # optional
|
|
181
|
+
usePortForward: true # use kubectl port-forward instead of debug pod
|
|
182
|
+
usePodExec: true # use kubectl exec + curl
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### Auto-discovery for Kubernetes Services
|
|
186
|
+
|
|
187
|
+
Omit `http.url` when `source.selector.kind` is `Service` and the IP/port are discovered automatically from the LoadBalancer status:
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
- name: auto-discover service
|
|
191
|
+
http:
|
|
192
|
+
method: GET
|
|
193
|
+
path: /health
|
|
194
|
+
scheme: https # optional, defaults to http
|
|
195
|
+
port: 443 # optional port name/number/index
|
|
196
|
+
source:
|
|
197
|
+
type: local
|
|
198
|
+
selector:
|
|
199
|
+
kind: Service
|
|
200
|
+
metadata:
|
|
201
|
+
namespace: production
|
|
202
|
+
name: my-service
|
|
203
|
+
expect:
|
|
204
|
+
statusCode: 200
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### Command test
|
|
210
|
+
|
|
211
|
+
Run any shell command and validate its output.
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
- name: check kubectl version
|
|
215
|
+
command:
|
|
216
|
+
command: "kubectl version --short"
|
|
217
|
+
parseJson: false # parse stdout as JSON (default: false)
|
|
218
|
+
env:
|
|
219
|
+
MY_VAR: value # extra environment variables
|
|
220
|
+
workingDir: /tmp # working directory
|
|
221
|
+
source:
|
|
222
|
+
type: local # or pod (uses kubectl exec)
|
|
223
|
+
expect:
|
|
224
|
+
exitCode: 0
|
|
225
|
+
stdout:
|
|
226
|
+
contains: "Client Version" # or: equals, matches/regex, negate
|
|
227
|
+
stderr:
|
|
228
|
+
contains: ""
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Multiple stdout expectations (all must pass):
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
expect:
|
|
235
|
+
exitCode: 0
|
|
236
|
+
stdout:
|
|
237
|
+
- contains: "Running"
|
|
238
|
+
- matches: "\\d+ pods"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
JSON output validation:
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
- name: cluster info JSON
|
|
245
|
+
command:
|
|
246
|
+
command: "kubectl cluster-info --output=json"
|
|
247
|
+
parseJson: true
|
|
248
|
+
source:
|
|
249
|
+
type: local
|
|
250
|
+
expect:
|
|
251
|
+
exitCode: 0
|
|
252
|
+
jsonPath:
|
|
253
|
+
- path: "$.Kubernetes"
|
|
254
|
+
comparator: exists
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
### Wait test
|
|
260
|
+
|
|
261
|
+
Poll a Kubernetes resource until a condition is met (or timeout).
|
|
262
|
+
|
|
263
|
+
```yaml
|
|
264
|
+
- name: wait for deployment
|
|
265
|
+
wait:
|
|
266
|
+
target:
|
|
267
|
+
kind: Deployment
|
|
268
|
+
metadata:
|
|
269
|
+
namespace: default
|
|
270
|
+
name: my-app
|
|
271
|
+
context: my-cluster # optional
|
|
272
|
+
jsonPath: "$.status.readyReplicas"
|
|
273
|
+
jsonPathExpectation:
|
|
274
|
+
comparator: greaterThan
|
|
275
|
+
value: 0
|
|
276
|
+
targetEnv: READY_REPLICAS # optional: store extracted value in env var
|
|
277
|
+
polling:
|
|
278
|
+
timeoutSeconds: 120 # default: 60
|
|
279
|
+
intervalSeconds: 5 # default: 2
|
|
280
|
+
maxRetries: 24 # optional upper bound
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Selector by labels:
|
|
284
|
+
|
|
285
|
+
```yaml
|
|
286
|
+
wait:
|
|
287
|
+
target:
|
|
288
|
+
kind: Pod
|
|
289
|
+
metadata:
|
|
290
|
+
namespace: production
|
|
291
|
+
labels:
|
|
292
|
+
app: web-server
|
|
293
|
+
version: v1.2.0
|
|
294
|
+
jsonPath: "$.status.phase"
|
|
295
|
+
jsonPathExpectation:
|
|
296
|
+
comparator: equals
|
|
297
|
+
value: Running
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
### HTTP body comparison test
|
|
303
|
+
|
|
304
|
+
Compare the response bodies of two HTTP calls and assert they are identical (useful for canary / shadow traffic validation).
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
- name: compare two backends
|
|
308
|
+
httpBodyComparison:
|
|
309
|
+
request1:
|
|
310
|
+
http:
|
|
311
|
+
url: "http://service-v1"
|
|
312
|
+
method: GET
|
|
313
|
+
path: /api/data
|
|
314
|
+
source:
|
|
315
|
+
type: local
|
|
316
|
+
request2:
|
|
317
|
+
http:
|
|
318
|
+
url: "http://service-v2"
|
|
319
|
+
method: GET
|
|
320
|
+
path: /api/data
|
|
321
|
+
source:
|
|
322
|
+
type: local
|
|
323
|
+
parseAsJson: true # parse bodies as JSON before comparing
|
|
324
|
+
delaySeconds: 1 # wait between requests
|
|
325
|
+
removeJsonPaths: # ignore dynamic fields
|
|
326
|
+
- "$.timestamp"
|
|
327
|
+
- "$.requestId"
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Expectation operators
|
|
333
|
+
|
|
334
|
+
All comparators can be negated with `negate: true`.
|
|
335
|
+
|
|
336
|
+
| Comparator | Description | Types |
|
|
337
|
+
|---------------|--------------------------------------|----------------|
|
|
338
|
+
| `equals` | Deep equality | any |
|
|
339
|
+
| `contains` | Substring / JSON-stringified search | string, object |
|
|
340
|
+
| `matches` | Regular expression test | string |
|
|
341
|
+
| `exists` | Value is not null/undefined | any |
|
|
342
|
+
| `greaterThan` | Numeric `>` | number |
|
|
343
|
+
| `lessThan` | Numeric `<` | number |
|
|
344
|
+
|
|
345
|
+
### Negation example
|
|
346
|
+
|
|
347
|
+
```yaml
|
|
348
|
+
expect:
|
|
349
|
+
bodyContains:
|
|
350
|
+
value: "error"
|
|
351
|
+
negate: true # assert the body does NOT contain "error"
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Word-boundary match
|
|
355
|
+
|
|
356
|
+
```yaml
|
|
357
|
+
expect:
|
|
358
|
+
bodyContains:
|
|
359
|
+
value: "ok"
|
|
360
|
+
matchword: true # uses \bok\b regex (whole word only)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Advanced features
|
|
366
|
+
|
|
367
|
+
### Retry on failure
|
|
368
|
+
|
|
369
|
+
```yaml
|
|
370
|
+
- name: flaky service
|
|
371
|
+
retries: 5 # retry up to 5 times, 500ms between attempts
|
|
372
|
+
http:
|
|
373
|
+
url: "http://flaky-service"
|
|
374
|
+
method: GET
|
|
375
|
+
path: /api
|
|
376
|
+
source:
|
|
377
|
+
type: local
|
|
378
|
+
expect:
|
|
379
|
+
statusCode: 200
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Multiple tests in one file
|
|
383
|
+
|
|
384
|
+
Tests run **sequentially** and stop at the first failure (fail-fast).
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
- name: first test
|
|
388
|
+
http: { url: "http://svc", method: GET, path: /ready }
|
|
389
|
+
source: { type: local }
|
|
390
|
+
expect: { statusCode: 200 }
|
|
391
|
+
|
|
392
|
+
- name: second test
|
|
393
|
+
command: { command: "kubectl get pods -n default" }
|
|
394
|
+
source: { type: local }
|
|
395
|
+
expect: { exitCode: 0, stdout: { contains: "Running" } }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Environment variables in URL
|
|
399
|
+
|
|
400
|
+
```yaml
|
|
401
|
+
http:
|
|
402
|
+
url: "$API_BASE_URL" # $VAR or ${VAR}
|
|
403
|
+
headers:
|
|
404
|
+
Authorization: "Bearer ${API_TOKEN}"
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## Debug logging
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
DEBUG_MODE=true YAMLTests -f tests.yaml
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Prints full request/response details, comparison results, and kubectl commands.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Project structure
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
src/
|
|
423
|
+
core.js # Test execution engine (HTTP, command, wait, comparison)
|
|
424
|
+
runner.js # Multi-test orchestration (YAML parsing, fail-fast, retry)
|
|
425
|
+
index.js # Public API
|
|
426
|
+
cli.js # YAMLTests binary entry point
|
|
427
|
+
test/
|
|
428
|
+
unit/ # Pure function tests (compareValue, filterJson, parseCurl, ...)
|
|
429
|
+
integration/ # Real HTTP server + real shell command tests
|
|
430
|
+
e2e/ # CLI binary spawned end-to-end
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Running the test suite
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
npm test # all tests
|
|
439
|
+
npm run test:unit # unit tests only
|
|
440
|
+
npm run test:integration # integration tests only
|
|
441
|
+
npm run test:e2e # end-to-end CLI tests only
|
|
442
|
+
npm run test:coverage # with coverage report
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## CI/CD
|
|
448
|
+
|
|
449
|
+
```yaml
|
|
450
|
+
# .github/workflows/test.yml
|
|
451
|
+
name: Tests
|
|
452
|
+
on: [push, pull_request]
|
|
453
|
+
|
|
454
|
+
jobs:
|
|
455
|
+
test:
|
|
456
|
+
runs-on: ubuntu-latest
|
|
457
|
+
steps:
|
|
458
|
+
- uses: actions/checkout@v4
|
|
459
|
+
- uses: actions/setup-node@v4
|
|
460
|
+
with:
|
|
461
|
+
node-version: '20'
|
|
462
|
+
- run: npm install
|
|
463
|
+
- run: npm test
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## License
|
|
469
|
+
|
|
470
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yamltest",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Declarative YAML-based HTTP, command, and Kubernetes test runner",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"YAMLTests": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.0.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"test:coverage": "vitest run --coverage",
|
|
20
|
+
"test:unit": "vitest run test/unit",
|
|
21
|
+
"test:integration": "vitest run test/integration",
|
|
22
|
+
"test:e2e": "vitest run test/e2e"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"yaml",
|
|
26
|
+
"test",
|
|
27
|
+
"http",
|
|
28
|
+
"kubernetes",
|
|
29
|
+
"declarative",
|
|
30
|
+
"testing"
|
|
31
|
+
],
|
|
32
|
+
"author": "",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"axios": "^1.6.0",
|
|
36
|
+
"deep-diff": "^1.0.2",
|
|
37
|
+
"fs-extra": "^11.2.0",
|
|
38
|
+
"js-yaml": "^4.1.0",
|
|
39
|
+
"jsonpath-plus": "^9.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@vitest/coverage-v8": "^1.6.0",
|
|
43
|
+
"vitest": "^1.6.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* YAMLTests CLI
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* YAMLTests -f <file.yaml>
|
|
9
|
+
* YAMLTests -f - # read from stdin
|
|
10
|
+
* YAMLTests -f <<EOF
|
|
11
|
+
* - name: test
|
|
12
|
+
* http: { url: "http://...", method: GET, path: "/" }
|
|
13
|
+
* source: { type: local }
|
|
14
|
+
* expect: { statusCode: 200 }
|
|
15
|
+
* EOF
|
|
16
|
+
*
|
|
17
|
+
* Exit codes:
|
|
18
|
+
* 0 – all tests passed
|
|
19
|
+
* 1 – one or more tests failed / usage error
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { runTests } = require('./runner');
|
|
25
|
+
|
|
26
|
+
// ── ANSI colours (disabled when NO_COLOR is set or stdout is not a TTY) ──────
|
|
27
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
28
|
+
const c = {
|
|
29
|
+
green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
|
30
|
+
red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
31
|
+
yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
|
32
|
+
bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
33
|
+
dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const args = argv.slice(2); // strip node + script
|
|
39
|
+
const opts = { file: null };
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < args.length; i++) {
|
|
42
|
+
if (args[i] === '-f' || args[i] === '--file') {
|
|
43
|
+
opts.file = args[i + 1] || null;
|
|
44
|
+
i++;
|
|
45
|
+
} else if (args[i].startsWith('-f=')) {
|
|
46
|
+
opts.file = args[i].slice(3);
|
|
47
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
48
|
+
opts.help = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return opts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function printUsage() {
|
|
56
|
+
process.stdout.write(
|
|
57
|
+
[
|
|
58
|
+
'',
|
|
59
|
+
c.bold('YAMLTests') + ' – declarative YAML test runner',
|
|
60
|
+
'',
|
|
61
|
+
c.bold('USAGE'),
|
|
62
|
+
' YAMLTests -f <file.yaml>',
|
|
63
|
+
' YAMLTests -f - # read YAML from stdin',
|
|
64
|
+
' YAMLTests -f <<EOF',
|
|
65
|
+
' - name: my-test',
|
|
66
|
+
' http:',
|
|
67
|
+
' url: "http://example.com"',
|
|
68
|
+
' method: GET',
|
|
69
|
+
' path: "/"',
|
|
70
|
+
' source:',
|
|
71
|
+
' type: local',
|
|
72
|
+
' expect:',
|
|
73
|
+
' statusCode: 200',
|
|
74
|
+
' EOF',
|
|
75
|
+
'',
|
|
76
|
+
c.bold('OPTIONS'),
|
|
77
|
+
' -f, --file <path|-> YAML file to run, or - for stdin',
|
|
78
|
+
' -h, --help Show this help',
|
|
79
|
+
'',
|
|
80
|
+
c.bold('ENVIRONMENT'),
|
|
81
|
+
' DEBUG_MODE=true Enable verbose debug logging',
|
|
82
|
+
' NO_COLOR=1 Disable ANSI colour output',
|
|
83
|
+
'',
|
|
84
|
+
].join('\n')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Input reading ─────────────────────────────────────────────────────────────
|
|
89
|
+
function readStdin() {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let data = '';
|
|
92
|
+
process.stdin.setEncoding('utf8');
|
|
93
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
94
|
+
process.stdin.on('end', () => resolve(data));
|
|
95
|
+
process.stdin.on('error', reject);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readInput(filePath) {
|
|
100
|
+
if (filePath === '-') {
|
|
101
|
+
return readStdin();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const resolved = path.resolve(filePath);
|
|
105
|
+
if (!fs.existsSync(resolved)) {
|
|
106
|
+
throw new Error(`File not found: ${resolved}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return fs.readFileSync(resolved, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Output formatting ─────────────────────────────────────────────────────────
|
|
113
|
+
function formatDuration(ms) {
|
|
114
|
+
if (ms < 1000) return `${ms}ms`;
|
|
115
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printResults(result) {
|
|
119
|
+
const { total, passed, failed, skipped, results } = result;
|
|
120
|
+
|
|
121
|
+
process.stdout.write('\n');
|
|
122
|
+
|
|
123
|
+
for (const r of results) {
|
|
124
|
+
if (r.skipped) {
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
` ${c.yellow('○')} ${c.dim(r.name)} ${c.dim('(skipped)')}\n`
|
|
127
|
+
);
|
|
128
|
+
} else if (r.passed) {
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
` ${c.green('✓')} ${r.name} ${c.dim(formatDuration(r.durationMs))}` +
|
|
131
|
+
(r.attempts > 1 ? c.dim(` [${r.attempts} attempts]`) : '') +
|
|
132
|
+
'\n'
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
` ${c.red('✗')} ${c.bold(r.name)} ${c.dim(formatDuration(r.durationMs))}\n`
|
|
137
|
+
);
|
|
138
|
+
if (r.error) {
|
|
139
|
+
const lines = r.error.split('\n');
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
process.stdout.write(` ${c.red(line)}\n`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write('\n');
|
|
148
|
+
|
|
149
|
+
const summaryParts = [];
|
|
150
|
+
if (passed > 0) summaryParts.push(c.green(`${passed} passed`));
|
|
151
|
+
if (failed > 0) summaryParts.push(c.red(`${failed} failed`));
|
|
152
|
+
if (skipped > 0) summaryParts.push(c.yellow(`${skipped} skipped`));
|
|
153
|
+
summaryParts.push(`${total} total`);
|
|
154
|
+
|
|
155
|
+
process.stdout.write(` ${summaryParts.join(c.dim(' | '))}\n\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
159
|
+
async function main() {
|
|
160
|
+
const opts = parseArgs(process.argv);
|
|
161
|
+
|
|
162
|
+
if (opts.help) {
|
|
163
|
+
printUsage();
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!opts.file) {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
c.red('Error: ') + 'No input specified. Use -f <file> or -f - for stdin.\n\n'
|
|
170
|
+
);
|
|
171
|
+
printUsage();
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let yamlContent;
|
|
176
|
+
try {
|
|
177
|
+
yamlContent = await readInput(opts.file);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
process.stderr.write(c.red('Error: ') + err.message + '\n');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!yamlContent || !yamlContent.trim()) {
|
|
184
|
+
process.stderr.write(c.red('Error: ') + 'Empty input – no YAML content to run.\n');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let result;
|
|
189
|
+
try {
|
|
190
|
+
result = await runTests(yamlContent);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
process.stderr.write(c.red('Error: ') + err.message + '\n');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
printResults(result);
|
|
197
|
+
|
|
198
|
+
process.exit(result.failed > 0 ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
main().catch((err) => {
|
|
202
|
+
process.stderr.write(c.red('Fatal: ') + (err.message || String(err)) + '\n');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
});
|