From 272034443b2d2988d8579ebbef9b41c4f888e824 Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 18:45:36 -0800
Subject: [PATCH 1/9] chore(supertest): scaffold @bupkis/supertest package
structure
Add package directory structure with package.json, tsconfig.json, and
LICENSE.md. This provides the foundation for HTTP response assertions
that work with supertest, fetch, and axios responses.
---
package-lock.json | 851 +++++++++++++++++++++++++++++++
packages/supertest/LICENSE.md | 55 ++
packages/supertest/package.json | 73 +++
packages/supertest/src/index.ts | 20 +
packages/supertest/tsconfig.json | 5 +
5 files changed, 1004 insertions(+)
create mode 100644 packages/supertest/LICENSE.md
create mode 100644 packages/supertest/package.json
create mode 100644 packages/supertest/src/index.ts
create mode 100644 packages/supertest/tsconfig.json
diff --git a/package-lock.json b/package-lock.json
index 46ca2fc0..1acf17ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -628,6 +628,10 @@
"resolved": "packages/sinon",
"link": true
},
+ "node_modules/@bupkis/supertest": {
+ "resolved": "packages/supertest",
+ "link": true
+ },
"node_modules/@commitlint/cli": {
"version": "20.2.0",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.2.0.tgz",
@@ -3278,6 +3282,19 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3596,6 +3613,16 @@
"win32"
]
},
+ "node_modules/@paralleldrive/cuid2": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
+ "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.1.5"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4237,6 +4264,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cookiejar": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
+ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4365,6 +4399,13 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/methods": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
+ "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -4463,6 +4504,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/superagent": {
+ "version": "8.1.9",
+ "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
+ "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/cookiejar": "^2.1.5",
+ "@types/methods": "^1.1.4",
+ "@types/node": "*",
+ "form-data": "^4.0.0"
+ }
+ },
+ "node_modules/@types/supertest": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
+ "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/methods": "^1.1.4",
+ "@types/superagent": "^8.1.0"
+ }
+ },
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -5222,6 +5287,47 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5496,6 +5602,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -5525,6 +5638,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5766,6 +5886,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/boxen": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz",
@@ -6529,6 +6674,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
@@ -6575,6 +6733,16 @@
"dot-prop": "^5.1.0"
}
},
+ "node_modules/component-emitter": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
+ "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -6641,6 +6809,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/conventional-changelog-angular": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz",
@@ -6693,6 +6871,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cookiejar": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
+ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -7192,6 +7397,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -7226,6 +7451,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dezalgo": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
+ "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "asap": "^2.0.0",
+ "wrappy": "1"
+ }
+ },
"node_modules/diff": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
@@ -7303,6 +7539,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -7330,6 +7573,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -7640,6 +7893,13 @@
"node": ">=6"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@@ -8250,6 +8510,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -8342,6 +8612,90 @@
"node": ">=12.0.0"
}
},
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/fast-check": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.2.tgz",
@@ -8424,6 +8778,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -8513,6 +8874,28 @@
"node": ">=8"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/find-up": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
@@ -8584,6 +8967,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/formatly": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
@@ -8600,6 +9000,44 @@
"node": ">=18.3.0"
}
},
+ "node_modules/formidable": {
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
+ "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@paralleldrive/cuid2": "^2.2.2",
+ "dezalgo": "^1.0.4",
+ "once": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/tunnckoCore/commissions"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -9115,6 +9553,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -9141,6 +9600,23 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -9285,6 +9761,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/irregular-plurals": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz",
@@ -9673,6 +10159,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -12508,6 +13001,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -12530,6 +13033,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -12546,6 +13062,16 @@
"node": ">= 8"
}
},
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -13130,6 +13656,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -13640,6 +14179,19 @@
],
"license": "MIT"
},
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
@@ -13844,6 +14396,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -14213,6 +14775,20 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -14250,6 +14826,22 @@
],
"license": "MIT"
},
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -14290,6 +14882,22 @@
"node": ">= 0.6"
}
},
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -14706,6 +15314,34 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/router/node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -14812,6 +15448,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -14825,6 +15468,60 @@
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/serve": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz",
@@ -14901,6 +15598,26 @@
"node": "*"
}
},
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/serve/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
@@ -15207,6 +15924,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15529,6 +16253,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -15804,6 +16538,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/superagent": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
+ "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "component-emitter": "^1.3.1",
+ "cookiejar": "^2.1.4",
+ "debug": "^4.3.7",
+ "fast-safe-stringify": "^2.1.1",
+ "form-data": "^4.0.5",
+ "formidable": "^3.5.4",
+ "methods": "^1.1.2",
+ "mime": "2.6.0",
+ "qs": "^6.14.1"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
+ "node_modules/supertest": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz",
+ "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==",
+ "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "methods": "^1.1.2",
+ "superagent": "^10.2.1"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -16150,6 +16920,16 @@
"node": ">=8.0"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/trim-newlines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
@@ -16468,6 +17248,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -16736,6 +17548,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -17809,6 +18631,35 @@
"bupkis": ">=0.15.0",
"sinon": ">=17.0.0"
}
+ },
+ "packages/supertest": {
+ "name": "@bupkis/supertest",
+ "version": "0.0.0",
+ "license": "BlueOak-1.0.0",
+ "devDependencies": {
+ "@types/express": "5.0.3",
+ "@types/supertest": "6.0.3",
+ "express": "5.1.0",
+ "supertest": "7.1.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ },
+ "peerDependencies": {
+ "bupkis": ">=0.15.0"
+ }
+ },
+ "packages/supertest/node_modules/@types/express": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
+ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "*"
+ }
}
}
}
diff --git a/packages/supertest/LICENSE.md b/packages/supertest/LICENSE.md
new file mode 100644
index 00000000..8cb5cc6e
--- /dev/null
+++ b/packages/supertest/LICENSE.md
@@ -0,0 +1,55 @@
+# Blue Oak Model License
+
+Version 1.0.0
+
+## Purpose
+
+This license gives everyone as much permission to work with
+this software as possible, while protecting contributors
+from liability.
+
+## Acceptance
+
+In order to receive this license, you must agree to its
+rules. The rules of this license are both obligations
+under that agreement and conditions to your license.
+You must not do anything with this software that triggers
+a rule that you cannot or will not follow.
+
+## Copyright
+
+Each contributor licenses you to do everything with this
+software that would otherwise infringe that contributor's
+copyright in it.
+
+## Notices
+
+You must ensure that everyone who gets a copy of
+any part of this software from you, with or without
+changes, also gets the text of this license or a link to
+.
+
+## Excuse
+
+If anyone notifies you in writing that you have not
+complied with [Notices](#notices), you can keep your
+license by taking all practical steps to comply within 30
+days after the notice. If you do not do so, your license
+ends immediately.
+
+## Patent
+
+Each contributor licenses you to do everything with this
+software that would otherwise infringe any patent claims
+they can license or become able to license.
+
+## Reliability
+
+No contributor can revoke this license.
+
+## No Liability
+
+**_As far as the law allows, this software comes as is,
+without any warranty or condition, and no contributor
+will be liable to anyone for any damages related to this
+software or this license, under any kind of legal claim._**
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
new file mode 100644
index 00000000..23d5172f
--- /dev/null
+++ b/packages/supertest/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@bupkis/supertest",
+ "version": "0.0.0",
+ "type": "module",
+ "description": "HTTP response assertions for Bupkis - works with supertest, fetch, and axios",
+ "repository": {
+ "directory": "packages/supertest",
+ "type": "git",
+ "url": "git+https://github.com/boneskull/bupkis.git"
+ },
+ "author": {
+ "email": "boneskull@boneskull.com",
+ "name": "Christopher Hiller"
+ },
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ },
+ "main": "./dist/index.cjs",
+ "types": "./dist/index.d.cts",
+ "module": "./dist/index.js",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.cts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "CHANGELOG.md",
+ "dist",
+ "src"
+ ],
+ "keywords": [
+ "bupkis",
+ "supertest",
+ "http",
+ "response",
+ "assert",
+ "assertion",
+ "test",
+ "fetch",
+ "axios"
+ ],
+ "scripts": {
+ "build": "zshy",
+ "prepublishOnly": "npm run build",
+ "test": "npm run test:base -- \"test/*.test.ts\"",
+ "test:base": "node --import tsx --test --test-reporter=spec",
+ "test:dev": "npm run test:base -- --watch \"test/*.test.ts\"",
+ "test:node20": "npm run test:base -- test/*.test.ts",
+ "test:types": "tsd"
+ },
+ "peerDependencies": {
+ "bupkis": ">=0.15.0"
+ },
+ "devDependencies": {
+ "@types/express": "5.0.3",
+ "@types/supertest": "6.0.3",
+ "express": "5.1.0",
+ "supertest": "7.1.1"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "zshy": {
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ }
+ }
+}
diff --git a/packages/supertest/src/index.ts b/packages/supertest/src/index.ts
new file mode 100644
index 00000000..ca59e41e
--- /dev/null
+++ b/packages/supertest/src/index.ts
@@ -0,0 +1,20 @@
+/**
+ * HTTP response assertions for Bupkis.
+ *
+ * @module @bupkis/supertest
+ * @example
+ *
+ * ```ts
+ * import { use } from 'bupkis';
+ * import supertestAssertions from '@bupkis/supertest';
+ * import request from 'supertest';
+ *
+ * const { expect } = use(supertestAssertions);
+ *
+ * const response = await request(app).get('/api/users');
+ * expect(response, 'to have status', 200);
+ * ```
+ */
+
+// Placeholder - will be populated with exports
+export {};
diff --git a/packages/supertest/tsconfig.json b/packages/supertest/tsconfig.json
new file mode 100644
index 00000000..fe916db5
--- /dev/null
+++ b/packages/supertest/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "exclude": ["dist", "node_modules", "**/test/**/*.snap.cjs"],
+ "extends": "../../.config/tsconfig.base.json",
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
From cc1c158ebca9f14ddd2b485753f9d45d801522ba Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:00:21 -0800
Subject: [PATCH 2/9] feat(supertest): implement HTTP response assertions
Add comprehensive HTTP response assertions for testing with supertest,
superagent, fetch, or axios responses. Includes:
- Status assertions (exact code and categories: ok/redirect/client error/server error)
- Header assertions (existence, exact match, regex pattern)
- Body assertions (existence, exact string, JSON, partial object match, regex)
- Redirect assertions (is redirect, redirect to URL/pattern)
All assertions support case-insensitive header matching and both
'headers' and 'header' property names for superagent compatibility.
Co-Authored-By: Claude Opus 4.5
---
packages/supertest/README.md | 670 ++++++++++++++++++
packages/supertest/src/assertions.ts | 750 +++++++++++++++++++++
packages/supertest/src/guards.ts | 95 +++
packages/supertest/src/index.ts | 6 +-
packages/supertest/src/schema.ts | 28 +
packages/supertest/test/assertions.test.ts | 565 ++++++++++++++++
packages/supertest/test/guards.test.ts | 79 +++
7 files changed, 2191 insertions(+), 2 deletions(-)
create mode 100644 packages/supertest/README.md
create mode 100644 packages/supertest/src/assertions.ts
create mode 100644 packages/supertest/src/guards.ts
create mode 100644 packages/supertest/src/schema.ts
create mode 100644 packages/supertest/test/assertions.test.ts
create mode 100644 packages/supertest/test/guards.test.ts
diff --git a/packages/supertest/README.md b/packages/supertest/README.md
new file mode 100644
index 00000000..ae537bcf
--- /dev/null
+++ b/packages/supertest/README.md
@@ -0,0 +1,670 @@
+# @bupkis/supertest
+
+HTTP response assertions for [Bupkis](https://bupkis.zip).
+
+Works with [supertest](https://github.com/ladjs/supertest), [superagent](https://github.com/ladjs/superagent), fetch responses, axios responses, or any object with a numeric `status` property.
+
+## Installation
+
+```bash
+npm install @bupkis/supertest bupkis
+```
+
+## Usage
+
+```typescript
+import { use } from 'bupkis';
+import supertestAssertions from '@bupkis/supertest';
+import request from 'supertest';
+
+const { expect } = use(supertestAssertions);
+
+// Status assertions
+const response = await request(app).get('/api/users');
+expect(response, 'to have status', 200);
+expect(response, 'to have status', 'ok');
+
+// Header assertions
+expect(response, 'to have header', 'content-type');
+expect(response, 'to have header', 'content-type', 'application/json');
+expect(response, 'to have header', 'content-type', /json/);
+
+// Body assertions
+expect(response, 'to have body');
+expect(response, 'to have JSON body');
+expect(response, 'to have JSON body satisfying', { users: [] });
+
+// Redirect assertions
+const redirect = await request(app).get('/old-page');
+expect(redirect, 'to redirect');
+expect(redirect, 'to redirect to', '/new-page');
+```
+
+## Assertions
+
+### {Response} to have status {number}
+
+> Aliases:
+>
+> {Response} to have status {number}
+> {Response} to respond with status {number}
+
+Asserts that a response has a specific HTTP status code.
+
+**Success**:
+
+```js
+expect({ status: 200 }, 'to have status', 200);
+expect({ status: 404 }, 'to have status', 404);
+expect({ status: 500 }, 'to respond with status', 500);
+```
+
+**Failure**:
+
+```js
+expect({ status: 404 }, 'to have status', 200);
+// AssertionError: Expected response to have status 200
+```
+
+**Negation**:
+
+```js
+expect({ status: 404 }, 'not to have status', 200);
+```
+
+### {Response} to have status {category}
+
+> Aliases:
+>
+> {Response} to have status {category}
+> {Response} to respond with status {category}
+
+Asserts that a response has a status code within a category. Valid categories are:
+
+- `'ok'` - 2xx status codes (200-299)
+- `'redirect'` - 3xx status codes (300-399)
+- `'client error'` - 4xx status codes (400-499)
+- `'server error'` - 5xx status codes (500-599)
+
+**Success**:
+
+```js
+expect({ status: 200 }, 'to have status', 'ok');
+expect({ status: 201 }, 'to have status', 'ok');
+expect({ status: 301 }, 'to have status', 'redirect');
+expect({ status: 404 }, 'to have status', 'client error');
+expect({ status: 500 }, 'to have status', 'server error');
+```
+
+**Failure**:
+
+```js
+expect({ status: 404 }, 'to have status', 'ok');
+// AssertionError: Expected response to have ok status
+```
+
+**Negation**:
+
+```js
+expect({ status: 404 }, 'not to have status', 'ok');
+```
+
+### {Response} to have header {string}
+
+> Aliases:
+>
+> {Response} to have header {string}
+> {Response} to include header {string}
+
+Asserts that a response has a specific header (existence check only). Header names are matched case-insensitively.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+};
+expect(response, 'to have header', 'content-type');
+expect(response, 'to have header', 'Content-Type');
+expect(response, 'to include header', 'CONTENT-TYPE');
+```
+
+**Failure**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+};
+expect(response, 'to have header', 'x-custom-header');
+// AssertionError: Expected response to have header "x-custom-header"
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+};
+expect(response, 'not to have header', 'x-custom-header');
+```
+
+### {Response} to have header {string} {string}
+
+> Aliases:
+>
+> {Response} to have header {string} {string}
+> {Response} to include header {string} {string}
+
+Asserts that a response has a header with an exact value.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+};
+expect(response, 'to have header', 'content-type', 'application/json');
+```
+
+**Failure**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+};
+expect(response, 'to have header', 'content-type', 'application/json');
+// AssertionError: Expected header "content-type" to equal "application/json"
+```
+
+**With array header values** (e.g., multiple `Set-Cookie` headers):
+
+```js
+const response = {
+ status: 200,
+ headers: { 'set-cookie': ['a=1', 'b=2'] },
+};
+expect(response, 'to have header', 'set-cookie', 'a=1, b=2');
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+};
+expect(response, 'not to have header', 'content-type', 'application/json');
+```
+
+### {Response} to have header {string} {RegExp}
+
+> Aliases:
+>
+> {Response} to have header {string} {RegExp}
+> {Response} to include header {string} {RegExp}
+
+Asserts that a response has a header matching a regex pattern.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+};
+expect(response, 'to have header', 'content-type', /json/);
+expect(response, 'to have header', 'content-type', /^application\//);
+
+const cacheResponse = {
+ status: 200,
+ headers: { 'cache-control': 'max-age=3600, public' },
+};
+expect(cacheResponse, 'to have header', 'cache-control', /max-age=\d+/);
+```
+
+**Failure**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+};
+expect(response, 'to have header', 'content-type', /json/);
+// AssertionError: Expected header "content-type" to match /json/
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+};
+expect(response, 'not to have header', 'content-type', /json/);
+```
+
+### {Response} to have body
+
+Asserts that a response has a non-empty body. Empty objects `{}` and empty arrays `[]` are considered to have a body (they're valid JSON responses).
+
+**Success**:
+
+```js
+expect({ status: 200, text: 'Hello' }, 'to have body');
+expect({ status: 200, body: { users: [] } }, 'to have body');
+expect({ status: 200, body: {} }, 'to have body'); // empty object counts
+expect({ status: 200, body: [] }, 'to have body'); // empty array counts
+```
+
+**Failure**:
+
+```js
+expect({ status: 200 }, 'to have body');
+// AssertionError: Expected response to have a body
+
+expect({ status: 200, text: '', body: '' }, 'to have body');
+// AssertionError: Expected response to have a body
+```
+
+**Negation**:
+
+```js
+expect({ status: 204 }, 'not to have body');
+```
+
+### {Response} to have body {string}
+
+Asserts that a response has an exact string body.
+
+**Success**:
+
+```js
+expect({ status: 200, text: 'Hello, World!' }, 'to have body', 'Hello, World!');
+
+// Object bodies are stringified for comparison
+expect({ status: 200, body: { id: 1 } }, 'to have body', '{"id":1}');
+```
+
+**Failure**:
+
+```js
+expect({ status: 200, text: 'Hello' }, 'to have body', 'Goodbye');
+// AssertionError: Expected response body to equal string
+```
+
+**Negation**:
+
+```js
+expect({ status: 200, text: 'Hello' }, 'not to have body', 'Goodbye');
+```
+
+### {Response} to have JSON body
+
+Asserts that a response has a JSON content-type and a body. Checks for `application/json` in either the `type` property or `content-type` header.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ type: 'application/json',
+ body: { users: [] },
+};
+expect(response, 'to have JSON body');
+
+// Also works with content-type header
+const response2 = {
+ status: 200,
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+ body: { data: 'test' },
+};
+expect(response2, 'to have JSON body');
+```
+
+**Failure**:
+
+```js
+const htmlResponse = {
+ status: 200,
+ type: 'text/html',
+ body: '',
+};
+expect(htmlResponse, 'to have JSON body');
+// AssertionError: Expected response to have JSON content-type
+
+const emptyResponse = {
+ status: 204,
+ type: 'application/json',
+};
+expect(emptyResponse, 'to have JSON body');
+// AssertionError: Expected response to have a JSON body
+```
+
+**Negation**:
+
+```js
+const htmlResponse = {
+ status: 200,
+ type: 'text/html',
+ body: '',
+};
+expect(htmlResponse, 'not to have JSON body');
+```
+
+### {Response} to have JSON body satisfying {object}
+
+Asserts that a response has a JSON body containing all specified properties with matching values. Uses partial/subset matching - the response may contain additional properties beyond those specified.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1, name: 'John', email: 'john@example.com' },
+};
+
+// Partial match - only checks specified properties
+expect(response, 'to have JSON body satisfying', { id: 1 });
+expect(response, 'to have JSON body satisfying', { name: 'John' });
+expect(response, 'to have JSON body satisfying', { id: 1, name: 'John' });
+
+// Nested objects
+const nestedResponse = {
+ status: 200,
+ body: {
+ user: { id: 1, profile: { name: 'John', age: 30 } },
+ meta: { version: '1.0' },
+ },
+};
+expect(nestedResponse, 'to have JSON body satisfying', {
+ user: { profile: { name: 'John' } },
+});
+
+// Arrays
+const arrayResponse = {
+ status: 200,
+ body: { users: [{ id: 1 }, { id: 2 }] },
+};
+expect(arrayResponse, 'to have JSON body satisfying', {
+ users: [{ id: 1 }, { id: 2 }],
+});
+```
+
+**Failure**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1 },
+};
+expect(response, 'to have JSON body satisfying', { name: 'John' });
+// AssertionError: Expected response body to satisfy specification
+
+const response2 = {
+ status: 200,
+ body: { id: 1, name: 'Jane' },
+};
+expect(response2, 'to have JSON body satisfying', { name: 'John' });
+// AssertionError: Expected response body to satisfy specification
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1, name: 'Jane' },
+};
+expect(response, 'not to have JSON body satisfying', { name: 'John' });
+```
+
+### {Response} to have body satisfying {RegExp}
+
+Asserts that a response body (as text) matches a regex pattern.
+
+**Success**:
+
+```js
+expect(
+ { status: 200, text: 'Hello, World!' },
+ 'to have body satisfying',
+ /World/,
+);
+expect(
+ { status: 200, text: '{"id":123}' },
+ 'to have body satisfying',
+ /"id":\d+/,
+);
+```
+
+**Failure**:
+
+```js
+expect({ status: 200, text: 'Hello' }, 'to have body satisfying', /Goodbye/);
+// AssertionError: Expected response body to match /Goodbye/
+
+expect({ status: 200 }, 'to have body satisfying', /anything/);
+// AssertionError: Expected response to have a body
+```
+
+**Negation**:
+
+```js
+expect(
+ { status: 200, text: 'Hello' },
+ 'not to have body satisfying',
+ /Goodbye/,
+);
+```
+
+### {Response} to have body satisfying {object}
+
+Asserts that a response body satisfies a partial object match. Similar to `to have JSON body satisfying` but doesn't require JSON content-type.
+
+**Success**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1, name: 'John', extra: 'ignored' },
+};
+expect(response, 'to have body satisfying', { id: 1 });
+```
+
+**Failure**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1 },
+};
+expect(response, 'to have body satisfying', { name: 'John' });
+// AssertionError: Expected response body to satisfy specification
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 200,
+ body: { id: 1 },
+};
+expect(response, 'not to have body satisfying', { name: 'John' });
+```
+
+### {Response} to redirect
+
+Asserts that a response is a redirect (has a 3xx status code).
+
+**Success**:
+
+```js
+expect({ status: 301 }, 'to redirect');
+expect({ status: 302 }, 'to redirect');
+expect({ status: 307 }, 'to redirect');
+expect({ status: 308 }, 'to redirect');
+```
+
+**Failure**:
+
+```js
+expect({ status: 200 }, 'to redirect');
+// AssertionError: Expected response to be a redirect, but got status 200
+
+expect({ status: 404 }, 'to redirect');
+// AssertionError: Expected response to be a redirect, but got status 404
+```
+
+**Negation**:
+
+```js
+expect({ status: 200 }, 'not to redirect');
+```
+
+### {Response} to redirect to {string}
+
+Asserts that a response redirects to a specific URL. The response must be a redirect (3xx) and have a `Location` header matching the expected URL exactly.
+
+**Success**:
+
+```js
+const response = {
+ status: 302,
+ headers: { location: '/login' },
+};
+expect(response, 'to redirect to', '/login');
+
+const fullUrl = {
+ status: 301,
+ headers: { location: 'https://example.com/new-page' },
+};
+expect(fullUrl, 'to redirect to', 'https://example.com/new-page');
+```
+
+**Failure**:
+
+```js
+// Not a redirect
+const okResponse = {
+ status: 200,
+ headers: { location: '/somewhere' },
+};
+expect(okResponse, 'to redirect to', '/somewhere');
+// AssertionError: Expected response to be a redirect, but got status 200
+
+// Missing Location header
+expect({ status: 302 }, 'to redirect to', '/login');
+// AssertionError: Expected redirect response to have a Location header
+
+// Location doesn't match
+const response = {
+ status: 302,
+ headers: { location: '/dashboard' },
+};
+expect(response, 'to redirect to', '/login');
+// AssertionError: Expected redirect to "/login"
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 302,
+ headers: { location: '/dashboard' },
+};
+expect(response, 'not to redirect to', '/login');
+```
+
+### {Response} to redirect to {RegExp}
+
+Asserts that a response redirects to a URL matching a pattern. The response must be a redirect (3xx) and have a `Location` header matching the regex.
+
+**Success**:
+
+```js
+const response = {
+ status: 302,
+ headers: { location: '/auth/login?redirect=/dashboard' },
+};
+expect(response, 'to redirect to', /\/auth/);
+expect(response, 'to redirect to', /redirect=/);
+expect(response, 'to redirect to', /^\/auth\/login/);
+```
+
+**Failure**:
+
+```js
+// Not a redirect
+const okResponse = {
+ status: 200,
+ headers: { location: '/somewhere' },
+};
+expect(okResponse, 'to redirect to', /somewhere/);
+// AssertionError: Expected response to be a redirect, but got status 200
+
+// Location doesn't match pattern
+const response = {
+ status: 302,
+ headers: { location: '/dashboard' },
+};
+expect(response, 'to redirect to', /login/);
+// AssertionError: Expected redirect Location to match /login/
+```
+
+**Negation**:
+
+```js
+const response = {
+ status: 302,
+ headers: { location: '/dashboard' },
+};
+expect(response, 'not to redirect to', /login/);
+```
+
+## Compatible Response Objects
+
+This library works with any object that has a numeric `status` property. It's designed to be compatible with:
+
+- **supertest** responses
+- **superagent** responses (uses `header` instead of `headers`)
+- **fetch** responses (after calling `.json()` or similar)
+- **axios** responses
+- Plain objects for testing
+
+```js
+// Minimal response
+expect({ status: 200 }, 'to have status', 200);
+
+// supertest/superagent style
+expect(
+ {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ body: { users: [] },
+ text: '{"users":[]}',
+ type: 'application/json',
+ },
+ 'to have JSON body',
+);
+
+// superagent uses 'header' (singular)
+expect(
+ {
+ status: 200,
+ header: { 'content-type': 'text/html' },
+ },
+ 'to have header',
+ 'content-type',
+);
+```
+
+## License
+
+Copyright © 2026 [Christopher "boneskull" Hiller][boneskull]. Licensed under [BlueOak-1.0.0](https://blueoakcouncil.org/license/1.0.0).
+
+[boneskull]: https://github.com/boneskull
diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts
new file mode 100644
index 00000000..52ab63f4
--- /dev/null
+++ b/packages/supertest/src/assertions.ts
@@ -0,0 +1,750 @@
+/**
+ * HTTP response assertions for Bupkis.
+ *
+ * @packageDocumentation
+ */
+
+import { expect, schema, z } from 'bupkis';
+
+import type { HttpResponse } from './guards.js';
+
+const { isArray } = Array;
+const { entries, keys } = Object;
+
+import { HttpResponseSchema } from './schema.js';
+
+// #region Status Code Assertions
+
+/**
+ * Valid status category names for status assertions.
+ */
+const StatusCategorySchema = z.enum([
+ 'ok',
+ 'redirect',
+ 'client error',
+ 'server error',
+]);
+
+type StatusCategory = z.infer;
+
+/**
+ * Maps status category names to their HTTP status code ranges.
+ */
+const STATUS_RANGES: Record = {
+ 'client error': { max: 499, min: 400 },
+ ok: { max: 299, min: 200 },
+ redirect: { max: 399, min: 300 },
+ 'server error': { max: 599, min: 500 },
+};
+
+/**
+ * Determines the category name for a given status code.
+ *
+ * @function
+ */
+const getStatusCategory = (status: number): string => {
+ if (status >= 200 && status < 300) {
+ return 'ok (2xx)';
+ }
+ if (status >= 300 && status < 400) {
+ return 'redirect (3xx)';
+ }
+ if (status >= 400 && status < 500) {
+ return 'client error (4xx)';
+ }
+ if (status >= 500 && status < 600) {
+ return 'server error (5xx)';
+ }
+ return `unknown (${status})`;
+};
+
+/**
+ * Asserts that a response has a specific HTTP status code.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have status', 200);
+ * expect(response, 'to have status', 404);
+ * ```
+ */
+export const toHaveStatusAssertion = expect.createAssertion(
+ [
+ HttpResponseSchema,
+ ['to have status', 'to respond with status'],
+ z.number(),
+ ],
+ (response: HttpResponse, expected: number) => {
+ if (response.status === expected) {
+ return true;
+ }
+ return {
+ actual: response.status,
+ expected,
+ message: `Expected response to have status ${expected}`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response has a status code within a category.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have status', 'ok'); // 2xx
+ * expect(response, 'to have status', 'redirect'); // 3xx
+ * expect(response, 'to have status', 'client error'); // 4xx
+ * expect(response, 'to have status', 'server error'); // 5xx
+ * ```
+ */
+export const toHaveStatusCategoryAssertion = expect.createAssertion(
+ [
+ HttpResponseSchema,
+ ['to have status', 'to respond with status'],
+ StatusCategorySchema,
+ ],
+ (response: HttpResponse, category: StatusCategory) => {
+ const range = STATUS_RANGES[category];
+ if (response.status >= range.min && response.status <= range.max) {
+ return true;
+ }
+ return {
+ actual: `${response.status} (${getStatusCategory(response.status)})`,
+ expected: `${category} (${range.min}-${range.max})`,
+ message: `Expected response to have ${category} status`,
+ };
+ },
+);
+
+// #endregion
+
+// #region Header Assertions
+
+/**
+ * Gets the headers object from a response, handling both `headers` and `header`
+ * property names.
+ *
+ * @function
+ */
+const getHeaders = (
+ response: HttpResponse,
+): Record | undefined =>
+ response.headers ?? response.header;
+
+/**
+ * Gets a header value by name (case-insensitive).
+ *
+ * @function
+ * @param response - The HTTP response
+ * @param name - The header name to look up
+ * @returns The header value, or undefined if not found
+ */
+const getHeaderValue = (
+ response: HttpResponse,
+ name: string,
+): string | string[] | undefined => {
+ const headers = getHeaders(response);
+ if (!headers) {
+ return undefined;
+ }
+
+ // HTTP headers are case-insensitive, so we need to search
+ const lowerName = name.toLowerCase();
+ for (const [key, value] of entries(headers)) {
+ if (key.toLowerCase() === lowerName) {
+ return value;
+ }
+ }
+ return undefined;
+};
+
+/**
+ * Asserts that a response has a specific header (existence check only).
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have header', 'content-type');
+ * expect(response, 'to have header', 'X-Request-Id');
+ * ```
+ */
+export const toHaveHeaderAssertion = expect.createAssertion(
+ [HttpResponseSchema, ['to have header', 'to include header'], z.string()],
+ (response: HttpResponse, headerName: string) => {
+ const value = getHeaderValue(response, headerName);
+ if (value !== undefined) {
+ return true;
+ }
+ const headers = getHeaders(response);
+ return {
+ actual: headers ? keys(headers).join(', ') : 'no headers',
+ expected: headerName,
+ message: `Expected response to have header "${headerName}"`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response has a header with an exact value.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have header', 'content-type', 'application/json');
+ * expect(response, 'to have header', 'cache-control', 'no-cache');
+ * ```
+ */
+export const toHaveHeaderValueAssertion = expect.createAssertion(
+ [
+ HttpResponseSchema,
+ ['to have header', 'to include header'],
+ z.string(),
+ z.string(),
+ ],
+ (response: HttpResponse, headerName: string, expectedValue: string) => {
+ const actualValue = getHeaderValue(response, headerName);
+
+ if (actualValue === undefined) {
+ const headers = getHeaders(response);
+ return {
+ actual: headers ? keys(headers).join(', ') : 'no headers',
+ expected: `${headerName}: ${expectedValue}`,
+ message: `Expected response to have header "${headerName}"`,
+ };
+ }
+
+ // Handle array values (multiple headers with same name)
+ const actualString = isArray(actualValue)
+ ? actualValue.join(', ')
+ : actualValue;
+
+ if (actualString === expectedValue) {
+ return true;
+ }
+
+ return {
+ actual: actualString,
+ expected: expectedValue,
+ message: `Expected header "${headerName}" to equal "${expectedValue}"`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response has a header matching a regex pattern.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have header', 'content-type', /json/);
+ * expect(response, 'to have header', 'cache-control', /max-age=\d+/);
+ * ```
+ */
+export const toHaveHeaderMatchingAssertion = expect.createAssertion(
+ [
+ HttpResponseSchema,
+ ['to have header', 'to include header'],
+ z.string(),
+ z.instanceof(RegExp),
+ ],
+ (response: HttpResponse, headerName: string, pattern: RegExp) => {
+ const actualValue = getHeaderValue(response, headerName);
+
+ if (actualValue === undefined) {
+ const headers = getHeaders(response);
+ return {
+ actual: headers ? keys(headers).join(', ') : 'no headers',
+ expected: `${headerName} matching ${pattern}`,
+ message: `Expected response to have header "${headerName}"`,
+ };
+ }
+
+ // Handle array values (multiple headers with same name)
+ const actualString = isArray(actualValue)
+ ? actualValue.join(', ')
+ : actualValue;
+
+ if (pattern.test(actualString)) {
+ return true;
+ }
+
+ return {
+ actual: actualString,
+ expected: `matching ${pattern}`,
+ message: `Expected header "${headerName}" to match ${pattern}`,
+ };
+ },
+);
+
+// #endregion
+
+// #region Body Assertions
+
+const { stringify } = JSON;
+
+/**
+ * Gets the body content as a string for display/comparison.
+ *
+ * @function
+ */
+const getBodyText = (response: HttpResponse): string | undefined => {
+ if (response.text !== undefined) {
+ return response.text;
+ }
+ if (response.body !== undefined) {
+ return typeof response.body === 'string'
+ ? response.body
+ : stringify(response.body);
+ }
+ return undefined;
+};
+
+/**
+ * Checks if the response has a non-empty body.
+ *
+ * @function
+ */
+const hasBody = (response: HttpResponse): boolean => {
+ // Check text first (raw body)
+ if (response.text !== undefined && response.text !== '') {
+ return true;
+ }
+ // Check parsed body
+ if (response.body !== undefined) {
+ // Empty object {} or empty array [] still counts as "has body"
+ if (typeof response.body === 'object' && response.body !== null) {
+ return true;
+ }
+ // Non-empty string
+ if (typeof response.body === 'string' && response.body !== '') {
+ return true;
+ }
+ // Other truthy values
+ if (response.body) {
+ return true;
+ }
+ }
+ return false;
+};
+
+/**
+ * Checks if the response appears to be JSON based on content-type.
+ *
+ * @function
+ */
+const isJsonResponse = (response: HttpResponse): boolean => {
+ // Check the type property (supertest normalizes this)
+ if (response.type) {
+ return (
+ response.type.includes('json') ||
+ response.type.includes('application/json')
+ );
+ }
+
+ // Fall back to checking headers
+ const contentType = getHeaderValue(response, 'content-type');
+ if (contentType) {
+ const value = isArray(contentType) ? contentType.join(', ') : contentType;
+ return value.includes('json');
+ }
+
+ return false;
+};
+
+/**
+ * Performs a deep partial match - checks if all properties in expected exist in
+ * actual with matching values.
+ *
+ * @function
+ */
+const deepPartialMatch = (actual: unknown, expected: unknown): boolean => {
+ // Exact match for primitives
+ if (expected === actual) {
+ return true;
+ }
+
+ // Handle null
+ if (expected === null || actual === null) {
+ return expected === actual;
+ }
+
+ // Both must be objects for partial matching
+ if (typeof expected !== 'object' || typeof actual !== 'object') {
+ return false;
+ }
+
+ // Handle arrays - must have same length and matching elements
+ if (isArray(expected)) {
+ if (!isArray(actual) || actual.length !== expected.length) {
+ return false;
+ }
+ return expected.every((item, index) =>
+ deepPartialMatch(actual[index], item),
+ );
+ }
+
+ // Object partial match - all keys in expected must exist and match in actual
+ const expectedObj = expected as Record;
+ const actualObj = actual as Record;
+
+ for (const key of keys(expectedObj)) {
+ if (!(key in actualObj)) {
+ return false;
+ }
+ if (!deepPartialMatch(actualObj[key], expectedObj[key])) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+/**
+ * Asserts that a response has a non-empty body.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have body');
+ * ```
+ */
+export const toHaveBodyAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have body'],
+ (response: HttpResponse) => {
+ if (hasBody(response)) {
+ return true;
+ }
+ return {
+ actual: 'empty or no body',
+ expected: 'a response body',
+ message: 'Expected response to have a body',
+ };
+ },
+);
+
+/**
+ * Asserts that a response has an exact string body.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have body', 'Hello, World!');
+ * expect(response, 'to have body', '{"users":[]}');
+ * ```
+ */
+export const toHaveBodyStringAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have body', z.string()],
+ (response: HttpResponse, expected: string) => {
+ const bodyText = getBodyText(response);
+
+ if (bodyText === expected) {
+ return true;
+ }
+
+ return {
+ actual: bodyText ?? 'no body',
+ expected,
+ message: 'Expected response body to equal string',
+ };
+ },
+);
+
+/**
+ * Asserts that a response has a JSON content-type and a body.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have JSON body');
+ * ```
+ */
+export const toHaveJsonBodyAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have JSON body'],
+ (response: HttpResponse) => {
+ if (!isJsonResponse(response)) {
+ const contentType =
+ response.type ?? getHeaderValue(response, 'content-type') ?? 'unknown';
+ return {
+ actual: contentType,
+ expected: 'application/json',
+ message: 'Expected response to have JSON content-type',
+ };
+ }
+
+ if (!hasBody(response)) {
+ return {
+ actual: 'empty body',
+ expected: 'JSON body',
+ message: 'Expected response to have a JSON body',
+ };
+ }
+
+ return true;
+ },
+);
+
+/**
+ * Asserts that a response has a JSON body satisfying a partial match.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have JSON body satisfying', { users: [] });
+ * expect(response, 'to have JSON body satisfying', { status: 'ok' });
+ * ```
+ */
+export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have JSON body satisfying', schema.AnyObjectSchema],
+ (response: HttpResponse, expected: Record) => {
+ const body = response.body;
+
+ if (body === undefined || body === null) {
+ return {
+ actual: 'no body',
+ expected: stringify(expected),
+ message: 'Expected response to have a body',
+ };
+ }
+
+ if (typeof body !== 'object') {
+ return {
+ actual: typeof body,
+ expected: 'object',
+ message: 'Expected response body to be an object',
+ };
+ }
+
+ if (deepPartialMatch(body, expected)) {
+ return true;
+ }
+
+ return {
+ actual: stringify(body),
+ expected: stringify(expected),
+ message: 'Expected response body to satisfy specification',
+ };
+ },
+);
+
+/**
+ * Asserts that a response body matches a regex pattern.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have body satisfying', /success/);
+ * expect(response, 'to have body satisfying', /"id":\d+/);
+ * ```
+ */
+export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have body satisfying', z.instanceof(RegExp)],
+ (response: HttpResponse, pattern: RegExp) => {
+ const bodyText = getBodyText(response);
+
+ if (bodyText === undefined) {
+ return {
+ actual: 'no body',
+ expected: `matching ${pattern}`,
+ message: 'Expected response to have a body',
+ };
+ }
+
+ if (pattern.test(bodyText)) {
+ return true;
+ }
+
+ return {
+ actual: bodyText,
+ expected: `matching ${pattern}`,
+ message: `Expected response body to match ${pattern}`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response body satisfies a partial object match.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to have body satisfying', { users: [] });
+ * ```
+ */
+export const toHaveBodySatisfyingObjectAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to have body satisfying', schema.AnyObjectSchema],
+ (response: HttpResponse, expected: Record) => {
+ const body = response.body;
+
+ if (body === undefined || body === null) {
+ return {
+ actual: 'no body',
+ expected: stringify(expected),
+ message: 'Expected response to have a body',
+ };
+ }
+
+ if (typeof body !== 'object') {
+ return {
+ actual: typeof body,
+ expected: 'object',
+ message: 'Expected response body to be an object',
+ };
+ }
+
+ if (deepPartialMatch(body, expected)) {
+ return true;
+ }
+
+ return {
+ actual: stringify(body),
+ expected: stringify(expected),
+ message: 'Expected response body to satisfy specification',
+ };
+ },
+);
+
+// #endregion
+
+// #region Redirect Assertions
+
+/**
+ * Checks if the response is a redirect (3xx status code).
+ *
+ * @function
+ */
+const isRedirect = (response: HttpResponse): boolean =>
+ response.status >= 300 && response.status < 400;
+
+/**
+ * Asserts that a response is a redirect (3xx status code).
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to redirect');
+ * ```
+ */
+export const toRedirectAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to redirect'],
+ (response: HttpResponse) => {
+ if (isRedirect(response)) {
+ return true;
+ }
+ return {
+ actual: response.status,
+ expected: '3xx redirect status',
+ message: `Expected response to be a redirect, but got status ${response.status}`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response redirects to a specific URL.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to redirect to', '/login');
+ * expect(response, 'to redirect to', 'https://example.com/auth');
+ * ```
+ */
+export const toRedirectToUrlAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to redirect to', z.string()],
+ (response: HttpResponse, expectedUrl: string) => {
+ if (!isRedirect(response)) {
+ return {
+ actual: response.status,
+ expected: '3xx redirect status',
+ message: `Expected response to be a redirect, but got status ${response.status}`,
+ };
+ }
+
+ const location = getHeaderValue(response, 'location');
+ if (location === undefined) {
+ return {
+ actual: 'no Location header',
+ expected: expectedUrl,
+ message: 'Expected redirect response to have a Location header',
+ };
+ }
+
+ const locationString = isArray(location) ? location[0] : location;
+
+ if (locationString === expectedUrl) {
+ return true;
+ }
+
+ return {
+ actual: locationString,
+ expected: expectedUrl,
+ message: `Expected redirect to "${expectedUrl}"`,
+ };
+ },
+);
+
+/**
+ * Asserts that a response redirects to a URL matching a pattern.
+ *
+ * @example
+ *
+ * ```ts
+ * expect(response, 'to redirect to', /\/auth/);
+ * expect(response, 'to redirect to', /login\?redirect=/);
+ * ```
+ */
+export const toRedirectToPatternAssertion = expect.createAssertion(
+ [HttpResponseSchema, 'to redirect to', z.instanceof(RegExp)],
+ (response: HttpResponse, pattern: RegExp) => {
+ if (!isRedirect(response)) {
+ return {
+ actual: response.status,
+ expected: '3xx redirect status',
+ message: `Expected response to be a redirect, but got status ${response.status}`,
+ };
+ }
+
+ const location = getHeaderValue(response, 'location');
+ if (location === undefined) {
+ return {
+ actual: 'no Location header',
+ expected: `matching ${pattern}`,
+ message: 'Expected redirect response to have a Location header',
+ };
+ }
+
+ const locationString = isArray(location) ? (location[0] ?? '') : location;
+
+ if (pattern.test(locationString)) {
+ return true;
+ }
+
+ return {
+ actual: locationString,
+ expected: `matching ${pattern}`,
+ message: `Expected redirect Location to match ${pattern}`,
+ };
+ },
+);
+
+// #endregion
+
+/**
+ * All HTTP response assertions for use with `expect.use()`.
+ */
+export const supertestAssertions = [
+ toHaveStatusAssertion,
+ toHaveStatusCategoryAssertion,
+ toHaveHeaderAssertion,
+ toHaveHeaderValueAssertion,
+ toHaveHeaderMatchingAssertion,
+ toHaveBodyAssertion,
+ toHaveBodyStringAssertion,
+ toHaveJsonBodyAssertion,
+ toHaveJsonBodySatisfyingAssertion,
+ toHaveBodySatisfyingRegexAssertion,
+ toHaveBodySatisfyingObjectAssertion,
+ toRedirectAssertion,
+ toRedirectToUrlAssertion,
+ toRedirectToPatternAssertion,
+] as const;
diff --git a/packages/supertest/src/guards.ts b/packages/supertest/src/guards.ts
new file mode 100644
index 00000000..c18c54a8
--- /dev/null
+++ b/packages/supertest/src/guards.ts
@@ -0,0 +1,95 @@
+/**
+ * Type guards for HTTP response detection.
+ *
+ * @packageDocumentation
+ */
+
+/**
+ * Represents an HTTP response object.
+ *
+ * This interface is designed to be compatible with:
+ *
+ * - Supertest/superagent Response objects
+ * - Fetch Response objects (after conversion)
+ * - Axios Response objects
+ *
+ * @example
+ *
+ * ```ts
+ * // With supertest
+ * const response = await request(app).get('/api/users');
+ * // response satisfies HttpResponse
+ *
+ * // With fetch (after conversion)
+ * const fetchResponse = await fetch('/api/users');
+ * const response: HttpResponse = {
+ * status: fetchResponse.status,
+ * headers: Object.fromEntries(fetchResponse.headers),
+ * body: await fetchResponse.json(),
+ * };
+ * ```
+ */
+export interface HttpResponse {
+ /**
+ * Parsed response body.
+ *
+ * For JSON responses, this is the parsed object. For other responses, it may
+ * be undefined or a string.
+ */
+ body?: unknown;
+
+ /**
+ * HTTP response headers (alternative property name).
+ *
+ * Superagent uses `header` (singular) for the same purpose.
+ */
+ header?: Record;
+
+ /**
+ * HTTP response headers.
+ *
+ * Supertest uses `headers` as a record.
+ */
+ headers?: Record;
+
+ /**
+ * HTTP status code (e.g., 200, 404, 500).
+ */
+ status: number;
+
+ /**
+ * Raw response body as text.
+ */
+ text?: string;
+
+ /**
+ * Content-Type of the response (without parameters).
+ *
+ * Supertest normalizes this from the Content-Type header.
+ */
+ type?: string;
+}
+
+/**
+ * Checks if a value is an HTTP response object.
+ *
+ * @example
+ *
+ * ```ts
+ * const response = await request(app).get('/api/users');
+ * if (isHttpResponse(response)) {
+ * console.log(response.status); // TypeScript knows this is a number
+ * }
+ * ```
+ *
+ * @function
+ * @param value - The value to check
+ * @returns `true` if the value has a numeric `status` property
+ */
+export const isHttpResponse = (value: unknown): value is HttpResponse => {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+ const obj = value as Record;
+ return typeof obj.status === 'number';
+};
diff --git a/packages/supertest/src/index.ts b/packages/supertest/src/index.ts
index ca59e41e..e9a56ec0 100644
--- a/packages/supertest/src/index.ts
+++ b/packages/supertest/src/index.ts
@@ -16,5 +16,7 @@
* ```
*/
-// Placeholder - will be populated with exports
-export {};
+export {
+ supertestAssertions as default,
+ supertestAssertions,
+} from './assertions.js';
diff --git a/packages/supertest/src/schema.ts b/packages/supertest/src/schema.ts
new file mode 100644
index 00000000..4ccc59d2
--- /dev/null
+++ b/packages/supertest/src/schema.ts
@@ -0,0 +1,28 @@
+/**
+ * Zod schemas for HTTP response types.
+ *
+ * @packageDocumentation
+ */
+
+import { z } from 'bupkis';
+
+import { type HttpResponse, isHttpResponse } from './guards.js';
+
+/**
+ * Schema that validates HTTP response objects.
+ *
+ * @example
+ *
+ * ```ts
+ * import { HttpResponseSchema } from '@bupkis/supertest';
+ *
+ * const result = HttpResponseSchema.safeParse(response);
+ * if (result.success) {
+ * // result.data is typed as HttpResponse
+ * }
+ * ```
+ */
+export const HttpResponseSchema = z.custom(
+ isHttpResponse,
+ 'Expected an HTTP response object with a status property',
+);
diff --git a/packages/supertest/test/assertions.test.ts b/packages/supertest/test/assertions.test.ts
new file mode 100644
index 00000000..c4c1ac2c
--- /dev/null
+++ b/packages/supertest/test/assertions.test.ts
@@ -0,0 +1,565 @@
+/**
+ * Tests for HTTP response assertions.
+ */
+
+import { use } from 'bupkis';
+import { describe, it } from 'node:test';
+
+import { supertestAssertions } from '../src/assertions.js';
+
+const { expect } = use(supertestAssertions);
+
+describe('@bupkis/supertest assertions', () => {
+ describe('to have status', () => {
+ describe('with numeric status code', () => {
+ it('should pass when status matches exactly', () => {
+ expect({ status: 200 }, 'to have status', 200);
+ expect({ status: 404 }, 'to have status', 404);
+ expect({ status: 500 }, 'to have status', 500);
+ });
+
+ it('should fail when status does not match', () => {
+ expect(
+ () => expect({ status: 404 }, 'to have status', 200),
+ 'to throw',
+ /Expected response to have status 200/,
+ );
+ });
+
+ it('should work with the alternate phrase "to respond with status"', () => {
+ expect({ status: 200 }, 'to respond with status', 200);
+ });
+ });
+
+ describe('with status category "ok"', () => {
+ it('should pass for 2xx status codes', () => {
+ expect({ status: 200 }, 'to have status', 'ok');
+ expect({ status: 201 }, 'to have status', 'ok');
+ expect({ status: 204 }, 'to have status', 'ok');
+ expect({ status: 299 }, 'to have status', 'ok');
+ });
+
+ it('should fail for non-2xx status codes', () => {
+ expect(
+ () => expect({ status: 404 }, 'to have status', 'ok'),
+ 'to throw',
+ /Expected response to have ok status/,
+ );
+ expect(
+ () => expect({ status: 500 }, 'to have status', 'ok'),
+ 'to throw',
+ /Expected response to have ok status/,
+ );
+ });
+ });
+
+ describe('with status category "redirect"', () => {
+ it('should pass for 3xx status codes', () => {
+ expect({ status: 300 }, 'to have status', 'redirect');
+ expect({ status: 301 }, 'to have status', 'redirect');
+ expect({ status: 302 }, 'to have status', 'redirect');
+ expect({ status: 307 }, 'to have status', 'redirect');
+ expect({ status: 308 }, 'to have status', 'redirect');
+ });
+
+ it('should fail for non-3xx status codes', () => {
+ expect(
+ () => expect({ status: 200 }, 'to have status', 'redirect'),
+ 'to throw',
+ /Expected response to have redirect status/,
+ );
+ });
+ });
+
+ describe('with status category "client error"', () => {
+ it('should pass for 4xx status codes', () => {
+ expect({ status: 400 }, 'to have status', 'client error');
+ expect({ status: 401 }, 'to have status', 'client error');
+ expect({ status: 403 }, 'to have status', 'client error');
+ expect({ status: 404 }, 'to have status', 'client error');
+ expect({ status: 422 }, 'to have status', 'client error');
+ expect({ status: 429 }, 'to have status', 'client error');
+ });
+
+ it('should fail for non-4xx status codes', () => {
+ expect(
+ () => expect({ status: 500 }, 'to have status', 'client error'),
+ 'to throw',
+ /Expected response to have client error status/,
+ );
+ });
+ });
+
+ describe('with status category "server error"', () => {
+ it('should pass for 5xx status codes', () => {
+ expect({ status: 500 }, 'to have status', 'server error');
+ expect({ status: 502 }, 'to have status', 'server error');
+ expect({ status: 503 }, 'to have status', 'server error');
+ expect({ status: 504 }, 'to have status', 'server error');
+ });
+
+ it('should fail for non-5xx status codes', () => {
+ expect(
+ () => expect({ status: 200 }, 'to have status', 'server error'),
+ 'to throw',
+ /Expected response to have server error status/,
+ );
+ });
+ });
+ });
+
+ describe('to have header', () => {
+ describe('header existence', () => {
+ it('should pass when header exists (using headers property)', () => {
+ const response = {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'content-type');
+ });
+
+ it('should pass when header exists (using header property)', () => {
+ const response = {
+ header: { 'content-type': 'application/json' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'content-type');
+ });
+
+ it('should be case-insensitive for header names', () => {
+ const response = {
+ headers: { 'Content-Type': 'application/json' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'content-type');
+ expect(response, 'to have header', 'CONTENT-TYPE');
+ expect(response, 'to have header', 'Content-Type');
+ });
+
+ it('should fail when header does not exist', () => {
+ const response = {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ };
+ expect(
+ () => expect(response, 'to have header', 'x-custom-header'),
+ 'to throw',
+ /Expected response to have header "x-custom-header"/,
+ );
+ });
+
+ it('should fail when response has no headers', () => {
+ const response = { status: 200 };
+ expect(
+ () => expect(response, 'to have header', 'content-type'),
+ 'to throw',
+ /Expected response to have header "content-type"/,
+ );
+ });
+
+ it('should work with alternate phrase "to include header"', () => {
+ const response = {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ };
+ expect(response, 'to include header', 'content-type');
+ });
+ });
+
+ describe('header with exact value', () => {
+ it('should pass when header matches exact value', () => {
+ const response = {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'content-type', 'application/json');
+ });
+
+ it('should fail when header value does not match', () => {
+ const response = {
+ headers: { 'content-type': 'text/html' },
+ status: 200,
+ };
+ expect(
+ () =>
+ expect(
+ response,
+ 'to have header',
+ 'content-type',
+ 'application/json',
+ ),
+ 'to throw',
+ /Expected header "content-type" to equal "application\/json"/,
+ );
+ });
+
+ it('should handle array header values', () => {
+ const response = {
+ headers: { 'set-cookie': ['a=1', 'b=2'] },
+ status: 200,
+ };
+ expect(response, 'to have header', 'set-cookie', 'a=1, b=2');
+ });
+ });
+
+ describe('header with regex match', () => {
+ it('should pass when header matches regex', () => {
+ const response = {
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'content-type', /json/);
+ expect(response, 'to have header', 'content-type', /^application\//);
+ });
+
+ it('should fail when header does not match regex', () => {
+ const response = {
+ headers: { 'content-type': 'text/html' },
+ status: 200,
+ };
+ expect(
+ () => expect(response, 'to have header', 'content-type', /json/),
+ 'to throw',
+ /Expected header "content-type" to match/,
+ );
+ });
+
+ it('should handle complex regex patterns', () => {
+ const response = {
+ headers: { 'cache-control': 'max-age=3600, public' },
+ status: 200,
+ };
+ expect(response, 'to have header', 'cache-control', /max-age=\d+/);
+ });
+ });
+ });
+
+ describe('to have body', () => {
+ it('should pass when response has text body', () => {
+ expect({ status: 200, text: 'Hello' }, 'to have body');
+ });
+
+ it('should pass when response has object body', () => {
+ expect({ body: { users: [] }, status: 200 }, 'to have body');
+ });
+
+ it('should pass when response has empty object body', () => {
+ // Empty object still counts as having a body
+ expect({ body: {}, status: 200 }, 'to have body');
+ });
+
+ it('should pass when response has empty array body', () => {
+ expect({ body: [], status: 200 }, 'to have body');
+ });
+
+ it('should fail when response has no body', () => {
+ expect(
+ () => expect({ status: 200 }, 'to have body'),
+ 'to throw',
+ /Expected response to have a body/,
+ );
+ });
+
+ it('should fail when response has empty string body', () => {
+ expect(
+ () => expect({ body: '', status: 200, text: '' }, 'to have body'),
+ 'to throw',
+ /Expected response to have a body/,
+ );
+ });
+ });
+
+ describe('to have body (exact string)', () => {
+ it('should pass when body text matches exactly', () => {
+ expect(
+ { status: 200, text: 'Hello, World!' },
+ 'to have body',
+ 'Hello, World!',
+ );
+ });
+
+ it('should pass when body object stringifies to expected', () => {
+ expect({ body: { id: 1 }, status: 200 }, 'to have body', '{"id":1}');
+ });
+
+ it('should fail when body does not match', () => {
+ expect(
+ () => expect({ status: 200, text: 'Hello' }, 'to have body', 'Goodbye'),
+ 'to throw',
+ /Expected response body to equal string/,
+ );
+ });
+ });
+
+ describe('to have JSON body', () => {
+ it('should pass when response has JSON content-type and body', () => {
+ const response = {
+ body: { users: [] },
+ status: 200,
+ type: 'application/json',
+ };
+ expect(response, 'to have JSON body');
+ });
+
+ it('should pass when JSON content-type is in headers', () => {
+ const response = {
+ body: { data: 'test' },
+ headers: { 'content-type': 'application/json; charset=utf-8' },
+ status: 200,
+ };
+ expect(response, 'to have JSON body');
+ });
+
+ it('should fail when content-type is not JSON', () => {
+ const response = {
+ body: '',
+ status: 200,
+ type: 'text/html',
+ };
+ expect(
+ () => expect(response, 'to have JSON body'),
+ 'to throw',
+ /Expected response to have JSON content-type/,
+ );
+ });
+
+ it('should fail when response has no body', () => {
+ const response = {
+ status: 204,
+ type: 'application/json',
+ };
+ expect(
+ () => expect(response, 'to have JSON body'),
+ 'to throw',
+ /Expected response to have a JSON body/,
+ );
+ });
+ });
+
+ describe('to have JSON body satisfying', () => {
+ it('should pass when body contains expected properties', () => {
+ const response = {
+ body: { email: 'john@example.com', id: 1, name: 'John' },
+ status: 200,
+ };
+ expect(response, 'to have JSON body satisfying', { id: 1 });
+ expect(response, 'to have JSON body satisfying', { name: 'John' });
+ expect(response, 'to have JSON body satisfying', { id: 1, name: 'John' });
+ });
+
+ it('should pass with nested objects', () => {
+ const response = {
+ body: {
+ meta: { version: '1.0' },
+ user: { id: 1, profile: { age: 30, name: 'John' } },
+ },
+ status: 200,
+ };
+ expect(response, 'to have JSON body satisfying', {
+ user: { profile: { name: 'John' } },
+ });
+ });
+
+ it('should pass with arrays', () => {
+ const response = {
+ body: { users: [{ id: 1 }, { id: 2 }] },
+ status: 200,
+ };
+ expect(response, 'to have JSON body satisfying', {
+ users: [{ id: 1 }, { id: 2 }],
+ });
+ });
+
+ it('should fail when property is missing', () => {
+ const response = {
+ body: { id: 1 },
+ status: 200,
+ };
+ expect(
+ () =>
+ expect(response, 'to have JSON body satisfying', { name: 'John' }),
+ 'to throw',
+ /Expected response body to satisfy specification/,
+ );
+ });
+
+ it('should fail when property value differs', () => {
+ const response = {
+ body: { id: 1, name: 'Jane' },
+ status: 200,
+ };
+ expect(
+ () =>
+ expect(response, 'to have JSON body satisfying', { name: 'John' }),
+ 'to throw',
+ /Expected response body to satisfy specification/,
+ );
+ });
+ });
+
+ describe('to have body satisfying (regex)', () => {
+ it('should pass when body matches regex', () => {
+ expect(
+ { status: 200, text: 'Hello, World!' },
+ 'to have body satisfying',
+ /World/,
+ );
+ expect(
+ { status: 200, text: '{"id":123}' },
+ 'to have body satisfying',
+ /"id":\d+/,
+ );
+ });
+
+ it('should fail when body does not match regex', () => {
+ expect(
+ () =>
+ expect(
+ { status: 200, text: 'Hello' },
+ 'to have body satisfying',
+ /Goodbye/,
+ ),
+ 'to throw',
+ /Expected response body to match/,
+ );
+ });
+
+ it('should fail when response has no body', () => {
+ expect(
+ () => expect({ status: 200 }, 'to have body satisfying', /anything/),
+ 'to throw',
+ /Expected response to have a body/,
+ );
+ });
+ });
+
+ describe('to have body satisfying (object)', () => {
+ it('should pass when body contains expected properties', () => {
+ const response = {
+ body: { extra: 'ignored', id: 1, name: 'John' },
+ status: 200,
+ };
+ expect(response, 'to have body satisfying', { id: 1 });
+ });
+
+ it('should fail when body is missing expected property', () => {
+ const response = {
+ body: { id: 1 },
+ status: 200,
+ };
+ expect(
+ () => expect(response, 'to have body satisfying', { name: 'John' }),
+ 'to throw',
+ /Expected response body to satisfy specification/,
+ );
+ });
+ });
+
+ describe('to redirect', () => {
+ it('should pass for 3xx status codes', () => {
+ expect({ status: 301 }, 'to redirect');
+ expect({ status: 302 }, 'to redirect');
+ expect({ status: 307 }, 'to redirect');
+ expect({ status: 308 }, 'to redirect');
+ });
+
+ it('should fail for non-3xx status codes', () => {
+ expect(
+ () => expect({ status: 200 }, 'to redirect'),
+ 'to throw',
+ /Expected response to be a redirect/,
+ );
+ expect(
+ () => expect({ status: 404 }, 'to redirect'),
+ 'to throw',
+ /Expected response to be a redirect/,
+ );
+ });
+ });
+
+ describe('to redirect to (URL)', () => {
+ it('should pass when Location header matches exactly', () => {
+ const response = {
+ headers: { location: '/login' },
+ status: 302,
+ };
+ expect(response, 'to redirect to', '/login');
+ });
+
+ it('should pass with full URL', () => {
+ const response = {
+ headers: { location: 'https://example.com/new-page' },
+ status: 301,
+ };
+ expect(response, 'to redirect to', 'https://example.com/new-page');
+ });
+
+ it('should fail when not a redirect', () => {
+ const response = {
+ headers: { location: '/somewhere' },
+ status: 200,
+ };
+ expect(
+ () => expect(response, 'to redirect to', '/somewhere'),
+ 'to throw',
+ /Expected response to be a redirect/,
+ );
+ });
+
+ it('should fail when Location header is missing', () => {
+ const response = { status: 302 };
+ expect(
+ () => expect(response, 'to redirect to', '/login'),
+ 'to throw',
+ /Expected redirect response to have a Location header/,
+ );
+ });
+
+ it('should fail when Location does not match', () => {
+ const response = {
+ headers: { location: '/dashboard' },
+ status: 302,
+ };
+ expect(
+ () => expect(response, 'to redirect to', '/login'),
+ 'to throw',
+ /Expected redirect to "\/login"/,
+ );
+ });
+ });
+
+ describe('to redirect to (regex)', () => {
+ it('should pass when Location header matches pattern', () => {
+ const response = {
+ headers: { location: '/auth/login?redirect=/dashboard' },
+ status: 302,
+ };
+ expect(response, 'to redirect to', /\/auth/);
+ expect(response, 'to redirect to', /redirect=/);
+ });
+
+ it('should fail when not a redirect', () => {
+ const response = {
+ headers: { location: '/somewhere' },
+ status: 200,
+ };
+ expect(
+ () => expect(response, 'to redirect to', /somewhere/),
+ 'to throw',
+ /Expected response to be a redirect/,
+ );
+ });
+
+ it('should fail when Location does not match pattern', () => {
+ const response = {
+ headers: { location: '/dashboard' },
+ status: 302,
+ };
+ expect(
+ () => expect(response, 'to redirect to', /login/),
+ 'to throw',
+ /Expected redirect Location to match/,
+ );
+ });
+ });
+});
diff --git a/packages/supertest/test/guards.test.ts b/packages/supertest/test/guards.test.ts
new file mode 100644
index 00000000..d2ac31ec
--- /dev/null
+++ b/packages/supertest/test/guards.test.ts
@@ -0,0 +1,79 @@
+/**
+ * Tests for HTTP response type guards.
+ */
+
+import { expect } from 'bupkis';
+import { describe, it } from 'node:test';
+
+import { isHttpResponse } from '../src/guards.js';
+
+describe('@bupkis/supertest', () => {
+ describe('isHttpResponse()', () => {
+ it('should return true for object with numeric status', () => {
+ expect(isHttpResponse({ status: 200 }), 'to be', true);
+ });
+
+ it('should return true for supertest-like response', () => {
+ const response = {
+ body: { users: [] },
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ text: '{"users":[]}',
+ type: 'application/json',
+ };
+ expect(isHttpResponse(response), 'to be', true);
+ });
+
+ it('should return true for response with header property (superagent style)', () => {
+ const response = {
+ body: {},
+ header: { 'content-type': 'text/plain' },
+ status: 404,
+ text: 'Not Found',
+ };
+ expect(isHttpResponse(response), 'to be', true);
+ });
+
+ it('should return false for null', () => {
+ expect(isHttpResponse(null), 'to be', false);
+ });
+
+ it('should return false for undefined', () => {
+ expect(isHttpResponse(undefined), 'to be', false);
+ });
+
+ it('should return false for primitive values', () => {
+ expect(isHttpResponse(42), 'to be', false);
+ expect(isHttpResponse('string'), 'to be', false);
+ expect(isHttpResponse(true), 'to be', false);
+ });
+
+ it('should return false for object without status', () => {
+ expect(isHttpResponse({ headers: {} }), 'to be', false);
+ });
+
+ it('should return false for object with non-numeric status', () => {
+ expect(isHttpResponse({ status: '200' }), 'to be', false);
+ expect(isHttpResponse({ status: null }), 'to be', false);
+ });
+
+ it('should return false for array', () => {
+ expect(isHttpResponse([200]), 'to be', false);
+ });
+
+ it('should return true for minimal response with only status', () => {
+ expect(isHttpResponse({ status: 500 }), 'to be', true);
+ });
+
+ it('should return true for response with extra properties', () => {
+ const response = {
+ body: {},
+ customProperty: 'value',
+ headers: {},
+ request: {},
+ status: 200,
+ };
+ expect(isHttpResponse(response), 'to be', true);
+ });
+ });
+});
From f02518666c113bf04cac51dbf70b24e8475d4cba Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:14:14 -0800
Subject: [PATCH 3/9] chore(supertest): fix knip issues
- Remove unused devDependencies (express, supertest, @types/*)
- Remove individual assertion exports (only export supertestAssertions array)
Co-Authored-By: Claude Opus 4.5
---
packages/supertest/package.json | 6 ------
packages/supertest/src/assertions.ts | 28 ++++++++++++++--------------
2 files changed, 14 insertions(+), 20 deletions(-)
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
index 23d5172f..9424c307 100644
--- a/packages/supertest/package.json
+++ b/packages/supertest/package.json
@@ -55,12 +55,6 @@
"peerDependencies": {
"bupkis": ">=0.15.0"
},
- "devDependencies": {
- "@types/express": "5.0.3",
- "@types/supertest": "6.0.3",
- "express": "5.1.0",
- "supertest": "7.1.1"
- },
"publishConfig": {
"access": "public"
},
diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts
index 52ab63f4..e35f4090 100644
--- a/packages/supertest/src/assertions.ts
+++ b/packages/supertest/src/assertions.ts
@@ -68,7 +68,7 @@ const getStatusCategory = (status: number): string => {
* expect(response, 'to have status', 404);
* ```
*/
-export const toHaveStatusAssertion = expect.createAssertion(
+const toHaveStatusAssertion = expect.createAssertion(
[
HttpResponseSchema,
['to have status', 'to respond with status'],
@@ -98,7 +98,7 @@ export const toHaveStatusAssertion = expect.createAssertion(
* expect(response, 'to have status', 'server error'); // 5xx
* ```
*/
-export const toHaveStatusCategoryAssertion = expect.createAssertion(
+const toHaveStatusCategoryAssertion = expect.createAssertion(
[
HttpResponseSchema,
['to have status', 'to respond with status'],
@@ -169,7 +169,7 @@ const getHeaderValue = (
* expect(response, 'to have header', 'X-Request-Id');
* ```
*/
-export const toHaveHeaderAssertion = expect.createAssertion(
+const toHaveHeaderAssertion = expect.createAssertion(
[HttpResponseSchema, ['to have header', 'to include header'], z.string()],
(response: HttpResponse, headerName: string) => {
const value = getHeaderValue(response, headerName);
@@ -195,7 +195,7 @@ export const toHaveHeaderAssertion = expect.createAssertion(
* expect(response, 'to have header', 'cache-control', 'no-cache');
* ```
*/
-export const toHaveHeaderValueAssertion = expect.createAssertion(
+const toHaveHeaderValueAssertion = expect.createAssertion(
[
HttpResponseSchema,
['to have header', 'to include header'],
@@ -241,7 +241,7 @@ export const toHaveHeaderValueAssertion = expect.createAssertion(
* expect(response, 'to have header', 'cache-control', /max-age=\d+/);
* ```
*/
-export const toHaveHeaderMatchingAssertion = expect.createAssertion(
+const toHaveHeaderMatchingAssertion = expect.createAssertion(
[
HttpResponseSchema,
['to have header', 'to include header'],
@@ -409,7 +409,7 @@ const deepPartialMatch = (actual: unknown, expected: unknown): boolean => {
* expect(response, 'to have body');
* ```
*/
-export const toHaveBodyAssertion = expect.createAssertion(
+const toHaveBodyAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have body'],
(response: HttpResponse) => {
if (hasBody(response)) {
@@ -433,7 +433,7 @@ export const toHaveBodyAssertion = expect.createAssertion(
* expect(response, 'to have body', '{"users":[]}');
* ```
*/
-export const toHaveBodyStringAssertion = expect.createAssertion(
+const toHaveBodyStringAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have body', z.string()],
(response: HttpResponse, expected: string) => {
const bodyText = getBodyText(response);
@@ -459,7 +459,7 @@ export const toHaveBodyStringAssertion = expect.createAssertion(
* expect(response, 'to have JSON body');
* ```
*/
-export const toHaveJsonBodyAssertion = expect.createAssertion(
+const toHaveJsonBodyAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have JSON body'],
(response: HttpResponse) => {
if (!isJsonResponse(response)) {
@@ -494,7 +494,7 @@ export const toHaveJsonBodyAssertion = expect.createAssertion(
* expect(response, 'to have JSON body satisfying', { status: 'ok' });
* ```
*/
-export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion(
+const toHaveJsonBodySatisfyingAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have JSON body satisfying', schema.AnyObjectSchema],
(response: HttpResponse, expected: Record) => {
const body = response.body;
@@ -537,7 +537,7 @@ export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion(
* expect(response, 'to have body satisfying', /"id":\d+/);
* ```
*/
-export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion(
+const toHaveBodySatisfyingRegexAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have body satisfying', z.instanceof(RegExp)],
(response: HttpResponse, pattern: RegExp) => {
const bodyText = getBodyText(response);
@@ -571,7 +571,7 @@ export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion(
* expect(response, 'to have body satisfying', { users: [] });
* ```
*/
-export const toHaveBodySatisfyingObjectAssertion = expect.createAssertion(
+const toHaveBodySatisfyingObjectAssertion = expect.createAssertion(
[HttpResponseSchema, 'to have body satisfying', schema.AnyObjectSchema],
(response: HttpResponse, expected: Record) => {
const body = response.body;
@@ -625,7 +625,7 @@ const isRedirect = (response: HttpResponse): boolean =>
* expect(response, 'to redirect');
* ```
*/
-export const toRedirectAssertion = expect.createAssertion(
+const toRedirectAssertion = expect.createAssertion(
[HttpResponseSchema, 'to redirect'],
(response: HttpResponse) => {
if (isRedirect(response)) {
@@ -649,7 +649,7 @@ export const toRedirectAssertion = expect.createAssertion(
* expect(response, 'to redirect to', 'https://example.com/auth');
* ```
*/
-export const toRedirectToUrlAssertion = expect.createAssertion(
+const toRedirectToUrlAssertion = expect.createAssertion(
[HttpResponseSchema, 'to redirect to', z.string()],
(response: HttpResponse, expectedUrl: string) => {
if (!isRedirect(response)) {
@@ -693,7 +693,7 @@ export const toRedirectToUrlAssertion = expect.createAssertion(
* expect(response, 'to redirect to', /login\?redirect=/);
* ```
*/
-export const toRedirectToPatternAssertion = expect.createAssertion(
+const toRedirectToPatternAssertion = expect.createAssertion(
[HttpResponseSchema, 'to redirect to', z.instanceof(RegExp)],
(response: HttpResponse, pattern: RegExp) => {
if (!isRedirect(response)) {
From 1fb475cca6fd773a77a2e34297a9852d64187251 Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:25:16 -0800
Subject: [PATCH 4/9] chore(supertest): address PR review feedback
- Add prettier config to match other packages
- Simplify redundant isJsonResponse check
Co-Authored-By: Claude Opus 4.5
---
packages/supertest/package.json | 10 ++++++++++
packages/supertest/src/assertions.ts | 5 +----
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
index 9424c307..6f4fad59 100644
--- a/packages/supertest/package.json
+++ b/packages/supertest/package.json
@@ -58,6 +58,16 @@
"publishConfig": {
"access": "public"
},
+ "prettier": {
+ "jsdocCommentLineStrategy": "keep",
+ "jsdocPreferCodeFences": true,
+ "plugins": [
+ "prettier-plugin-jsdoc",
+ "prettier-plugin-pkg"
+ ],
+ "singleQuote": true,
+ "tsdoc": true
+ },
"zshy": {
"exports": {
".": "./src/index.ts",
diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts
index e35f4090..ffe4ef0a 100644
--- a/packages/supertest/src/assertions.ts
+++ b/packages/supertest/src/assertions.ts
@@ -336,10 +336,7 @@ const hasBody = (response: HttpResponse): boolean => {
const isJsonResponse = (response: HttpResponse): boolean => {
// Check the type property (supertest normalizes this)
if (response.type) {
- return (
- response.type.includes('json') ||
- response.type.includes('application/json')
- );
+ return response.type.includes('json');
}
// Fall back to checking headers
From 9072e15366c20ed7a977ad2dedfaa1495f069abe Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:31:03 -0800
Subject: [PATCH 5/9] chore(supertest): add release-please config and monorepo
docs
Add @bupkis/supertest to release-please configuration files for
automated versioning and to the monorepo README plugins section.
---
.release-please-manifest.json | 3 ++-
README.md | 3 ++-
packages/supertest/package.json | 7 +++----
release-please-config.json | 6 ++++++
4 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index dee9d6f0..a7f9fffa 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -5,5 +5,6 @@
"packages/from-jest": "0.1.1",
"packages/property-testing": "0.1.0",
"packages/rxjs": "0.0.0",
- "packages/sinon": "0.1.1"
+ "packages/sinon": "0.1.1",
+ "packages/supertest": "0.0.0"
}
diff --git a/README.md b/README.md
index a7ff1fb3..5575e03c 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-* * *
+---
This is the monorepo for [**BUPKIS**](https://bupkis.zip), the uncommonly extensible assertion library.
@@ -21,6 +21,7 @@ This is the monorepo for [**BUPKIS**](https://bupkis.zip), the uncommonly extens
- **[@bupkis/events](./packages/events/)** - Event emitter assertions
- **[@bupkis/rxjs](./packages/rxjs/)** - RxJS Observable assertions
- **[@bupkis/sinon](./packages/sinon/)** - Sinon spy/stub/mock assertions
+- **[@bupkis/supertest](./packages/supertest/)** - HTTP response assertions
### Migration Tools
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
index 6f4fad59..6753159c 100644
--- a/packages/supertest/package.json
+++ b/packages/supertest/package.json
@@ -21,9 +21,9 @@
"module": "./dist/index.js",
"exports": {
".": {
- "types": "./dist/index.d.cts",
"import": "./dist/index.js",
- "require": "./dist/index.cjs"
+ "require": "./dist/index.cjs",
+ "types": "./dist/index.d.cts"
},
"./package.json": "./package.json"
},
@@ -49,8 +49,7 @@
"test": "npm run test:base -- \"test/*.test.ts\"",
"test:base": "node --import tsx --test --test-reporter=spec",
"test:dev": "npm run test:base -- --watch \"test/*.test.ts\"",
- "test:node20": "npm run test:base -- test/*.test.ts",
- "test:types": "tsd"
+ "test:node20": "npm run test:base -- test/*.test.ts"
},
"peerDependencies": {
"bupkis": ">=0.15.0"
diff --git a/release-please-config.json b/release-please-config.json
index 3034e314..3e8de5c7 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -44,6 +44,12 @@
"component": "@bupkis/sinon",
"initial-version": "0.1.0",
"release-type": "node"
+ },
+ "packages/supertest": {
+ "changelog-path": "CHANGELOG.md",
+ "component": "@bupkis/supertest",
+ "initial-version": "0.1.0",
+ "release-type": "node"
}
}
}
From 9d5fd24f53783a9c5cd5030cce2d723e76c03000 Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:33:00 -0800
Subject: [PATCH 6/9] fix(supertest): add consistent fallback for array
location headers
Add ?? '' fallback to line 669 to match the pattern used at line 713
for defensive coding and consistency.
---
packages/supertest/src/assertions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts
index ffe4ef0a..9b77e89d 100644
--- a/packages/supertest/src/assertions.ts
+++ b/packages/supertest/src/assertions.ts
@@ -666,7 +666,7 @@ const toRedirectToUrlAssertion = expect.createAssertion(
};
}
- const locationString = isArray(location) ? location[0] : location;
+ const locationString = isArray(location) ? (location[0] ?? '') : location;
if (locationString === expectedUrl) {
return true;
From 32523ac255c88705324400d8465f2ce5f28daa9d Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 21:38:24 -0800
Subject: [PATCH 7/9] test(supertest): add manual testing script for real HTTP
responses
Adds examples/manual-test.ts that spins up a test server and verifies
all assertions work correctly with actual fetch responses. Tests:
- Status assertions (exact and category)
- Header assertions (existence, exact value, pattern)
- Body assertions (string, JSON, partial match, regex)
- Redirect assertions (existence, exact URL, pattern)
- Negation assertions
---
packages/supertest/examples/manual-test.ts | 256 +++++++++++++++++++++
packages/supertest/package.json | 1 +
2 files changed, 257 insertions(+)
create mode 100644 packages/supertest/examples/manual-test.ts
diff --git a/packages/supertest/examples/manual-test.ts b/packages/supertest/examples/manual-test.ts
new file mode 100644
index 00000000..9f9ace77
--- /dev/null
+++ b/packages/supertest/examples/manual-test.ts
@@ -0,0 +1,256 @@
+/**
+ * Manual testing script for @bupkis/supertest with real HTTP responses.
+ *
+ * Run with: npx tsx examples/manual-test.ts
+ */
+
+import { createServer, type Server } from 'node:http';
+
+import { use } from 'bupkis';
+
+import supertestAssertions from '../src/index.js';
+
+const { expect } = use(supertestAssertions);
+
+/**
+ * Adapts a fetch Response to the shape expected by @bupkis/supertest.
+ */
+async function adaptFetchResponse(response: Response) {
+ const text = await response.text();
+ let body: unknown;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+
+ // Convert Headers to plain object
+ const headers: Record = {};
+ response.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+
+ return {
+ status: response.status,
+ headers,
+ body,
+ text,
+ type: response.headers.get('content-type') ?? undefined,
+ };
+}
+
+/**
+ * Creates a test server with various endpoints.
+ */
+function createTestServer(): Server {
+ return createServer((req, res) => {
+ const url = new URL(req.url ?? '/', `http://localhost`);
+
+ switch (url.pathname) {
+ case '/json':
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ message: 'hello', users: [{ id: 1 }] }));
+ break;
+
+ case '/text':
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('Hello, World!');
+ break;
+
+ case '/redirect':
+ res.writeHead(302, { Location: '/destination' });
+ res.end();
+ break;
+
+ case '/redirect-pattern':
+ res.writeHead(301, { Location: '/auth/login?redirect=/dashboard' });
+ res.end();
+ break;
+
+ case '/not-found':
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Not found' }));
+ break;
+
+ case '/server-error':
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Internal server error' }));
+ break;
+
+ case '/custom-header':
+ res.writeHead(200, {
+ 'Content-Type': 'text/plain',
+ 'X-Request-Id': 'abc-123',
+ 'X-Rate-Limit': '100',
+ });
+ res.end('OK');
+ break;
+
+ default:
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('OK');
+ }
+ });
+}
+
+async function runTests() {
+ const server = createTestServer();
+
+ await new Promise((resolve) => {
+ server.listen(0, () => resolve());
+ });
+
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ throw new Error('Failed to get server address');
+ }
+ const baseUrl = `http://localhost:${address.port}`;
+
+ console.log(`Test server running at ${baseUrl}\n`);
+ console.log('Running manual tests with real HTTP responses...\n');
+
+ let passed = 0;
+ let failed = 0;
+
+ async function test(name: string, fn: () => Promise) {
+ try {
+ await fn();
+ console.log(` \u2714 ${name}`);
+ passed++;
+ } catch (err) {
+ console.log(` \u2718 ${name}`);
+ console.log(` ${(err as Error).message}`);
+ failed++;
+ }
+ }
+
+ // Status assertions
+ console.log('Status assertions:');
+ await test('to have status (exact)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have status', 200);
+ });
+
+ await test('to have status (category: ok)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have status', 'ok');
+ });
+
+ await test('to have status (category: client error)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/not-found`));
+ expect(res, 'to have status', 'client error');
+ });
+
+ await test('to have status (category: server error)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/server-error`),
+ );
+ expect(res, 'to have status', 'server error');
+ });
+
+ // Header assertions
+ console.log('\nHeader assertions:');
+ await test('to have header (existence)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/custom-header`),
+ );
+ expect(res, 'to have header', 'x-request-id');
+ });
+
+ await test('to have header (exact value)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/custom-header`),
+ );
+ expect(res, 'to have header', 'x-request-id', 'abc-123');
+ });
+
+ await test('to have header (pattern)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/custom-header`),
+ );
+ expect(res, 'to have header', 'x-rate-limit', /\d+/);
+ });
+
+ // Body assertions
+ console.log('\nBody assertions:');
+ await test('to have body', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have body');
+ });
+
+ await test('to have body (exact string)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/text`));
+ expect(res, 'to have body', 'Hello, World!');
+ });
+
+ await test('to have JSON body', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have JSON body');
+ });
+
+ await test('to have JSON body satisfying', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have JSON body satisfying', { message: 'hello' });
+ });
+
+ await test('to have body satisfying (regex)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/text`));
+ expect(res, 'to have body satisfying', /Hello/);
+ });
+
+ await test('to have body satisfying (object)', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'to have body satisfying', { users: [{ id: 1 }] });
+ });
+
+ // Redirect assertions
+ console.log('\nRedirect assertions:');
+ await test('to redirect', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/redirect`, { redirect: 'manual' }),
+ );
+ expect(res, 'to redirect');
+ });
+
+ await test('to redirect to (exact URL)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/redirect`, { redirect: 'manual' }),
+ );
+ expect(res, 'to redirect to', '/destination');
+ });
+
+ await test('to redirect to (pattern)', async () => {
+ const res = await adaptFetchResponse(
+ await fetch(`${baseUrl}/redirect-pattern`, { redirect: 'manual' }),
+ );
+ expect(res, 'to redirect to', /\/auth\/login/);
+ });
+
+ // Negation tests
+ console.log('\nNegation assertions:');
+ await test('not to have status', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'not to have status', 404);
+ });
+
+ await test('not to redirect', async () => {
+ const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`));
+ expect(res, 'not to redirect');
+ });
+
+ // Cleanup
+ server.close();
+
+ console.log(`\n${'='.repeat(40)}`);
+ console.log(`Results: ${passed} passed, ${failed} failed`);
+ console.log('='.repeat(40));
+
+ if (failed > 0) {
+ process.exit(1);
+ }
+}
+
+runTests().catch((err) => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
index 6753159c..44d218e1 100644
--- a/packages/supertest/package.json
+++ b/packages/supertest/package.json
@@ -49,6 +49,7 @@
"test": "npm run test:base -- \"test/*.test.ts\"",
"test:base": "node --import tsx --test --test-reporter=spec",
"test:dev": "npm run test:base -- --watch \"test/*.test.ts\"",
+ "test:manual": "tsx examples/manual-test.ts",
"test:node20": "npm run test:base -- test/*.test.ts"
},
"peerDependencies": {
From 7323af40b83163d82acf074b67653c1f325dc8c6 Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Wed, 14 Jan 2026 22:28:16 -0800
Subject: [PATCH 8/9] fix(supertest): handle circular references and fix
misleading JSDoc
- Wrap stringify() in try-catch to gracefully handle circular references
in response bodies (returns '[unserializable body]' on failure)
- Update HttpResponseSchema JSDoc to mark as @internal and remove
misleading import example (schema is not exported from main entry)
---
packages/supertest/src/assertions.ts | 12 +++++++++---
packages/supertest/src/schema.ts | 13 ++++---------
2 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts
index 9b77e89d..c8847694 100644
--- a/packages/supertest/src/assertions.ts
+++ b/packages/supertest/src/assertions.ts
@@ -293,9 +293,15 @@ const getBodyText = (response: HttpResponse): string | undefined => {
return response.text;
}
if (response.body !== undefined) {
- return typeof response.body === 'string'
- ? response.body
- : stringify(response.body);
+ if (typeof response.body === 'string') {
+ return response.body;
+ }
+ try {
+ return stringify(response.body);
+ } catch {
+ // Handle circular references or other unserializable values
+ return '[unserializable body]';
+ }
}
return undefined;
};
diff --git a/packages/supertest/src/schema.ts b/packages/supertest/src/schema.ts
index 4ccc59d2..efb14c21 100644
--- a/packages/supertest/src/schema.ts
+++ b/packages/supertest/src/schema.ts
@@ -11,16 +11,11 @@ import { type HttpResponse, isHttpResponse } from './guards.js';
/**
* Schema that validates HTTP response objects.
*
- * @example
+ * This schema is used internally by the assertions to validate that the subject
+ * is an HTTP response object. It uses duck typing to accept any object with a
+ * numeric `status` property.
*
- * ```ts
- * import { HttpResponseSchema } from '@bupkis/supertest';
- *
- * const result = HttpResponseSchema.safeParse(response);
- * if (result.success) {
- * // result.data is typed as HttpResponse
- * }
- * ```
+ * @internal
*/
export const HttpResponseSchema = z.custom(
isHttpResponse,
From 68831d300344389aeefac5edf0c1002bf8ce08ea Mon Sep 17 00:00:00 2001
From: Christopher Hiller
Date: Thu, 15 Jan 2026 13:04:35 -0800
Subject: [PATCH 9/9] chore(supertest): normalize package.json
---
packages/supertest/package.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/supertest/package.json b/packages/supertest/package.json
index 44d218e1..3bf32e4c 100644
--- a/packages/supertest/package.json
+++ b/packages/supertest/package.json
@@ -21,9 +21,9 @@
"module": "./dist/index.js",
"exports": {
".": {
+ "types": "./dist/index.d.cts",
"import": "./dist/index.js",
- "require": "./dist/index.cjs",
- "types": "./dist/index.d.cts"
+ "require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},