@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 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
+ }
@@ -0,0 +1,3 @@
1
+ export function buildEndpoint(baseUrl, path) {
2
+ return baseUrl.replace(/\/$/, '') + '/' + path.replace(/^\//, '')
3
+ }
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
+ }
@@ -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
+ }