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 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
+ });