@klondikemarlen/expect-http-response 0.0.0-stage → 1.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marlen Brunner
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 CHANGED
@@ -1,3 +1,234 @@
1
- # Temporary Holding Version
1
+ # @klondikemarlen/expect-http-response
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Readable HTTP response failures for Jest and Vitest: focused status errors and contextual body diffs, without unrelated transport internals. One `expect` describes the status and complete body. This dependency-free ESM matcher is registered explicitly with your existing test runner.
4
+
5
+ ## Why This Matcher Exists
6
+
7
+ The goal is readable failures, not satisfying an assertion-count quota. Separate status and body assertions are a useful baseline:
8
+
9
+ ```js
10
+ expect(response.status).toEqual(200)
11
+ expect(response.body).toEqual(expectedBody)
12
+ ```
13
+
14
+ Those assertions keep an incorrect status obvious and give the body its own native diff. A stock matcher can express the combined contract too:
15
+
16
+ ```js
17
+ expect(response).toEqual(expect.objectContaining({ status: 200, body: expectedBody }))
18
+ ```
19
+
20
+ However, a failure against the whole Supertest response can include unrelated headers, request metadata, and raw response text. `toMatchObject` is not an equivalent complete-body replacement: its partial matching can accept unexpected body fields.
21
+
22
+ `toHaveHttpResponse` keeps the focused diagnostics of the two-expect approach while naming one HTTP response contract:
23
+
24
+ ```js
25
+ expect(response).toHaveHttpResponse(200, expectedBody)
26
+ ```
27
+
28
+ - A status-only failure reports the differing statuses without printing the matching body.
29
+ - A body failure uses the active runner's contextual diff, exposing changed nested values rather than dumping both collections or hiding them as `[Object]`.
30
+ - If both differ, both failures are reported; separate assertions normally stop at the first failed status assertion.
31
+ - Failed negation explains that status and body both matched without printing the matching payload twice.
32
+
33
+ For example, a changed field among 100 response records remains visible in the diff (excerpt):
34
+
35
+ ```diff
36
+ "id": 50,
37
+ - "name": "Expected record 50",
38
+ + "name": "Record 50",
39
+ ```
40
+
41
+ This changes the diagnostic boundary, not the equality policy. Complete-body comparison, deliberate asymmetric matches, and the runner's custom equality testers retain their existing meaning.
42
+
43
+ ## Installation
44
+
45
+ Install a published release as a development dependency:
46
+
47
+ ```sh
48
+ npm install --save-dev @klondikemarlen/expect-http-response
49
+ ```
50
+
51
+ Alternatively, install directly from this focused repository:
52
+
53
+ ```sh
54
+ npm install --save-dev github:klondikemarlen/expect-http-response
55
+ ```
56
+
57
+ Use an ESM project (`"type": "module"`) or `.mjs` setup/config files. The package has no runtime dependencies and exports `toHaveHttpResponse` from its root only. Consumers supply their own test runner and explicitly register the matcher; registration is not automatic.
58
+
59
+ ## Registering the Matcher
60
+
61
+ Register once in the setup module for the runner you use. The examples use the runner's own `expect` instance.
62
+
63
+ ### Jest
64
+
65
+ Jest setup files run after Jest initializes its assertion environment:
66
+
67
+ ```js
68
+ // test/setup-http-response-matcher.mjs
69
+ import { expect } from "@jest/globals"
70
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
71
+
72
+ expect.extend({ toHaveHttpResponse })
73
+ ```
74
+
75
+ ```js
76
+ // jest.config.mjs
77
+ export default {
78
+ setupFilesAfterEnv: ["<rootDir>/test/setup-http-response-matcher.mjs"],
79
+ }
80
+ ```
81
+
82
+ For Jest's native ESM mode, invoke the existing Jest command with Node's VM-module support, for example:
83
+
84
+ ```sh
85
+ node --experimental-vm-modules node_modules/jest/bin/jest.js
86
+ ```
87
+
88
+ Preserve the project's existing transforms and test command when integrating the setup.
89
+
90
+ ### Vitest
91
+
92
+ ```js
93
+ // test/setup-http-response-matcher.mjs
94
+ import { expect } from "vitest"
95
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
96
+
97
+ expect.extend({ toHaveHttpResponse })
98
+ ```
99
+
100
+ ```js
101
+ // vitest.config.mjs
102
+ import { defineConfig } from "vitest/config"
103
+
104
+ export default defineConfig({
105
+ test: {
106
+ setupFiles: ["./test/setup-http-response-matcher.mjs"],
107
+ },
108
+ })
109
+ ```
110
+
111
+ ### Other Compatible Runners
112
+
113
+ Import the matcher from the package root and extend the active runner's `expect` instance explicitly. The matcher relies on the runner's equality function and custom matcher context; it does not bundle or select a runner.
114
+
115
+ Focused failures use the runner's `utils.printDiffOrStringify`, which Jest and Vitest supply through `expect.extend`. Other compatible runners need a Jest- or Vitest-compatible version of that utility for contextual diffs; without it, only basic expected/received rendering is available.
116
+
117
+ ## TypeScript
118
+
119
+ The package root resolves to `index.d.mts`. Add a declaration augmentation for each runner you actually install. For Jest's `expect` package:
120
+
121
+ ```ts
122
+ // test/http-response-matchers.d.ts
123
+ import "expect"
124
+
125
+ declare module "expect" {
126
+ interface Matchers<R extends void | Promise<void>, T = unknown> {
127
+ toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
128
+ }
129
+ }
130
+ ```
131
+
132
+ For Vitest:
133
+
134
+ ```ts
135
+ // test/http-response-matchers.d.ts
136
+ import "vitest"
137
+
138
+ declare module "vitest" {
139
+ interface Matchers<R, T> {
140
+ toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
141
+ }
142
+ }
143
+ ```
144
+
145
+ Ensure TypeScript includes the augmentation as well as the tests (merge these entries into the existing `include` paths rather than replacing source entries):
146
+
147
+ ```json
148
+ {
149
+ "include": ["src/**/*.ts", "test/**/*.ts", "test/http-response-matchers.d.ts"]
150
+ }
151
+ ```
152
+
153
+ The TypeScript setup module must also be included if it is written in TypeScript. Explicitly importing `expect` from `vitest` or `@jest/globals` does not require runner globals in `compilerOptions.types`. Do not add an augmentation for a runner that is not installed.
154
+
155
+ For a Node.js project, keep `@types/node` installed and include `"node"` in `compilerOptions.types` if the project's configuration does not already load it. Node types are separate from Jest/Vitest globals.
156
+
157
+ ## Example: Express and Supertest
158
+
159
+ This makes a real request to an Express route. The single assertion is the response contract; it makes no separate claim about persistence or other effects.
160
+
161
+ ```ts
162
+ import express from "express"
163
+ import { expect, test } from "vitest"
164
+ import request from "supertest"
165
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
166
+
167
+ expect.extend({ toHaveHttpResponse })
168
+
169
+ const app = express()
170
+ app.get("/users/:id", (req, res) => {
171
+ res.status(200).json({
172
+ user: { id: Number(req.params.id), name: "Ada" },
173
+ })
174
+ })
175
+
176
+ test("when the user exists, returns the complete user response", async () => {
177
+ const response = await request(app).get("/users/7")
178
+
179
+ expect(response).toHaveHttpResponse(200, {
180
+ user: { id: 7, name: "Ada" },
181
+ })
182
+ })
183
+ ```
184
+
185
+ ## Body Equality, Negation, and Invalid Arguments
186
+
187
+ `toHaveHttpResponse(status, body)` passes only when the numeric response `status` and complete `body` both match. Body comparison uses the active runner's equality function and custom equality testers, plus iterable equality when the runner exposes it, consistent with `toEqual`. Transport fields such as request, socket, or response internals are not inspected.
188
+
189
+ - Body equality is complete: unexpected body keys fail, just as they do with `toEqual`.
190
+ - Asymmetric values such as `expect.objectContaining(...)` deliberately request a partial body match.
191
+ - `.not` negates the combined status-and-body contract. It passes if either status or body differs and fails only when both match.
192
+ - The expected status and received `response.status` must be finite numbers. An Express `res.status()` method is not a sent response status.
193
+ - Invalid inputs throw even through `.not`; they are not treated as a successful negative assertion.
194
+ - Supply the body argument explicitly. Pass `undefined` when the expected response body is undefined.
195
+
196
+ ## Package Development
197
+
198
+ The package's focused test suite includes matcher unit tests using Jest's `expect`, a real Express/Supertest integration test using Vitest, and strict TypeScript registration checks for both runners.
199
+
200
+ ```sh
201
+ npm ci
202
+ npm test
203
+ npm run test:unit
204
+ npm run test:integration
205
+ npm run typecheck
206
+ ```
207
+
208
+ ## Release
209
+
210
+ From the repository's merged `main` branch, verify the package before tagging and publishing. Release publication is not complete until the registry artifact is installed and exercised:
211
+
212
+ ```sh
213
+ npm test
214
+ npm pack --dry-run
215
+ git switch main
216
+ git pull --ff-only origin main
217
+ git tag v1.0.1
218
+ git push origin v1.0.1
219
+ gh release create v1.0.1 --title v1.0.1 --generate-notes
220
+ npm publish --access public
221
+ npm view @klondikemarlen/expect-http-response@1.0.1 version
222
+ ```
223
+
224
+ In a separate consumer project, install the registry artifact:
225
+
226
+ ```sh
227
+ npm install --save-dev @klondikemarlen/expect-http-response@1.0.1
228
+ ```
229
+
230
+ After publishing, use the actual registry installation in a fresh consumer project and run its matcher and TypeScript registration checks. Do not claim npm availability until the publish and registry install have been observed.
231
+
232
+ ## License
233
+
234
+ MIT. See [LICENSE](LICENSE).
package/index.d.mts ADDED
@@ -0,0 +1,59 @@
1
+ type EqualityTester = (this: any, ...values: any[]) => boolean | undefined
2
+
3
+ type EqualityFunction = (
4
+ received: unknown,
5
+ expected: unknown,
6
+ customTesters?: EqualityTester[],
7
+ strictCheck?: boolean
8
+ ) => boolean
9
+
10
+ interface HttpResponseLike {
11
+ readonly status: number
12
+ readonly body?: unknown
13
+ }
14
+
15
+ type JestBodyFormatter = (
16
+ expected: unknown,
17
+ received: unknown,
18
+ expectedLabel: string,
19
+ receivedLabel: string,
20
+ expand: boolean
21
+ ) => string
22
+
23
+ type VitestBodyFormatter = (
24
+ received: unknown,
25
+ expected: unknown,
26
+ options?: { expand?: boolean }
27
+ ) => string | undefined
28
+
29
+ interface MatcherUtils {
30
+ printDiffOrStringify?: JestBodyFormatter | VitestBodyFormatter
31
+ matcherHint?: (
32
+ matcherName: string,
33
+ received?: string,
34
+ expected?: string,
35
+ options?: { isNot?: boolean }
36
+ ) => string
37
+ printExpected?: (value: unknown) => string
38
+ printReceived?: (value: unknown) => string
39
+ iterableEquality?: EqualityTester
40
+ }
41
+
42
+ interface MatcherContext {
43
+ readonly isNot?: boolean
44
+ readonly customTesters?: EqualityTester[]
45
+ readonly equals: EqualityFunction
46
+ readonly utils?: MatcherUtils
47
+ }
48
+
49
+ interface MatcherResult {
50
+ readonly pass: boolean
51
+ readonly message: () => string
52
+ }
53
+
54
+ export declare function toHaveHttpResponse(
55
+ this: MatcherContext,
56
+ received: HttpResponseLike,
57
+ expectedStatus: number,
58
+ expectedBody: unknown
59
+ ): MatcherResult
package/index.mjs ADDED
@@ -0,0 +1,129 @@
1
+ const MATCHER_NAME = "toHaveHttpResponse"
2
+
3
+ export function toHaveHttpResponse(response, expectedStatus, expectedBody) {
4
+ if (arguments.length < 3) {
5
+ throw new TypeError(
6
+ `${MATCHER_NAME} requires an expected status and body. Pass undefined explicitly for an undefined body.`
7
+ )
8
+ }
9
+
10
+ if (typeof expectedStatus !== "number" || !Number.isFinite(expectedStatus)) {
11
+ throw new TypeError("expectedStatus must be a finite number.")
12
+ }
13
+
14
+ if (typeof response !== "object" || response === null) {
15
+ throw new TypeError("received value must be a non-null HTTP response object.")
16
+ }
17
+
18
+ const receivedStatus = response.status
19
+ if (typeof receivedStatus !== "number" || !Number.isFinite(receivedStatus)) {
20
+ throw new TypeError(
21
+ "received response.status must be a finite number; an Express status() method is not a response status value."
22
+ )
23
+ }
24
+
25
+ if (typeof this?.equals !== "function") {
26
+ throw new TypeError("the test runner must provide an equality function for custom matchers.")
27
+ }
28
+
29
+ const equalityTesters = [...(this.customTesters ?? [])]
30
+ const iterableEquality = this.utils?.iterableEquality
31
+ if (typeof iterableEquality === "function" && !equalityTesters.includes(iterableEquality)) {
32
+ equalityTesters.push(iterableEquality)
33
+ }
34
+
35
+ const receivedBody = response.body
36
+ const statusMatches = receivedStatus === expectedStatus
37
+ const bodyMatches = this.equals(receivedBody, expectedBody, equalityTesters)
38
+ const pass = statusMatches && bodyMatches
39
+
40
+ return {
41
+ pass,
42
+ message: () =>
43
+ formatMessage(this, {
44
+ expectedBody,
45
+ expectedStatus,
46
+ receivedBody,
47
+ receivedStatus,
48
+ statusMatches,
49
+ bodyMatches,
50
+ }),
51
+ }
52
+ }
53
+
54
+ function formatMessage(context, result) {
55
+ const hint = matcherHint(context)
56
+ if (context.isNot) {
57
+ return [
58
+ hint,
59
+ "",
60
+ "Expected the response not to match this HTTP contract:",
61
+ `Matched status: ${printValue(context, "printReceived", result.receivedStatus)}`,
62
+ "The status and body both matched.",
63
+ ].join("\n")
64
+ }
65
+
66
+ const lines = [hint]
67
+ if (!result.statusMatches) {
68
+ lines.push(
69
+ "",
70
+ `Status mismatch: expected ${printValue(context, "printExpected", result.expectedStatus)}, received ${printValue(context, "printReceived", result.receivedStatus)}.`
71
+ )
72
+ }
73
+
74
+ if (!result.bodyMatches) {
75
+ lines.push(
76
+ "",
77
+ "Body mismatch:",
78
+ formatBodyDifference(context, result.expectedBody, result.receivedBody)
79
+ )
80
+ }
81
+
82
+ return lines.join("\n")
83
+ }
84
+
85
+ function formatBodyDifference(context, expectedBody, receivedBody) {
86
+ const formatter = context.utils?.printDiffOrStringify
87
+ let difference
88
+
89
+ if (typeof formatter === "function") {
90
+ // Jest uses five positional arguments; Vitest uses three or four, with reversed values.
91
+ if (formatter.length >= 5) {
92
+ difference = formatter(expectedBody, receivedBody, "Expected body", "Received body", false)
93
+ } else {
94
+ difference = formatter(receivedBody, expectedBody, { expand: false })
95
+ }
96
+ }
97
+
98
+ if (typeof difference === "string") {
99
+ return difference
100
+ }
101
+
102
+ return [
103
+ `Expected body: ${printValue(context, "printExpected", expectedBody)}`,
104
+ `Received body: ${printValue(context, "printReceived", receivedBody)}`,
105
+ ].join("\n")
106
+ }
107
+
108
+ function matcherHint(context) {
109
+ if (typeof context.utils?.matcherHint === "function") {
110
+ return context.utils.matcherHint(
111
+ `.${MATCHER_NAME}`,
112
+ undefined,
113
+ "expectedStatus, expectedBody",
114
+ { isNot: context.isNot }
115
+ )
116
+ }
117
+
118
+ const negation = context.isNot ? ".not" : ""
119
+ return `expect(received)${negation}.${MATCHER_NAME}(expectedStatus, expectedBody)`
120
+ }
121
+
122
+ function printValue(context, formatterName, value) {
123
+ const formatter = context.utils?.[formatterName]
124
+ if (typeof formatter === "function") {
125
+ return formatter(value)
126
+ }
127
+
128
+ return String(value)
129
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@klondikemarlen/expect-http-response",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Focused HTTP response failure diagnostics for Jest and Vitest",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.mts",
10
+ "import": "./index.mjs"
11
+ }
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "index.d.mts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "registry": "https://registry.npmjs.org/"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test tests/to-have-http-response.test.mjs && vitest run && tsc --noEmit -p tsconfig.json",
25
+ "test:unit": "node --test tests/to-have-http-response.test.mjs",
26
+ "test:integration": "vitest run",
27
+ "typecheck": "tsc --noEmit -p tsconfig.json"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/klondikemarlen/expect-http-response.git"
32
+ },
33
+ "homepage": "https://github.com/klondikemarlen/expect-http-response#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/klondikemarlen/expect-http-response/issues"
36
+ },
37
+ "engines": {
38
+ "node": ">=20.19.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/express": "^5.0.6",
42
+ "@types/node": "^26.6.5",
43
+ "@types/supertest": "^7.2.1",
44
+ "expect": "^30.5.2",
45
+ "express": "^5.3.0",
46
+ "supertest": "^7.3.1",
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.4"
49
+ }
50
+ }