@ksz54213/specbridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/index.js +59 -0
- package/package.json +39 -0
- package/src/assertions.js +30 -0
- package/src/endpoint.js +3 -0
- package/src/parser.js +66 -0
- package/src/reporter.js +27 -0
- package/src/runner.js +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SpecBridge
|
|
2
|
+
|
|
3
|
+
> Contract testing CLI — verify HTTP APIs against Gherkin feature files.
|
|
4
|
+
|
|
5
|
+
SpecBridge lets you describe the expected behaviour of an HTTP API in plain-English Gherkin (`.feature`) files and then verify a live service against those contracts from the command line.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Node.js ≥ 18
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @ksz54213/specbridge
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
specbridge verify -f <path-to-feature> -u <base-url>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Option | Alias | Required | Description |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `--file` | `-f` | ✅ | Path to the `.feature` contract file |
|
|
32
|
+
| `--url` | `-u` | ✅ | Base URL of the service to test (e.g. `http://localhost:3000`) |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Writing a Contract
|
|
37
|
+
|
|
38
|
+
Contracts are written in [Gherkin](https://cucumber.io/docs/gherkin/) and stored as `.feature` files.
|
|
39
|
+
Each **Scenario** maps to one API call and a set of assertions.
|
|
40
|
+
|
|
41
|
+
### Supported Steps
|
|
42
|
+
|
|
43
|
+
| Step | Description |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `When I send a "METHOD" request to "/path"` | HTTP request (GET, POST, PUT, DELETE, …) |
|
|
46
|
+
| `And the request body is:` + doc-string | JSON request body |
|
|
47
|
+
| `Then the response status should be <code>` | Assert HTTP status code |
|
|
48
|
+
| `Then the response body should be:` + doc-string | Assert exact JSON response (key-by-key) |
|
|
49
|
+
| `Then the response body should contain field "key" with value "val"` | Assert a single field value |
|
|
50
|
+
|
|
51
|
+
> A scenario **must** include at least one `When` step and one `Then` status step to be executed.
|
|
52
|
+
> Scenarios missing these are skipped with a warning.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## End-to-End Example
|
|
57
|
+
|
|
58
|
+
The `example-api/` directory contains a small Node.js server and `example.feature` provides its contract.
|
|
59
|
+
|
|
60
|
+
### 1 — Start the example API
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cd example-api
|
|
64
|
+
npm start
|
|
65
|
+
# Example API running at http://localhost:3000
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2 — Run contract verification
|
|
69
|
+
|
|
70
|
+
From the project root:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
specbridge verify -f example.feature -u http://localhost:3000
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 3 — Expected output
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
✅ Scenario: Health check endpoint
|
|
80
|
+
GET http://localhost:3000/api/health
|
|
81
|
+
✅ Scenario: Get user by ID
|
|
82
|
+
GET http://localhost:3000/api/users/1
|
|
83
|
+
✅ Scenario: Create a new user
|
|
84
|
+
POST http://localhost:3000/api/users
|
|
85
|
+
────────────────────────────────
|
|
86
|
+
Results: 3 passed, 0 failed
|
|
87
|
+
────────────────────────────────
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### The contract file (`example.feature`)
|
|
91
|
+
|
|
92
|
+
```gherkin
|
|
93
|
+
Feature: API Contract Verification
|
|
94
|
+
|
|
95
|
+
Scenario: Health check endpoint
|
|
96
|
+
When I send a "GET" request to "/api/health"
|
|
97
|
+
Then the response status should be 200
|
|
98
|
+
Then the response body should contain field "status" with value "ok"
|
|
99
|
+
|
|
100
|
+
Scenario: Get user by ID
|
|
101
|
+
When I send a "GET" request to "/api/users/1"
|
|
102
|
+
Then the response status should be 200
|
|
103
|
+
Then the response body should be:
|
|
104
|
+
"""
|
|
105
|
+
{ "id": 1, "name": "John" }
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
Scenario: Create a new user
|
|
109
|
+
When I send a "POST" request to "/api/users"
|
|
110
|
+
And the request body is:
|
|
111
|
+
"""
|
|
112
|
+
{ "name": "Alice", "email": "alice@example.com" }
|
|
113
|
+
"""
|
|
114
|
+
Then the response status should be 201
|
|
115
|
+
Then the response body should contain field "name" with value "Alice"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### The example API endpoints
|
|
119
|
+
|
|
120
|
+
| Method | Path | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `GET` | `/api/health` | Health check — returns `{ "status": "ok" }` |
|
|
123
|
+
| `GET` | `/api/users/:id` | Fetch a user by ID |
|
|
124
|
+
| `POST` | `/api/users` | Create a new user (requires `name` in body) |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Exit Codes
|
|
129
|
+
|
|
130
|
+
| Code | Meaning |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `0` | All scenarios passed |
|
|
133
|
+
| `1` | One or more scenarios failed, or the feature file was not found / could not be parsed |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Running Tests
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm test
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { parseFeatureFile } from './src/parser.js'
|
|
5
|
+
import { buildEndpoint } from './src/endpoint.js'
|
|
6
|
+
import { runScenario } from './src/runner.js'
|
|
7
|
+
import { reportScenario, reportSkipped, reportSummary } from './src/reporter.js'
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('specbridge')
|
|
11
|
+
.description('Contract testing CLI — verify HTTP APIs against Gherkin feature files')
|
|
12
|
+
.version('0.1.0')
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command('verify')
|
|
16
|
+
.description('Verify a contract defined in a Gherkin feature file')
|
|
17
|
+
.requiredOption('-f, --file <path>', 'Path to the .feature file')
|
|
18
|
+
.requiredOption('-u, --url <url>', 'Base URL of the service to test')
|
|
19
|
+
.action(async (opts) => {
|
|
20
|
+
if (!existsSync(opts.file)) {
|
|
21
|
+
console.error(`\x1b[31mError: Feature File not found: ${opts.file}\x1b[0m`)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let scenarios
|
|
26
|
+
try {
|
|
27
|
+
scenarios = await parseFeatureFile(opts.file)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(`\x1b[31mError: Failed to parse feature file: ${err.message}\x1b[0m`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (scenarios.length === 0) {
|
|
34
|
+
console.log('\x1b[33mNo scenarios found in feature file\x1b[0m')
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let passed = 0
|
|
39
|
+
let failed = 0
|
|
40
|
+
|
|
41
|
+
for (const scenario of scenarios) {
|
|
42
|
+
if (!scenario.method || scenario.expectedStatus === null) {
|
|
43
|
+
reportSkipped(scenario.name)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const endpoint = buildEndpoint(opts.url, scenario.path)
|
|
48
|
+
const result = await runScenario(scenario, opts.url)
|
|
49
|
+
reportScenario(scenario.name, endpoint, result)
|
|
50
|
+
|
|
51
|
+
if (result.pass) passed++
|
|
52
|
+
else failed++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reportSummary(passed, failed)
|
|
56
|
+
process.exit(failed > 0 ? 1 : 0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
program.parse()
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ksz54213/specbridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Contract testing CLI tool — verify HTTP APIs against Gherkin feature files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"specbridge": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"index.js"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test --test-reporter=spec",
|
|
15
|
+
"test:watch": "node --test --watch"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"contract-testing",
|
|
19
|
+
"gherkin",
|
|
20
|
+
"api",
|
|
21
|
+
"cli",
|
|
22
|
+
"bdd"
|
|
23
|
+
],
|
|
24
|
+
"author": "James Hsueh",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cucumber/gherkin": "^29.0.0",
|
|
28
|
+
"@cucumber/messages": "^26.0.0",
|
|
29
|
+
"axios": "^1.7.0",
|
|
30
|
+
"chai": "^5.0.0",
|
|
31
|
+
"commander": "^12.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"nock": "^13.5.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function assertStatusCode(actual, expected) {
|
|
2
|
+
if (actual === expected) return { pass: true }
|
|
3
|
+
return { pass: false, message: `Status Code: expected ${expected}, got ${actual}` }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function assertBodyExact(actual, expected) {
|
|
7
|
+
try {
|
|
8
|
+
for (const [key, val] of Object.entries(expected)) {
|
|
9
|
+
const actualVal = actual[key]
|
|
10
|
+
if (JSON.stringify(actualVal) !== JSON.stringify(val)) {
|
|
11
|
+
return {
|
|
12
|
+
pass: false,
|
|
13
|
+
message: `Response Body mismatch: expected field "${key}" to equal ${JSON.stringify(val)}, got ${JSON.stringify(actualVal)}`
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { pass: true }
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return { pass: false, message: `Response Body assertion error: ${err.message}` }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertBodyField(actual, field, value) {
|
|
24
|
+
const actualVal = String(actual[field] ?? '')
|
|
25
|
+
if (actualVal === value) return { pass: true }
|
|
26
|
+
return {
|
|
27
|
+
pass: false,
|
|
28
|
+
message: `Response Body mismatch: expected field "${field}" to equal "${value}", got "${actualVal}"`
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/endpoint.js
ADDED
package/src/parser.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { generateMessages } from '@cucumber/gherkin'
|
|
2
|
+
import { IdGenerator, SourceMediaType } from '@cucumber/messages'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
export async function parseFeatureFile(filePath) {
|
|
6
|
+
const source = await readFile(filePath, 'utf8')
|
|
7
|
+
const envelopes = generateMessages(source, filePath, SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, {
|
|
8
|
+
includeSource: false,
|
|
9
|
+
includeGherkinDocument: false,
|
|
10
|
+
includePickles: true,
|
|
11
|
+
newId: IdGenerator.incrementing()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const errors = envelopes.filter(e => e.parseError)
|
|
15
|
+
if (errors.length > 0) throw new Error(errors[0].parseError.message.text)
|
|
16
|
+
|
|
17
|
+
return envelopes.filter(e => e.pickle).map(pickleToScenario)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pickleToScenario(envelope) {
|
|
21
|
+
const pickle = envelope.pickle
|
|
22
|
+
const scenario = {
|
|
23
|
+
name: pickle.name,
|
|
24
|
+
method: null,
|
|
25
|
+
path: null,
|
|
26
|
+
requestBody: null,
|
|
27
|
+
expectedStatus: null,
|
|
28
|
+
expectedBody: null,
|
|
29
|
+
expectedFields: []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const step of pickle.steps) {
|
|
33
|
+
const text = step.text
|
|
34
|
+
const docContent = step.argument?.docString?.content ?? null
|
|
35
|
+
|
|
36
|
+
const actionMatch = text.match(/^I send a "([A-Z]+)" request to "([^"]+)"$/)
|
|
37
|
+
if (actionMatch) {
|
|
38
|
+
scenario.method = actionMatch[1]
|
|
39
|
+
scenario.path = actionMatch[2]
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (/^the request body is:?$/.test(text) && docContent) {
|
|
44
|
+
scenario.requestBody = JSON.parse(docContent)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusMatch = text.match(/^the response status should be (\d+)$/)
|
|
49
|
+
if (statusMatch) {
|
|
50
|
+
scenario.expectedStatus = parseInt(statusMatch[1], 10)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (/^the response body should be:?$/.test(text) && docContent) {
|
|
55
|
+
scenario.expectedBody = JSON.parse(docContent)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fieldMatch = text.match(/^the response body should contain field "([^"]+)" with value "([^"]+)"$/)
|
|
60
|
+
if (fieldMatch) {
|
|
61
|
+
scenario.expectedFields.push({ field: fieldMatch[1], value: fieldMatch[2] })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return scenario
|
|
66
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const GREEN = '\x1b[32m'
|
|
2
|
+
const RED = '\x1b[31m'
|
|
3
|
+
const YELLOW = '\x1b[33m'
|
|
4
|
+
const RESET = '\x1b[0m'
|
|
5
|
+
|
|
6
|
+
export function reportScenario(scenarioName, endpoint, result) {
|
|
7
|
+
if (result.pass) {
|
|
8
|
+
console.log(`${GREEN}✅ Scenario: ${scenarioName}${RESET}`)
|
|
9
|
+
console.log(` ${endpoint}`)
|
|
10
|
+
} else {
|
|
11
|
+
console.log(`${RED}❌ Scenario: ${scenarioName}${RESET}`)
|
|
12
|
+
console.log(` ${endpoint}`)
|
|
13
|
+
for (const msg of result.messages) {
|
|
14
|
+
console.log(`${RED} ${msg}${RESET}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function reportSkipped(scenarioName) {
|
|
20
|
+
console.log(`${YELLOW}⚠️ Scenario skipped: ${scenarioName} — missing required steps (When + Then status)${RESET}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function reportSummary(passed, failed) {
|
|
24
|
+
console.log('────────────────────────────────')
|
|
25
|
+
console.log(` Results: ${passed} passed, ${failed} failed`)
|
|
26
|
+
console.log('────────────────────────────────')
|
|
27
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import { buildEndpoint } from './endpoint.js'
|
|
3
|
+
import { assertStatusCode, assertBodyExact, assertBodyField } from './assertions.js'
|
|
4
|
+
|
|
5
|
+
export async function runScenario(scenario, baseUrl) {
|
|
6
|
+
const endpoint = buildEndpoint(baseUrl, scenario.path)
|
|
7
|
+
const messages = []
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const response = await axios({
|
|
11
|
+
method: scenario.method,
|
|
12
|
+
url: endpoint,
|
|
13
|
+
data: scenario.requestBody ?? undefined,
|
|
14
|
+
timeout: 30_000,
|
|
15
|
+
validateStatus: () => true,
|
|
16
|
+
responseType: 'text'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const statusResult = assertStatusCode(response.status, scenario.expectedStatus)
|
|
20
|
+
if (!statusResult.pass) messages.push(statusResult.message)
|
|
21
|
+
|
|
22
|
+
if (scenario.expectedBody !== null || scenario.expectedFields.length > 0) {
|
|
23
|
+
let body
|
|
24
|
+
try {
|
|
25
|
+
body = JSON.parse(response.data)
|
|
26
|
+
} catch {
|
|
27
|
+
messages.push(`Response body is not valid JSON: ${String(response.data).slice(0, 100)}`)
|
|
28
|
+
return { pass: false, messages }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (scenario.expectedBody !== null) {
|
|
32
|
+
const bodyResult = assertBodyExact(body, scenario.expectedBody)
|
|
33
|
+
if (!bodyResult.pass) messages.push(bodyResult.message)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const { field, value } of scenario.expectedFields) {
|
|
37
|
+
const fieldResult = assertBodyField(body, field, value)
|
|
38
|
+
if (!fieldResult.pass) messages.push(fieldResult.message)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
messages.push(`Connection failed: ${err.message}`)
|
|
43
|
+
return { pass: false, messages }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { pass: messages.length === 0, messages }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runAll(scenarios, baseUrl) {
|
|
50
|
+
let passed = 0
|
|
51
|
+
let failed = 0
|
|
52
|
+
const results = []
|
|
53
|
+
|
|
54
|
+
for (const scenario of scenarios) {
|
|
55
|
+
const result = await runScenario(scenario, baseUrl)
|
|
56
|
+
results.push({ scenario, ...result })
|
|
57
|
+
if (result.pass) passed++
|
|
58
|
+
else failed++
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { passed, failed, results }
|
|
62
|
+
}
|