From 0c00c93b37410865ab0ad75653fe4a7e06d7021a Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Mon, 17 Mar 2025 07:57:58 +0100 Subject: [PATCH 01/22] First few types --- lib/axios.js | 19 ++++--- lib/data.js | 9 +++- lib/expect.js | 19 +++++++ lib/reporter.js | 1 + package-lock.json | 127 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 4 +- tsconfig.json | 26 ++++++++++ 7 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 tsconfig.json diff --git a/lib/axios.js b/lib/axios.js index e5b3f79..b5e475a 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -13,12 +13,12 @@ class Axios { }}) return super.axios = axios } - get (..._) { return this.axios.get (..._args(_)) .catch(_error) } - put (..._) { return this.axios.put (..._args(_)) .catch(_error) } - post (..._) { return this.axios.post (..._args(_)) .catch(_error) } - patch (..._) { return this.axios.patch (..._args(_)) .catch(_error) } - delete (..._) { return this.axios.delete (..._args(_)) .catch(_error) } - options (..._) { return this.axios.options (..._args(_)) .catch(_error) } + get (/** @type{any} */..._) { return this.axios.get (..._args(_)) .catch(_error) } + put (/** @type{any} */..._) { return this.axios.put (..._args(_)) .catch(_error) } + post (/** @type{any} */..._) { return this.axios.post (..._args(_)) .catch(_error) } + patch (/** @type{any} */..._) { return this.axios.patch (..._args(_)) .catch(_error) } + delete (/** @type{any} */..._) { return this.axios.delete (..._args(_)) .catch(_error) } + options (/** @type{any} */..._) { return this.axios.options (..._args(_)) .catch(_error) } /** @type typeof _.get */ get GET() { return this.get .bind (this) } /** @type typeof _.put */ get PUT() { return this.put .bind (this) } @@ -30,6 +30,9 @@ class Axios { } +/** + * @param {any[]} args - args + */ const _args = (args) => { const first = args[0], last = args[args.length-1] if (first.raw) { @@ -42,6 +45,10 @@ const _args = (args) => { return args } +/** + * @typedef {Error & {code:string}} ErrorWithCode + * @param {ErrorWithCode & {errors?: ErrorWithCode[]}} e - errors + */ const _error = (e) => { if (e.errors) e = e.errors[0] // Node 20 sends AggregationErrors if (e.code === 'ERR_INVALID_URL') {// URL wasn't completed due to the non-started server diff --git a/lib/data.js b/lib/data.js index d7158dc..d0c0ef8 100644 --- a/lib/data.js +++ b/lib/data.js @@ -1,10 +1,12 @@ const cds = require('@sap/cds') class DataUtil { + /** @type {ReturnType[] | undefined} */ + _deletes constructor() { // This is to support simplified usage like that: beforeEach(test.data.reset) - const {reset} = this; this.reset = (x) => { + const {reset} = this; this.reset = (/** @type{any}*/ x) => { if (typeof x === 'function') reset.call(this).then(x,x) // x is the done callback of jest -> no return else if (x?.assert) return reset.call(this) // x is a node --test TestContext object -> ignore else return reset.call(this,x) // x is a db service instance @@ -38,7 +40,10 @@ class DataUtil { } } - /* delete + new deploy from csv */ + /** + * delete + new deploy from csv + * @param {cds.DatabaseService} db - db + */ async reset(db) { if (!db) db = await cds.connect.to('db') await this.delete(db) diff --git a/lib/expect.js b/lib/expect.js index 21ba362..d50be6c 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -1,4 +1,8 @@ const { inspect } = require('node:util') + +/** + * @param {{status: any, body: any}} x + */ const format = x => inspect( is.error(x) ? x.message : typeof x === 'object' && 'status' in x && 'body' in x ? { status: x.status, body: x.body } @@ -12,18 +16,33 @@ const expect = module.exports = actual => { return Object.setPrototypeOf(chainable, new Assertion(actual)) } +/** + * @template T + * @typedef {(x: any) => x is T} Is */ + const is = new class { Array = Array.isArray + /** @type {Is} */ Error = x => x instanceof Error || x?.stack && x.message + /** @type {Is} */ Symbol = x => typeof x === 'symbol' + /** @type {Is} */ Object = x => typeof x === 'object' // && x && !is.array(x) + /** @type {Is} */ String = x => typeof x === 'string' || x instanceof String + /** @type {Is} */ Number = x => typeof x === 'number' || x instanceof Number + /** @type {Is} */ Boolean = x => typeof x === 'boolean' || x instanceof Boolean + /** @type {Is>} */ Promise = x => x instanceof Promise + /** @type {Is} */ RegExp = x => x instanceof RegExp + /** @type {Is} */ Date = x => x instanceof Date + /** @type {Is>} */ Set = x => x instanceof Set + /** @type {Is} */ Map = x => x instanceof Map array = this.Array error = this.Error diff --git a/lib/reporter.js b/lib/reporter.js index 7478777..ee83095 100644 --- a/lib/reporter.js +++ b/lib/reporter.js @@ -129,6 +129,7 @@ module.exports = function report_on (test,o) { /** * Adds handlers to debug test stream events. + * @param {string} events - comma-separated list of events to debug */ function debug (events) { inspect.defaultOptions.depth = 11 diff --git a/package-lock.json b/package-lock.json index 2da9cd5..6686804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@cap-js/cds-test": "file://.", + "@cap-js/cds-types": "^0.9.0", "@cap-js/sqlite": "^1.5.0", "@cap-js/test-sample-app": "file://./test/app/", "@sap/cds": "^8.8", @@ -37,13 +38,14 @@ "link": true }, "node_modules/@cap-js/cds-types": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@cap-js/cds-types/-/cds-types-0.2.0.tgz", - "integrity": "sha512-s4iVwAjf+rRIUu6jaEooXFcJv16+sP5CTkreQPxDUyxLWWGlhvEr67TuIH0C6Cnp4PPIsYmBK3AVxSW2mNc2wg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@cap-js/cds-types/-/cds-types-0.9.0.tgz", + "integrity": "sha512-AD4WGAOOSszaleQQqheIo0hHm50zk3NejMlHsuG6cLh4EyK/kozvcx8hkWfAkUT/s11fa8OjyMhztFCy8b5DAA==", "dev": true, - "license": "SEE LICENSE IN LICENSE", + "hasInstallScript": true, "peerDependencies": { - "@sap/cds": ">=7" + "@sap/cds": "^8.0.0", + "@types/express": ">=4" } }, "node_modules/@cap-js/db-service": { @@ -159,6 +161,114 @@ "node": ">=14" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "peer": true + }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, + "peer": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "peer": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "peer": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1696,6 +1806,13 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "peer": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index e43b69c..df97db1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "node": ">=20" }, "scripts": { + "check-types": "tsc", "test": "node --test \"test/**/*.test.js\"", "test:mocha": "npx -y mocha \"test/**/*.test.js\"", "test:jest": "npx -y jest \"test/.*\\.test\\.js\"", @@ -42,8 +43,9 @@ }, "devDependencies": { "@cap-js/cds-test": "file://.", - "@cap-js/test-sample-app": "file://./test/app/", + "@cap-js/cds-types": "^0.9.0", "@cap-js/sqlite": "^1.5.0", + "@cap-js/test-sample-app": "file://./test/app/", "@sap/cds": "^8.8", "express": "^4.17.1" } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..75df448 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "strictBindCallApply": true, + "skipLibCheck": true, + "noEmit": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "alwaysStrict": true, + "esModuleInterop": true, + "checkJs": true, + "allowJs": true, + "declaration": true, + "target": "ES2016", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist" + }, + "include": [ + "./lib/**/*.js" + ], + "verbose": true +} From 2c1974652c8cbbad94f65e3f58a594f5d24c9213 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 26 Mar 2025 08:24:35 +0100 Subject: [PATCH 02/22] Reuse Axios types --- lib/axios.js | 20 ++++++++++++++------ lib/expect.js | 2 +- tsconfig.json | 1 - 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/axios.js b/lib/axios.js index b5e475a..c7d91a2 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -1,4 +1,6 @@ const http = require('http') + +/** @typedef {import('axios').Axios} Axios_ */ class Axios { get axios() { const axios = require('axios').create ({ @@ -13,12 +15,18 @@ class Axios { }}) return super.axios = axios } - get (/** @type{any} */..._) { return this.axios.get (..._args(_)) .catch(_error) } - put (/** @type{any} */..._) { return this.axios.put (..._args(_)) .catch(_error) } - post (/** @type{any} */..._) { return this.axios.post (..._args(_)) .catch(_error) } - patch (/** @type{any} */..._) { return this.axios.patch (..._args(_)) .catch(_error) } - delete (/** @type{any} */..._) { return this.axios.delete (..._args(_)) .catch(_error) } - options (/** @type{any} */..._) { return this.axios.options (..._args(_)) .catch(_error) } + /** @type {Axios_["get"]} */ + get (..._) { return this.axios.get (..._args(_)) .catch(_error) } + /** @type {Axios_["put"]} */ + put (..._) { return this.axios.put (..._args(_)) .catch(_error) } + /** @type {Axios_["post"]} */ + post (..._) { return this.axios.post (..._args(_)) .catch(_error) } + /** @type {Axios_["patch"]} */ + patch (..._) { return this.axios.patch (..._args(_)) .catch(_error) } + /** @type {Axios_["delete"]} */ + delete (..._) { return this.axios.delete (..._args(_)) .catch(_error) } + /** @type {Axios_["options"]} */ + options (..._) { return this.axios.options (..._args(_)) .catch(_error) } /** @type typeof _.get */ get GET() { return this.get .bind (this) } /** @type typeof _.put */ get PUT() { return this.put .bind (this) } diff --git a/lib/expect.js b/lib/expect.js index d50be6c..05ffdfe 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -42,7 +42,7 @@ const is = new class { Date = x => x instanceof Date /** @type {Is>} */ Set = x => x instanceof Set - /** @type {Is} */ + /** @type {Is>} */ Map = x => x instanceof Map array = this.Array error = this.Error diff --git a/tsconfig.json b/tsconfig.json index 75df448..eec5a10 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "strictPropertyInitialization": true, "strictBindCallApply": true, "skipLibCheck": true, - "noEmit": true, "noImplicitThis": true, "noImplicitReturns": true, "alwaysStrict": true, From 6b129cfe3fd8cd2740be53a8097d3ed92606a1a5 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 26 Mar 2025 08:26:27 +0100 Subject: [PATCH 03/22] Formatting --- lib/axios.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/axios.js b/lib/axios.js index c7d91a2..ec56fa9 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -28,13 +28,20 @@ class Axios { /** @type {Axios_["options"]} */ options (..._) { return this.axios.options (..._args(_)) .catch(_error) } - /** @type typeof _.get */ get GET() { return this.get .bind (this) } - /** @type typeof _.put */ get PUT() { return this.put .bind (this) } - /** @type typeof _.post */ get POST() { return this.post .bind (this) } - /** @type typeof _.patch */ get PATCH() { return this.patch .bind (this) } - /** @type typeof _.delete */ get DELETE() { return this.delete .bind (this) } - /** @type typeof _.delete */ get DEL() { return this.delete .bind (this) } //> to avoid conflicts with cds.ql.DELETE - /** @type typeof _.options */ get OPTIONS() { return this.options .bind (this) } + /** @type typeof _.get */ + get GET() { return this.get .bind (this) } + /** @type typeof _.put */ + get PUT() { return this.put .bind (this) } + /** @type typeof _.post */ + get POST() { return this.post .bind (this) } + /** @type typeof _.patch */ + get PATCH() { return this.patch .bind (this) } + /** @type typeof _.delete */ + get DELETE() { return this.delete .bind (this) } + /** @type typeof _.delete */ + get DEL() { return this.delete .bind (this) } //> to avoid conflicts with cds.ql.DELETE + /** @type typeof _.options */ + get OPTIONS() { return this.options .bind (this) } } From 9112ec479771b7026b120463d847550e65b35d2e Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 27 Mar 2025 14:09:33 +0100 Subject: [PATCH 04/22] More types --- lib/axios.js | 29 ++++++++++++++++++----------- lib/data.js | 9 ++++++++- lib/expect.js | 4 +++- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/lib/axios.js b/lib/axios.js index ec56fa9..c133de3 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -1,9 +1,14 @@ const http = require('http') +const axios = require('axios') -/** @typedef {import('axios').Axios} Axios_ */ class Axios { + /** @type {axios.Axios | undefined} */ + #axios + /** @type {string | undefined} */ + url + get axios() { - const axios = require('axios').create ({ + const ax = require('axios').default.create ({ headers: { 'Content-Type': 'application/json' }, httpAgent: new http.Agent({ keepAlive: false}), baseURL: this.url, @@ -11,21 +16,22 @@ class Axios { // fill in baseURL on subsequent this.url = url, after server has started Reflect.defineProperty (this, 'url', { configurable: true, set: url => { Reflect.defineProperty (this, 'url', { value: url }) - axios.defaults.baseURL = url + axios.default.defaults.baseURL = url }}) - return super.axios = axios + this.#axios = ax + return this.#axios } - /** @type {Axios_["get"]} */ + /** @type {axios.Axios["get"]} **/ get (..._) { return this.axios.get (..._args(_)) .catch(_error) } - /** @type {Axios_["put"]} */ + /** @type {axios.Axios["put"]} */ put (..._) { return this.axios.put (..._args(_)) .catch(_error) } - /** @type {Axios_["post"]} */ + /** @type {axios.Axios["post"]} */ post (..._) { return this.axios.post (..._args(_)) .catch(_error) } - /** @type {Axios_["patch"]} */ + /** @type {axios.Axios["patch"]} */ patch (..._) { return this.axios.patch (..._args(_)) .catch(_error) } - /** @type {Axios_["delete"]} */ + /** @type {axios.Axios["delete"]} */ delete (..._) { return this.axios.delete (..._args(_)) .catch(_error) } - /** @type {Axios_["options"]} */ + /** @type {axios.Axios["options"]} */ options (..._) { return this.axios.options (..._args(_)) .catch(_error) } /** @type typeof _.get */ @@ -49,7 +55,8 @@ class Axios { * @param {any[]} args - args */ const _args = (args) => { - const first = args[0], last = args[args.length-1] + const first = args[0] + const last = args[args.length-1] if (first.raw) { if (first[first.length-1] === '' && typeof last === 'object') return [ String.raw(...args.slice(0,-1)), last ] diff --git a/lib/data.js b/lib/data.js index d0c0ef8..002061e 100644 --- a/lib/data.js +++ b/lib/data.js @@ -6,7 +6,8 @@ class DataUtil { constructor() { // This is to support simplified usage like that: beforeEach(test.data.reset) - const {reset} = this; this.reset = (/** @type{any}*/ x) => { + const {reset} = this + this.reset = (/** @type{any}*/ x) => { if (typeof x === 'function') reset.call(this).then(x,x) // x is the done callback of jest -> no return else if (x?.assert) return reset.call(this) // x is a node --test TestContext object -> ignore else return reset.call(this,x) // x is a db service instance @@ -17,11 +18,17 @@ class DataUtil { global.beforeEach (() => this.reset()) } + /** + * @param {cds.DatabaseService} db - db + */ async deploy(db) { if (!db) db = await cds.connect.to('db') await cds.deploy.data(db) } + /** + * @param {cds.DatabaseService} db - db + */ async delete(db) { if (!db) db = await cds.connect.to('db') if (!this._deletes) { diff --git a/lib/expect.js b/lib/expect.js index 05ffdfe..4c45704 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -12,7 +12,9 @@ const format = x => inspect( ) const expect = module.exports = actual => { - const chainable = function (x) { return this.call(x) }; delete chainable.length + const chainable = function (x) { + return this.call(x) + } return Object.setPrototypeOf(chainable, new Assertion(actual)) } From 9aea8a87a84c67b3f603233141f8c3d90a44f050 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 10 Sep 2025 10:38:01 +0200 Subject: [PATCH 05/22] Restore types --- lib/axios.js | 40 +++++++++++--- package-lock.json | 134 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 + tsconfig.json | 6 +++ 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/lib/axios.js b/lib/axios.js index e55e0fe..2b06604 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -1,33 +1,49 @@ +/** @typedef {import('axios')} axios */ +/** @typedef {import('axios').AxiosInstance} Axios */ + + const { NAXIOS } = process.env //> for early birds, aka canaries +// @ts-ignore if (NAXIOS) require = id => module.require (id === 'axios' ? './naxios' : id) // eslint-disable-line no-global-assign class AxiosProvider { + /** @type {Axios} */ + // @ts-expect-error - will always be access through getter and thus be defined + #axios + /** @type {string | undefined} */ + #url get axios() { const http = require('node:http') + /**@type {import('axios').default}*/ + // @ts-expect-error - axios is ESM, this would require ugly cast to unknown -> axios.default const axios = require('axios') - return super.axios = axios.create ({ + return this.#axios ??= axios.create ({ httpAgent: new http.Agent({ keepAlive: false}), //> https://github.com/nodejs/node/issues/47130 headers: { 'content-type': 'application/json' }, - baseURL: this.url, + baseURL: this.#url, }) } + /** @param {string} url */ set url (url) { // fill in baseURL when this.url is filled in subsequently on server start if (Object.hasOwn(this,'axios')) this.axios.defaults.baseURL = url - super.url = url + this.#url = url } + /** @type {Axios["options"]} */ options (..._) { return this.axios.options (..._args(_)) .catch(_error) } + /** @type {Axios["head"]} */ head (..._) { return this.axios.head (..._args(_)) .catch(_error) } + /** @type {Axios["get"]} */ get (..._) { return this.axios.get (..._args(_)) .catch(_error) } - /** @type {axios.Axios["put"]} */ + /** @type {Axios["put"]} */ put (..._) { return this.axios.put (..._args(_)) .catch(_error) } - /** @type {axios.Axios["post"]} */ + /** @type {Axios["post"]} */ post (..._) { return this.axios.post (..._args(_)) .catch(_error) } - /** @type {axios.Axios["patch"]} */ + /** @type {Axios["patch"]} */ patch (..._) { return this.axios.patch (..._args(_)) .catch(_error) } - /** @type {axios.Axios["delete"]} */ + /** @type {Axios["delete"]} */ delete (..._) { return this.axios.delete (..._args(_)) .catch(_error) } /** @type typeof self.options */ get OPTIONS() { return this.options .bind (this) } @@ -57,6 +73,16 @@ const _args = (args) => { else throw new Error (`Argument path is expected to be a string but got ${typeof first}`) } +/** + * @typedef { Error & { + * cause: { code: string }, + * code: string, + * status: number, + * errors?: ErrorType[], + * response: any + * }} ErrorType + */ +/** @param {ErrorType} err */ const _error = (err) => { // Node 20 sends AggregationErrors -> REVISIT: is that still the case? Doesn't seem so with Node 22 diff --git a/package-lock.json b/package-lock.json index 526e35d..f95f9cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "chest": "bin/chest.js" }, "devDependencies": { + "@cap-js/cds-types": "^0.14.0", "@cap-js/sqlite": "^1.5.0 || ^2", + "@types/node": "^24.3.1", "express": "^4.17.1" }, "engines": { @@ -29,6 +31,23 @@ "@sap/cds": ">=8.8" } }, + "node_modules/@cap-js/cds-types": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@cap-js/cds-types/-/cds-types-0.14.0.tgz", + "integrity": "sha512-wscDWFRAsrjFz0cF5moaJAhIOi7SVKyUYqZ7UeOW1SaJRbhYed9wDIFVGpB2h2jME/rqX1pPGhGvQQcqwFMRxw==", + "dev": true, + "hasInstallScript": true, + "peerDependencies": { + "@sap/cds": ">=9.0.0", + "@sap/cds-dk": "^9", + "@types/express": ">=4" + }, + "peerDependenciesMeta": { + "@sap/cds-dk": { + "optional": true + } + } + }, "node_modules/@cap-js/db-service": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@cap-js/db-service/-/db-service-2.1.2.tgz", @@ -124,6 +143,112 @@ "express": ">=4" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "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, + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "dev": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "peer": true + }, + "node_modules/@types/node": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "dev": true, + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "peer": true + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "peer": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dev": true, + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1606,11 +1731,10 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "peer": true + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index f92a6ba..4060c18 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@sap/cds": ">=8.8" }, "devDependencies": { + "@cap-js/cds-types": "^0.14.0", "@cap-js/sqlite": "^1.5.0 || ^2", + "@types/node": "^24.3.1", "express": "^4.17.1" } } diff --git a/tsconfig.json b/tsconfig.json index eec5a10..b177fa9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "lib": ["ESNext"], "strict": true, "noImplicitAny": true, "strictFunctionTypes": true, @@ -21,5 +22,10 @@ "include": [ "./lib/**/*.js" ], + "paths": { + "@sap/cds": [ + "node_modules/@cap-js/cds-types", + ] + }, "verbose": true } From cec36d46c6e26f8ef8e280de2c7c3cc58f7bc242 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 10 Sep 2025 15:54:33 +0200 Subject: [PATCH 06/22] More types --- lib/axios.js | 12 ++++++++---- lib/cds-test.js | 40 ++++++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/lib/axios.js b/lib/axios.js index 2b06604..d20e950 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -60,14 +60,18 @@ const self = AxiosProvider.prototype // eslint-disable-line no-unused-vars /** - * @param {any[]} args - args + * @template {any} T + * @param {T[]} args - args + * @returns {readonly T[]} */ const _args = (args) => { const first = args[0], last = args.at(-1) if (first.raw) { - if (first.at(-1) === '' && typeof last === 'object') - return [ String.raw(...args.slice(0,-1)).trim(), last ] - return [ String.raw(...args) ] + return (first.at(-1) === '' && typeof last === 'object') + // @ts-expect-error + ? [ String.raw(...args.slice(0,-1)).trim(), last ] + // @ts-expect-error + : [ String.raw(...args) ] } if (typeof first === 'string') return args else throw new Error (`Argument path is expected to be a string but got ${typeof first}`) diff --git a/lib/cds-test.js b/lib/cds-test.js index 582fed2..1143ee9 100644 --- a/lib/cds-test.js +++ b/lib/cds-test.js @@ -8,6 +8,7 @@ class Test extends require('./axios') { */ test = this + /** @returns {import('@sap/cds')} */ get cds() { return require('@sap/cds/lib') } get sleep() { return super.sleep = require('node:timers/promises').setTimeout } get data() { return super.data = new (require('./data'))} @@ -15,6 +16,8 @@ class Test extends require('./axios') { /** * Launches a cds server with arbitrary port and returns a subclass which * also acts as an axios lookalike, providing methods to send requests. + * @param {string} folder_or_cmd - either a folder to serve or the command 'serve' or 'run' + * @param {...string} args - additional arguments, e.g. '--project', 'myapp' */ run (folder_or_cmd, ...args) { @@ -47,6 +50,8 @@ class Test extends require('./axios') { * Serving projects from subfolders under the root specified by a sequence * of path components which are concatenated with path.resolve(). * Checks conflicts with cds.env loaded in other folder before. + * @param {string} folder - folder name + * @param {...string} paths - additional path components */ in (folder, ...paths) { if (!folder) return this @@ -63,12 +68,12 @@ class Test extends require('./axios') { if (process.env.CDS_TEST_ENV_CHECK) { const env = Reflect.getOwnPropertyDescriptor(cds,'env')?.value if (env && env._home !== folder && env.stack) { - let filter = line => !line.match(/node_modules\/jest-|node:internal/) + let filter = (/** @type {string} */line) => !line.match(/node_modules\/jest-|node:internal/) let err = new Error; err.message = `Detected cds.env loaded before running cds.test in different folder: \n` + `1. cds.env loaded from: ${local(cds.env._home)||'./'} \n` + `2. cds.test running in: ${local(folder)} \n\n` + - err.stack.split('\n').filter(filter).slice(1).join('\n') + err.stack?.split('\n').filter(filter).slice(1).join('\n') err.stack = env.stack.split('\n').filter(filter).slice(1).join('\n') throw err } @@ -79,20 +84,30 @@ class Test extends require('./axios') { /** * Method to spy on a function in an object, similar to jest.spyOn(). + * @template {any} T + * @param {T} o - object + * @param {keyof T} f - function name */ spy (o,f) { - const origin = o[f] + const origin = /** @type {Function} */(o[f]) + /** + * @this {Test} + * @param {...any} args - arguments + */ const fn = function (...args) { ++fn.called - return origin.apply(this,args) + return origin.apply(this, args) } fn.called = 0 + // @ts-expect-error - fn.restore = ()=> o[f] = origin + // @ts-expect-error - return o[f] = fn } /** * For usage in repl, e.g. var test = await cds.test() + * @param {(args: { server: import('http').Server, url: string }) => void} resolve - see cds.once(..., resolve) */ then (resolve) { if (this.server) { @@ -104,18 +119,19 @@ class Test extends require('./axios') { /** * Captures console.log output. + * @param {(message?: any, ...optionalParams: any[]) => void} capture */ - log (_capture) { + log (capture) { const {console} = global, {format} = require('util') const log = { output: '' } beforeAll(()=> global.console = { __proto__: console, - log: _capture ??= (..._)=> log.output += format(..._)+'\n', - info: _capture, - warn: _capture, - debug: _capture, - trace: _capture, - error: _capture, - timeEnd: _capture, time: ()=>{}, + log: capture ??= (..._)=> log.output += format(..._)+'\n', + info: capture, + warn: capture, + debug: capture, + trace: capture, + error: capture, + timeEnd: capture, time: ()=>{}, }) afterAll (log.release = ()=>{ log.output = ''; global.console = console }) afterEach (log.clear = ()=>{ log.output = '' }) From 3099e4369cd46b25562acbc2ba326c4712249f0f Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 07:49:12 +0200 Subject: [PATCH 07/22] Add globals --- global.d.ts | 20 ++++++++++++++++++++ lib/data.js | 8 ++++++-- tsconfig.json | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 global.d.ts diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..c6dbe97 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,20 @@ +declare function test(): void; +declare global { + // when extending global, only var can be used + var describe: { + each: function(): void; + skip: function(): void; + } + var it: { + each: function (): void, + skip: function (): void + } + var test: typeof it; + function before(): void; + function beforeAll(): void; + function after(): void; + function afterAll(): void; + function xtest(): void; + function xdescribe(): void; +} +export {}; \ No newline at end of file diff --git a/lib/data.js b/lib/data.js index 002061e..93e2b2d 100644 --- a/lib/data.js +++ b/lib/data.js @@ -7,7 +7,10 @@ class DataUtil { constructor() { // This is to support simplified usage like that: beforeEach(test.data.reset) const {reset} = this - this.reset = (/** @type{any}*/ x) => { + /** + * @param {any} [x] + */ + this.reset = x => { if (typeof x === 'function') reset.call(this).then(x,x) // x is the done callback of jest -> no return else if (x?.assert) return reset.call(this) // x is a node --test TestContext object -> ignore else return reset.call(this,x) // x is a db service instance @@ -23,6 +26,7 @@ class DataUtil { */ async deploy(db) { if (!db) db = await cds.connect.to('db') + // @ts-expect-error - dk type await cds.deploy.data(db) } @@ -47,7 +51,7 @@ class DataUtil { } } - /** + /** * delete + new deploy from csv * @param {cds.DatabaseService} db - db */ diff --git a/tsconfig.json b/tsconfig.json index b177fa9..2e822a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "outDir": "dist" }, "include": [ + "./global.d.ts", "./lib/**/*.js" ], "paths": { From a6992c449a7fdd46de46c1421a1355ed271e755e Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 09:28:22 +0200 Subject: [PATCH 08/22] More types --- global.d.ts | 58 ++++++++++++++++++++++++++++++--------- lib/fixtures/jest.js | 12 ++++++-- lib/fixtures/node-test.js | 4 +++ lib/fixtures/repl.js | 6 ++-- lib/fixtures/test-each.js | 8 ++++++ 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/global.d.ts b/global.d.ts index c6dbe97..5e95db9 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,20 +1,52 @@ -declare function test(): void; +import type { mock } from 'node:test' + declare global { // when extending global, only var can be used var describe: { - each: function(): void; - skip: function(): void; - } + each: () => void; + skip: { + each: () => void; + } + } | (() => void); + var xdescribe: typeof describe['skip']; var it: { - each: function (): void, - skip: function (): void + each: () => void, + skip: () => void } var test: typeof it; - function before(): void; - function beforeAll(): void; - function after(): void; - function afterAll(): void; - function xtest(): void; - function xdescribe(): void; + var xtest: typeof it['skip']; + function before(message: string & Function?, method: Function): void; + function beforeEach(): void + function beforeAll(message: string & Function?, method: Function): void; + function after(message: string & Function?, method: Function): void; + function afterEach(): void; + function afterAll(message: string & Function?, method: Function): void; + function expect(): void; + + var chai: { + expect: typeof expect, + should?: () => void, + fake?: boolean + } + + var jest: { + fn: () => void; + spyOn: typeof mock.method; + restoreAllMocks: () => void; + resetAllMocks: () => void; + clearAllMocks: () => void; + clearAllTimers: () => void; + mock: ( + module: string | unknown, + fn?: Function, + o?: { virtual?: boolean } + ) => void; + setTimeout: () => void; + } + + // cds-dk types + var cds: { + repl: unknown + } } -export {}; \ No newline at end of file +export {}; diff --git a/lib/fixtures/jest.js b/lib/fixtures/jest.js index fb5e9a6..9d6d6bd 100644 --- a/lib/fixtures/jest.js +++ b/lib/fixtures/jest.js @@ -1,2 +1,10 @@ -global.before = (m,fn=m) => global.beforeAll(fn) -global.after = (m,fn=m) => global.afterAll(fn) +/** + * @param {string} message + * @param {string} [fn] + */ +global.before = (message, fn = message) => global.beforeAll(fn) +/** + * @param {string} message + * @param {string} [fn] + */ +global.after = (message, fn = message) => global.afterAll(fn) diff --git a/lib/fixtures/node-test.js b/lib/fixtures/node-test.js index a7f44c4..6f8940d 100644 --- a/lib/fixtures/node-test.js +++ b/lib/fixtures/node-test.js @@ -1,4 +1,5 @@ const { describe, test, before, after, beforeEach, afterEach, mock } = require('node:test') +/** @param {{length: number} & Function} fn */ const _fn = fn => !fn.length ? fn : (_,done) => fn (done) describe.each = test.each = describe.skip.each = test.skip.each = require('./test-each') @@ -24,6 +25,7 @@ global.chai = { global.jest = { fn: (..._) => mock.fn (..._), + // @ts-expect-error - tsc doesn't understand proxy overloads + spreading spyOn: (..._) => mock.method (..._), restoreAllMocks: ()=> mock.restoreAll(), resetAllMocks: ()=> mock.reset(), @@ -32,10 +34,12 @@ global.jest = { mock (module, fn = ()=>{}, o) { if (typeof module === 'string') { const path = require.resolve (module) + // @ts-expect-error - missing props on Module, but we only need exports return require.cache[path] = { get exports () { return require.cache[path] = o?.virtual ? fn() : Object.assign (require(path), fn()) }} } + return undefined }, setTimeout(){} } diff --git a/lib/fixtures/repl.js b/lib/fixtures/repl.js index 20dd603..94eda32 100644 --- a/lib/fixtures/repl.js +++ b/lib/fixtures/repl.js @@ -1,6 +1,6 @@ -const repl = global.cds?.repl || {} -global.beforeAll = global.before = (msg,fn) => (fn||msg)() -global.afterAll = global.after = (msg,fn) => repl.on?.('exit',fn||msg) +const repl = global.cds?.repl ?? {} +global.beforeAll = global.before = (/** @type {Function?}*/msg,/** @type {Function}*/fn) => (fn ?? msg)() +global.afterAll = global.after = (/** @type {Function?}*/msg,/** @type {Function}*/fn) => repl.on?.('exit', fn ?? msg) global.beforeEach = global.afterEach = ()=>{} global.describe = ()=>{} global.chai = { diff --git a/lib/fixtures/test-each.js b/lib/fixtures/test-each.js index 14edabd..a59dc8a 100644 --- a/lib/fixtures/test-each.js +++ b/lib/fixtures/test-each.js @@ -1,6 +1,14 @@ // required for test.each in mocha and node --test const {format} = require('util') +/** + * @param {Array} table + */ module.exports = function each (table) { + /** + * @param {string} msg + * @param {(...args: unknown[]) => unknown} fn + * @return {Promise} + */ return (msg,fn) => Promise.all (table.map (each => { const args = Array.isArray(each) ? each : [each], [label] = args return this (format(msg, label), ()=> fn(...args)) From d4727ef35657040d37847c8ecf3e58499be6d503 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 11:56:04 +0200 Subject: [PATCH 09/22] Types --- global.d.ts | 13 +++++++++---- lib/fixtures/jest.js | 8 ++++---- lib/fixtures/test-each.js | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/global.d.ts b/global.d.ts index 5e95db9..dc6516f 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,25 +1,30 @@ import type { mock } from 'node:test' +import type each_type from './lib/fixtures/test-each.js' declare global { // when extending global, only var can be used var describe: { - each: () => void; + each: typeof each_type; skip: { each: () => void; } - } | (() => void); + }; var xdescribe: typeof describe['skip']; var it: { - each: () => void, + each: typeof each_type, skip: () => void } var test: typeof it; var xtest: typeof it['skip']; + function before(method: Function): void; function before(message: string & Function?, method: Function): void; - function beforeEach(): void + function beforeEach(): void; + function beforeAll(method: Function): void; function beforeAll(message: string & Function?, method: Function): void; + function after(method: Function): void; function after(message: string & Function?, method: Function): void; function afterEach(): void; + function afterAll(method: Function): void; function afterAll(message: string & Function?, method: Function): void; function expect(): void; diff --git a/lib/fixtures/jest.js b/lib/fixtures/jest.js index 9d6d6bd..a07b9a4 100644 --- a/lib/fixtures/jest.js +++ b/lib/fixtures/jest.js @@ -1,10 +1,10 @@ /** - * @param {string} message - * @param {string} [fn] + * @param {Function} message + * @param {Function} [fn] */ global.before = (message, fn = message) => global.beforeAll(fn) /** - * @param {string} message - * @param {string} [fn] + * @param {Function} message + * @param {Function} [fn] */ global.after = (message, fn = message) => global.afterAll(fn) diff --git a/lib/fixtures/test-each.js b/lib/fixtures/test-each.js index a59dc8a..62e5e3b 100644 --- a/lib/fixtures/test-each.js +++ b/lib/fixtures/test-each.js @@ -11,6 +11,7 @@ module.exports = function each (table) { */ return (msg,fn) => Promise.all (table.map (each => { const args = Array.isArray(each) ? each : [each], [label] = args + // @ts-ignore - FIXME: this should be each(...) or this.exports(...)!! return this (format(msg, label), ()=> fn(...args)) })) } From e0647093d452976dc48b1f134cde8fd6a5ea24a2 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 15:16:45 +0200 Subject: [PATCH 10/22] Types --- global.d.ts | 7 ++++--- lib/expect.js | 32 ++++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/global.d.ts b/global.d.ts index dc6516f..9a0a513 100644 --- a/global.d.ts +++ b/global.d.ts @@ -4,14 +4,15 @@ import type each_type from './lib/fixtures/test-each.js' declare global { // when extending global, only var can be used var describe: { - each: typeof each_type; + each?: typeof each_type; skip: { - each: () => void; + (...xs:any[]): unknown + each?: () => void; } }; var xdescribe: typeof describe['skip']; var it: { - each: typeof each_type, + each?: typeof each_type, skip: () => void } var test: typeof it; diff --git a/lib/expect.js b/lib/expect.js index 4656169..6b9ee29 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -1,7 +1,7 @@ const { inspect } = require('node:util') /** - * @param {{status: any, body: any}} x + * @param {{message: string, status: unknown, data: unknown, body?: any}} x */ const format = x => inspect( is.error(x) ? x.message @@ -11,6 +11,7 @@ const format = x => inspect( { colors: true, sorted: true, depth: 11 } ) +/** @type {{(actual: object): unknown, any: Function}} */ const expect = module.exports = actual => { const chainable = function (x) { return this.call(x) @@ -20,7 +21,7 @@ const expect = module.exports = actual => { /** * @template T - * @typedef {(x: any) => x is T} Is */ + * @typedef {(x?: any) => x is T} Is */ const is = new class { Array = Array.isArray @@ -59,18 +60,30 @@ const is = new class { set = this.Set map = this.Map /** Jest-style any matcher */ + + /** + * @param {{name: string} | string} type + */ any = expect.any = type => { if (type === undefined) return () => true + // @ts-expect-error - we do not check if type is an actual valid checker (string, boolean, date, ...) else return this [type.name || type] || (x => x instanceof type) } } - class Core { + _not = false + _own = false + _deep = false + _nested = false + /** @param {any} actual */ constructor (actual) { this._ = actual } - /** The central method to throw an AssertionError. */ + /** + * The central method to throw an AssertionError. + * @param {TemplateStringsArray} args + */ expected ([a, be, ...etc], ...args) { const raw = [a, (this._not ? ' NOT' : '') + be, ...etc] const err = new expected({ raw }, ...args) @@ -80,12 +93,17 @@ class Core { throw err } + /** @param {TemplateStringsArray} args */ should ([be, ...etc], ...args) { return this.expected(['', ' to ' + be, ...etc], this._, ...args) } - /** The central method to check assertions. */ - assert (check, _fail = () => false) { + /** + * The central method to check assertions. + * @param {(actual: any) => boolean} check + * @param {(outcome: boolean) => any} [_fail] + */ + assert (check, _fail = (_) => false) { const outcome = check(this._) if (this._not ? outcome : !outcome) return _fail(outcome) else return this @@ -258,6 +276,7 @@ class Chai extends Core { get equal() { return this.equals } get eq() { return this.equals } get eql() { return this.eqls } + // @ts-expect-error - FIXME! This is probably actually missing! get exists() { return this.defined } get lengthOf() { return this.length } get instanceOf() { return this.instanceof } @@ -305,6 +324,7 @@ class Jest extends Chai { toBeNull() { return this.null } toBeFalsy() { return this.falsy } toBeTruthy() { return this.truthy } + // @ts-expect-error - FIXME! This is probably actually missing! toBeDefined() { return this.defined } toBeUndefined() { return this.undefined } toBeInstanceOf() { return this.instanceof } From 61f782e0f52061da7c4bbe6900fdb42f0298ee4e Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 16:19:46 +0200 Subject: [PATCH 11/22] Types --- lib/expect.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/expect.js b/lib/expect.js index 6b9ee29..4b15f0a 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -11,7 +11,17 @@ const format = x => inspect( { colors: true, sorted: true, depth: 11 } ) -/** @type {{(actual: object): unknown, any: Function}} */ + +/** + * @type {{ + * (actual: object): unknown, any: Function, + * stringMatching: (x: string | RegExp) => (a: string) => boolean, + * stringContaining: (x: string) => (a: string) => boolean, + * arrayContaining: (x: any[]) => (a: any[]) => boolean, + * objectContaining: (x: object) => (a: object) => boolean, + * fail: (actual: any, expected?: any, message?: string) => never, + * }} + */ const expect = module.exports = actual => { const chainable = function (x) { return this.call(x) @@ -82,7 +92,7 @@ class Core { /** * The central method to throw an AssertionError. - * @param {TemplateStringsArray} args + * @param {string[]} args */ expected ([a, be, ...etc], ...args) { const raw = [a, (this._not ? ' NOT' : '') + be, ...etc] @@ -93,7 +103,7 @@ class Core { throw err } - /** @param {TemplateStringsArray} args */ + /** @param {string[] | TemplateStringsArray} args */ should ([be, ...etc], ...args) { return this.expected(['', ' to ' + be, ...etc], this._, ...args) } @@ -109,25 +119,30 @@ class Core { else return this } + /** @param {Function & string} x */ instanceof (x) { return this.assert(a => a instanceof x) || this.should`be an instance of ${x.name || x}` } + /** @param {Function & string} x */ kindof (x) { return this.assert(is.any(x)) || this.should`be kind of ${x?.name || x}` } + /** @param {Function & string} x */ equals (x, _fail = () => this.should`strictly equal ${x}`) { if (typeof x === 'function') return this.assert(x) if (this._deep) return this.eqls(x) return this.assert(a => a === x, _fail) } + /** @param {Function & string} x */ eqls (x, _fail = () => this.should`deeply equal ${x}`) { if (typeof x === 'function') return this.assert(x) return this.assert(a => compare(a, x, true), _fail) } + /** @param {Function & string} x */ subset (x, _fail = () => this.should`contain subset ${x}`) { return this.assert(a => { if (is.array(a) && is.array(x)) return x.every(x => a.some(o => compare(o,x))) @@ -136,6 +151,7 @@ class Core { }, _fail) } + /** @param {Function & string} x */ matches (x, _fail = () => this.should`match ${x}`) { return this.assert(a => { if (is.regexp(x)) return x.test(a) @@ -145,6 +161,7 @@ class Core { }, _fail) } + /** @param {Function & string} x */ includes (x, _fail = () => this.should`include ${x}`) { return this.assert(a => { if (!a) expected`an array or string or set or object but got ${a}` @@ -152,13 +169,16 @@ class Core { if (is.array(a)) return a.includes(x) || this._deep && a.some(o => compare(o,x)) if (is.set(a)) return a.has(x) if (is.object(a)) return compare(a,x) + return false }, _fail) } + /** @param {Function & string} x */ oneOf (x, _fail = () => this.should`be one of ${x}`) { return this.assert(a => x.includes(a), _fail) } + /** @param {Function & string} x */ throws (x, _fail = () => this.should`throw ${x}`) { if (is.promise(this._)) return this.rejectsWith(x) return this.assert(a => { @@ -170,6 +190,7 @@ class Core { }, _fail) } + /** @param {Function & string} x */ rejectsWith (x) { if (this._not) return Promise.resolve(this._).catch( e => expected`promise to be fulfilled but it was rejected with ${e}` @@ -183,10 +204,15 @@ class Core { ) } + /** @param {number} ln */ length (ln) { return this.assert(a => (a.length ?? String(a).length) === ln, () => this.should`have length ${ln}`) } + /** + * @param {string | string[]} p + * @param {any} [v] + */ property (p, v) { const has = !this._own ? (a, p) => a && typeof a === 'object' && p in a : Reflect.getOwnPropertyDescriptor const get = (a, p) => has(a, p) ? a[p] : $not_found, $not_found = {} @@ -201,6 +227,7 @@ class Core { return that } + /** @param {string | string[]} keys */ keys (...keys) { if (is.array(keys[0])) keys = keys[0] return this.assert(a => keys.every(k => k in a)) || this.should`have all keys ${keys}` @@ -337,18 +364,27 @@ class Jest extends Chai { () => this.should`have been called at least once` ) } + /** + * @param {number} count + */ toHaveBeenCalledTimes (count) { return this.assert ( fn => count === fn.mock.callCount(), () => this.should`have been called ${count} times, but was called ${this._.mock.callCount()} times` ) } + /** + * @param {...any} args + */ toHaveBeenCalledWith (...args) { return this.assert ( fn => fn.mock.calls.some(c => compare(c.arguments,args,true)), () => this.should`have been called with ${args}` ) } + /** + * @param {...any} args + */ toHaveBeenLastCalledWith (...args) { return this.assert ( fn => compare(fn.mock.calls.at(-1).arguments,args,true), @@ -391,13 +427,21 @@ expect.fail = function (actual, expected, message) { if (arguments.length === 3) throw Object.assign (new AssertionError (message, expect.fail), { expected, actual }) } +/** + * @param {{ raw: readonly string[] | ArrayLike; }} strings + * @param {...any} args + */ function expected (strings, ...args) { const err = new AssertionError ('expected ' + String.raw(strings, ...args.map(format))) if (new.target) return err; else throw err } +/** + * @param {string} [method] + */ function unsupported (method) { - const ignore = unsupported.skip ??= (process.env._chest_skip || '')?.split(',').reduce((p, c) => (p[c] = 1, p), {}) + // @ts-expect-error - skip not picked up by type system even when explicitly typed + const ignore = unsupported.skip ??= (process.env._chest_skip || '')?.split(',').reduce((p, c) => (p[c] = 1, p), /** @type{Record} */({})) if (!method) return new Error(`unsupported`) if (method in ignore) return () => { } else throw new Error(` @@ -406,6 +450,11 @@ function unsupported (method) { `) } +/** + * @param {*} a + * @param {*} b + * @param {boolean} [strict] + */ function compare (a, b, strict) { if (a == b) return true if (Buffer.isBuffer(a)) return Buffer.isBuffer(b) && a.equals(b) From d7808118131319442f1898f6472433a7be211794 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 16:46:32 +0200 Subject: [PATCH 12/22] Types --- lib/expect.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/expect.js b/lib/expect.js index 4b15f0a..9231f7c 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -14,7 +14,8 @@ const format = x => inspect( /** * @type {{ - * (actual: object): unknown, any: Function, + * (actual?: any): unknown, + * any: Function, * stringMatching: (x: string | RegExp) => (a: string) => boolean, * stringContaining: (x: string) => (a: string) => boolean, * arrayContaining: (x: any[]) => (a: any[]) => boolean, @@ -34,6 +35,7 @@ const expect = module.exports = actual => { * @typedef {(x?: any) => x is T} Is */ const is = new class { + /** @type {Is>} */ Array = Array.isArray /** @type {Is} */ Error = x => x instanceof Error || x?.stack && x.message @@ -92,7 +94,7 @@ class Core { /** * The central method to throw an AssertionError. - * @param {string[]} args + * @param {any[] | TemplateStringsArray} args */ expected ([a, be, ...etc], ...args) { const raw = [a, (this._not ? ' NOT' : '') + be, ...etc] @@ -103,7 +105,7 @@ class Core { throw err } - /** @param {string[] | TemplateStringsArray} args */ + /** @param {any[] | TemplateStringsArray} args */ should ([be, ...etc], ...args) { return this.expected(['', ' to ' + be, ...etc], this._, ...args) } @@ -178,12 +180,13 @@ class Core { return this.assert(a => x.includes(a), _fail) } - /** @param {Function & string} x */ + /** @param {Function & string & { test?: Function }} x */ throws (x, _fail = () => this.should`throw ${x}`) { if (is.promise(this._)) return this.rejectsWith(x) return this.assert(a => { if (typeof a === 'function') try { a(); return false } catch (err) { if (!x) return true; else this._= a = err } if (typeof x.test === 'function') return x.test(a) + // @ts-expect-error - TS does not pick up on the type guard if (typeof x === 'function') return x(a) if (typeof x === 'string') return a == x || a.code == x || a.message?.includes(x) if (typeof x === 'object') return compare(a,x) @@ -204,7 +207,8 @@ class Core { ) } - /** @param {number} ln */ + // tricking TS into coercion so that numbers are allowed and can be passed on to should`` + /** @param {number & string} ln */ length (ln) { return this.assert(a => (a.length ?? String(a).length) === ln, () => this.should`have length ${ln}`) } @@ -214,7 +218,7 @@ class Core { * @param {any} [v] */ property (p, v) { - const has = !this._own ? (a, p) => a && typeof a === 'object' && p in a : Reflect.getOwnPropertyDescriptor + const has = !this._own ? (/** @type {object}*/ a, /** @type {string}*/ p) => a && typeof a === 'object' && p in a : Reflect.getOwnPropertyDescriptor const get = (a, p) => has(a, p) ? a[p] : $not_found, $not_found = {} const y = this.assert(() => true) && !this._nested ? get(this._, p) : (p.split?.('.') ?? p).reduce((a, p) => get(a, p), this._) if (y === $not_found) return this._not || (this._nested @@ -227,16 +231,21 @@ class Core { return that } - /** @param {string | string[]} keys */ + /** @param {string[]} keys */ keys (...keys) { - if (is.array(keys[0])) keys = keys[0] + if (is.array(keys[0])) keys = /** @type{string[]}*/(keys[0]) return this.assert(a => keys.every(k => k in a)) || this.should`have all keys ${keys}` } + /** @param {number} x */ gt (x) { return this.assert(a => a > x) || this.should`be > ${x}` } + /** @param {number} x */ lt (x) { return this.assert(a => a < x) || this.should`be < ${x}` } + /** @param {number} x */ gte (x) { return this.assert(a => a >= x) || this.should`be >= ${x}` } + /** @param {number} x */ lte (x) { return this.assert(a => a <= x) || this.should`be <= ${x}` } + /** @param {number} x */ within (x, y) { return this.assert(a => x <= a && a <= y) || this.should`be within ${[x, y]}` } } @@ -282,7 +291,7 @@ class Chai extends Core { get null() { return this.assert(a => a === null) || this.should`be ${null}` } get true() { return this.assert(a => a === true) || this.should`be ${true}` } get false() { return this.assert(a => a === false) || this.should`be ${false}` } - get empty() { return this.assert(a => !a?.length === 0 || Object.keys(a).length === 0) || this.should`be empty` } + get empty() { return this.assert((/** @type {{length: number}} */a) => !a?.length === 0 || Object.keys(a).length === 0) || this.should`be empty` } get NaN() { return this.assert(a => isNaN(a)) || this.should`be ${NaN}` } get ok() { return this.truthy } From 99a219d8311b4583f025d0be787d7bcecedd6bce Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 16:49:58 +0200 Subject: [PATCH 13/22] Types --- lib/expect.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/expect.js b/lib/expect.js index 9231f7c..da278b4 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -291,6 +291,7 @@ class Chai extends Core { get null() { return this.assert(a => a === null) || this.should`be ${null}` } get true() { return this.assert(a => a === true) || this.should`be ${true}` } get false() { return this.assert(a => a === false) || this.should`be ${false}` } + // @ts-expect-error - FIXME: this is always false due to precedence! get empty() { return this.assert((/** @type {{length: number}} */a) => !a?.length === 0 || Object.keys(a).length === 0) || this.should`be empty` } get NaN() { return this.assert(a => isNaN(a)) || this.should`be ${NaN}` } get ok() { return this.truthy } From 1af46bf07873aaf99244b2974834473df8e72f04 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Thu, 11 Sep 2025 16:57:42 +0200 Subject: [PATCH 14/22] Types --- lib/expect.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/expect.js b/lib/expect.js index da278b4..807bc55 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -419,6 +419,10 @@ class Assertion extends Jest { class AssertionError extends Error { + /** + * @param {string} m - message + */ + // @ts-expect-error - super(...) usually returns void (in this case it returns Error, so it is fine to do) constructor (m, caller = Assertion.prototype.should) { Error.captureStackTrace (super(m), caller) } get caller() { return Assertion.prototype.should } get code() { return 'ERR_ASSERTION' } From 31d21e630c1719cb6e9bebe8e02384bf9d79c3c3 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 17 Sep 2025 16:14:00 +0200 Subject: [PATCH 15/22] Types --- global.d.ts | 2 +- lib/fixtures/node-test.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/global.d.ts b/global.d.ts index 9a0a513..24e1929 100644 --- a/global.d.ts +++ b/global.d.ts @@ -27,7 +27,7 @@ declare global { function afterEach(): void; function afterAll(method: Function): void; function afterAll(message: string & Function?, method: Function): void; - function expect(): void; + function expect(_:any): void; var chai: { expect: typeof expect, diff --git a/lib/fixtures/node-test.js b/lib/fixtures/node-test.js index 6f8940d..066e054 100644 --- a/lib/fixtures/node-test.js +++ b/lib/fixtures/node-test.js @@ -1,7 +1,10 @@ const { describe, test, before, after, beforeEach, afterEach, mock } = require('node:test') /** @param {{length: number} & Function} fn */ -const _fn = fn => !fn.length ? fn : (_,done) => fn (done) +const _fn = fn => !fn.length + ? fn + : (/** @type {any} */ _, /** @type {any} */ done) => fn (done) +// @ts-expect-error - adding new property to Suite type describe.each = test.each = describe.skip.each = test.skip.each = require('./test-each') global.describe = describe global.beforeEach = beforeEach From 48d97dce9a9a7f7a309fe3d0a7c22ca616876f87 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 17 Sep 2025 17:13:43 +0200 Subject: [PATCH 16/22] Types --- global.d.ts | 9 +++++---- lib/cds-test.js | 30 ++++++++++++++++++++++++------ package-lock.json | 26 ++++++++++++++++++++++++++ package.json | 2 ++ 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/global.d.ts b/global.d.ts index 24e1929..d52bbf7 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,9 +1,10 @@ -import type { mock } from 'node:test' +import type { HookFn, mock } from 'node:test' import type each_type from './lib/fixtures/test-each.js' declare global { // when extending global, only var can be used var describe: { + (message: string, method: Function): void, each?: typeof each_type; skip: { (...xs:any[]): unknown @@ -19,12 +20,12 @@ declare global { var xtest: typeof it['skip']; function before(method: Function): void; function before(message: string & Function?, method: Function): void; - function beforeEach(): void; + function beforeEach(method: Function): void; function beforeAll(method: Function): void; function beforeAll(message: string & Function?, method: Function): void; function after(method: Function): void; function after(message: string & Function?, method: Function): void; - function afterEach(): void; + function afterEach(method: HookFn): void; function afterAll(method: Function): void; function afterAll(message: string & Function?, method: Function): void; function expect(_:any): void; @@ -32,7 +33,7 @@ declare global { var chai: { expect: typeof expect, should?: () => void, - fake?: boolean + fake?: boolean, } var jest: { diff --git a/lib/cds-test.js b/lib/cds-test.js index 1143ee9..6e64da3 100644 --- a/lib/cds-test.js +++ b/lib/cds-test.js @@ -7,11 +7,15 @@ class Test extends require('./axios') { * Allows: const { GET, expect, test } = cds.test() */ test = this + /** @type {import('node:timers/promises').setTimeout | undefined} */ + #sleep + /** @type {import('./data') | undefined} */ + #data /** @returns {import('@sap/cds')} */ get cds() { return require('@sap/cds/lib') } - get sleep() { return super.sleep = require('node:timers/promises').setTimeout } - get data() { return super.data = new (require('./data'))} + get sleep() { return this.#sleep = require('node:timers/promises').setTimeout } + get data() { return this.#data = new (require('./data'))} /** * Launches a cds server with arbitrary port and returns a subclass which @@ -32,6 +36,7 @@ class Test extends require('./axios') { before (async ()=>{ process.env.cds_test_temp = cds.utils.path.resolve (cds.root,'_out',''+process.pid) if (!args.includes('--port')) args.push ('--port', '0') + // @ts-expect-error - cds.exec is not in types (cds-dk?) let { server, url } = await cds.exec (...args) this.server = server this.url = url @@ -39,7 +44,8 @@ class Test extends require('./axios') { // gracefully shutdown cds server... after (()=> Promise.all([ - cds.utils.rimraf (process.env.cds_test_temp), + cds.utils.rimraf (/** @type {string} */(process.env.cds_test_temp)), + // @ts-expect-error - cds.shutdown not in types (cds-dk?) cds.shutdown(), ])) @@ -78,6 +84,7 @@ class Test extends require('./axios') { throw err } } + // @ts-expect-error - cds.root is readonly in types cds.root = folder return this } @@ -123,7 +130,8 @@ class Test extends require('./axios') { */ log (capture) { const {console} = global, {format} = require('util') - const log = { output: '' } + const log = { output: '', release: ()=>{}, clear: ()=>{} } + // @ts-expect-error - __proto__ hack beforeAll(()=> global.console = { __proto__: console, log: capture ??= (..._)=> log.output += format(..._)+'\n', info: capture, @@ -142,6 +150,7 @@ class Test extends require('./axios') { * Silences all console log output, e.g.: CDS_TEST_SILENT=y jest/mocha ... */ silent(){ + // @ts-expect-error - __proto__ hack global.console = { __proto__: console, log: ()=>{}, info: ()=>{}, @@ -168,6 +177,10 @@ class Test extends require('./axios') { const chaip = require('chai-as-promised') chai.use (chaip.default/*v8 on ESM*/ ?? chaip/*v7*/) return chai + + /** + * @param {string} mod + */ function require (mod) { try { return module.require(mod) } catch(e) { if (e.code === 'MODULE_NOT_FOUND') throw new Error (`Failed to load required package '${mod}'. Please add it thru:` @@ -188,8 +201,13 @@ class Test extends require('./axios') { } -/** @type Test & ()=>Test */ -module.exports = exports = Object.assign ((..._) => (new Test).run(..._), { Test }) +/** @type {import('node:module').Module} */(module).exports = exports = Object.assign ( + /** + * @param {any[]} _ + */ + (..._) => (new Test).run(..._) + , { Test } +) // Set prototype to allow usages like cds.test.in(), cds.test.log(), ... Object.setPrototypeOf (exports, Test.prototype) diff --git a/package-lock.json b/package-lock.json index f95f9cf..e616acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,8 @@ "devDependencies": { "@cap-js/cds-types": "^0.14.0", "@cap-js/sqlite": "^1.5.0 || ^2", + "@types/chai": "^5.2.2", + "@types/chai-as-promised": "^8.0.2", "@types/node": "^24.3.1", "express": "^4.17.1" }, @@ -154,6 +156,24 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/chai-as-promised": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-8.0.2.tgz", + "integrity": "sha512-meQ1wDr1K5KRCSvG2lX7n7/5wf70BeptTKst0axGvnN6zqaVpRqegoIbugiAPSqOW9K9aL8gDVrm7a2LXOtn2Q==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -164,6 +184,12 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", diff --git a/package.json b/package.json index 4060c18..b01cf94 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "devDependencies": { "@cap-js/cds-types": "^0.14.0", "@cap-js/sqlite": "^1.5.0 || ^2", + "@types/chai": "^5.2.2", + "@types/chai-as-promised": "^8.0.2", "@types/node": "^24.3.1", "express": "^4.17.1" } From 96250b02b90eb1dcc1fb4cb967b652808c01e1ec Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 17 Sep 2025 18:59:05 +0200 Subject: [PATCH 17/22] Types --- lib/expect.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/expect.js b/lib/expect.js index 807bc55..1d7ff05 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -14,13 +14,13 @@ const format = x => inspect( /** * @type {{ - * (actual?: any): unknown, + * (actual?: any): any, * any: Function, * stringMatching: (x: string | RegExp) => (a: string) => boolean, * stringContaining: (x: string) => (a: string) => boolean, * arrayContaining: (x: any[]) => (a: any[]) => boolean, * objectContaining: (x: object) => (a: object) => boolean, - * fail: (actual: any, expected?: any, message?: string) => never, + * fail: (actual: any, expected?: any, message?: string) => void, * }} */ const expect = module.exports = actual => { @@ -219,8 +219,17 @@ class Core { */ property (p, v) { const has = !this._own ? (/** @type {object}*/ a, /** @type {string}*/ p) => a && typeof a === 'object' && p in a : Reflect.getOwnPropertyDescriptor + /** + * @param {Record} a + * @param {string} p + */ const get = (a, p) => has(a, p) ? a[p] : $not_found, $not_found = {} - const y = this.assert(() => true) && !this._nested ? get(this._, p) : (p.split?.('.') ?? p).reduce((a, p) => get(a, p), this._) + // FIXME: improve name. + const y = this.assert(() => true) && !this._nested + // @ts-expect-error - !this._nested => p is string + ? get(this._, p) + : (Array.isArray(p) ? p : p.split?.('.')) + .reduce((a, p) => get(a, p), this._) if (y === $not_found) return this._not || (this._nested ? this.should`have nested property ${p}` : this.should`have property ${p}`) From 64f7214c65b6c33e7a789881e84771068d5648b1 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 17 Sep 2025 19:10:41 +0200 Subject: [PATCH 18/22] Types --- lib/expect.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/expect.js b/lib/expect.js index 1d7ff05..1d0382e 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -24,6 +24,10 @@ const format = x => inspect( * }} */ const expect = module.exports = actual => { + /** + * @this {Function} + * @param {any} x + */ const chainable = function (x) { return this.call(x) } @@ -193,7 +197,7 @@ class Core { }, _fail) } - /** @param {Function & string} x */ + /** @param {Function & string} [x] */ rejectsWith (x) { if (this._not) return Promise.resolve(this._).catch( e => expected`promise to be fulfilled but it was rejected with ${e}` @@ -254,7 +258,10 @@ class Core { gte (x) { return this.assert(a => a >= x) || this.should`be >= ${x}` } /** @param {number} x */ lte (x) { return this.assert(a => a <= x) || this.should`be <= ${x}` } - /** @param {number} x */ + /** + * @param {number} x + * @param {number} y + */ within (x, y) { return this.assert(a => x <= a && a <= y) || this.should`be within ${[x, y]}` } } @@ -279,6 +286,10 @@ class Chai extends Core { get still() { return this } get which() { return this } get eventually() { + /** + * @param {Function} fn + * @param {Function} [_fail] + */ this.assert = (fn, _fail) => Promise.resolve(this._).then(a => expect(a).assert(fn, _fail)) return this } @@ -309,8 +320,9 @@ class Chai extends Core { get contains() { return new Proxy (this.includes,{ get: (fn,k) => { if (k === 'deep') { + // FIXME: _deep seems to be treated as a boolean at every call site. Why do we assign a function here? this._deep = fn - return (...args) => fn.call(this,...args) + return (...args) => fn.call(this, ...args) } else return fn[k] }, @@ -356,6 +368,7 @@ class Jest extends Chai { get toEqual() { return this.eqls } get toMatch() { return this.matches } get toMatchObject() { return this.matches } + // FIXME: this.deep is treated as a boolean throughout this file (is a function at one point). Why do we assume arrayesque functionality here? get toContainEqual() { return this.deep.includes } get toContain() { return this.includes } get toThrow() { return this.throws } @@ -393,11 +406,12 @@ class Jest extends Chai { ) } /** - * @param {...any} args + * @param {...any[]} args */ toHaveBeenCalledWith (...args) { return this.assert ( - fn => fn.mock.calls.some(c => compare(c.arguments,args,true)), + fn => fn.mock.calls.some((/** @type {{arguments: any}}*/c) => + compare(c.arguments, args, true)), () => this.should`have been called with ${args}` ) } @@ -447,7 +461,7 @@ class AssertionError extends Error { expect.fail = function (actual, expected, message) { if (arguments.length === 1) throw new AssertionError (actual, expect.fail) - if (arguments.length === 3) throw Object.assign (new AssertionError (message, expect.fail), { expected, actual }) + if (arguments.length === 3) throw Object.assign (new AssertionError (message ?? '', expect.fail), { expected, actual }) } /** From c97702664e294000b398975d1a95e4964701f33c Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Fri, 19 Sep 2025 09:22:41 +0200 Subject: [PATCH 19/22] Types --- global.d.ts | 2 ++ lib/expect.js | 4 +++- lib/naxios.js | 33 +++++++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/global.d.ts b/global.d.ts index d52bbf7..5fae59e 100644 --- a/global.d.ts +++ b/global.d.ts @@ -2,6 +2,8 @@ import type { HookFn, mock } from 'node:test' import type each_type from './lib/fixtures/test-each.js' declare global { + var _cds_test_fixture: any; + // when extending global, only var can be used var describe: { (message: string, method: Function): void, diff --git a/lib/expect.js b/lib/expect.js index 1d0382e..65968ae 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -318,12 +318,14 @@ class Chai extends Core { get containSubset() { return this.subset } get contains() { return new Proxy (this.includes,{ + get: (fn,k) => { if (k === 'deep') { // FIXME: _deep seems to be treated as a boolean at every call site. Why do we assign a function here? this._deep = fn - return (...args) => fn.call(this, ...args) + return (/** @type{any[]} */...args) => fn.call(this, ...args) } + // @ts-expect-error - apparently, fn is either a function, or, I guess, an object else return fn[k] }, apply: (fn,t,args) => fn.call (this,...args) diff --git a/lib/naxios.js b/lib/naxios.js index 4c2ba2b..5d901b3 100644 --- a/lib/naxios.js +++ b/lib/naxios.js @@ -15,6 +15,7 @@ class Naxios { /** * Mimics the axios.request() method, translating it to fetch() API + * @param {Parameters} config */ async request (config) { @@ -29,7 +30,7 @@ class Naxios { response.headers[k.toLowerCase()] = v // Axios throws errors for 4xx and 5xx responses - let ok = o.validateStatus ??= status => status >= 200 && status < 300 // default + let ok = o.validateStatus ??= (/** @type {number}*/status) => status >= 200 && status < 300 // default if (!ok(response.status)) throw Object.assign (new Error, { response }, response.data.error || { code: response.status, message: response.statusText, @@ -41,6 +42,16 @@ class Naxios { /** * Turn axios configs into fetch() options + * @param {object} parameters + * @param {string} parameters.url + * @param {ConstructorParameters} parameters.params + * @param {'arraybuffer' | 'document' | 'json' | 'text' | 'stream'} [parameters.responseType] + * @param {object | string | Readable} [parameters.data] + * @param {object} parameters.headers + * @param {(status: number) => boolean} [parameters.validateStatus] + * @param {function(string): any} [parameters.transformResponse] + * @param {any} [parameters.rest] + * @returns {RequestInit & { url: string }} */ options4 ({ url, params, data, headers, ...rest }) { const o = { ...this.defaults, ...rest, headers: new Headers (this.defaults.headers) } @@ -58,6 +69,11 @@ class Naxios { /** * Turn fetch() response into axios response + * @param {Response} res + * @param {{ + * transformResponse: (value: string) => string | PromiseLike, + * responseType: 'arraybuffer' | 'document' | 'json' | 'text' | 'stream' | string + * }} o */ data4 (res,o) { if (o.transformResponse) return res.text().then(o.transformResponse) @@ -68,7 +84,7 @@ class Naxios { case 'document': return res.text() case 'arraybuffer': return res.arrayBuffer() } - let ct = res.headers.get('content-type') + let ct = res.headers.get('content-type') ?? '' if (/stream|image|pdf|tar/.test(ct)) return res.body if (/xml/.test(ct)) return res.text() else return res.text().then(x => { @@ -82,10 +98,15 @@ class Naxios { * The standard default axios instance * @type {Naxios} */ -const axios = exports = module.exports = Object.setPrototypeOf (function (url, config) { - if (new.target) return new Naxios (url) - else config = typeof url === 'object' ? url : { url, ...config } - return axios.request (config) +const axios = exports = module.exports = Object.setPrototypeOf ( + /** + * @param {string | object} url + * @param {string | object} [config] + */ + function (url, config) { + if (new.target) return new Naxios (url) + else config = typeof url === 'object' ? url : { url, ...config } + return axios.request (config) }, Naxios.prototype) From 365fd8ec319cf76b5d756b0f5342ae8344f33f09 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Mon, 22 Sep 2025 09:04:43 +0200 Subject: [PATCH 20/22] Types --- lib/naxios.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/naxios.js b/lib/naxios.js index 5d901b3..7d8bede 100644 --- a/lib/naxios.js +++ b/lib/naxios.js @@ -1,10 +1,24 @@ const {Readable} = require('stream') class Naxios { + /** + * @type {{headers?: object, duplex?: string}} + */ + defaults = {} + /** + * @param {object} defaults + */ constructor (defaults) { this.defaults = { ...axios.defaults, ...defaults } } + /** + * @param {object} defaults + */ create (defaults) { return new Naxios (defaults) } + /** + * @param {string | object} url + * @param {object} [config] + */ options (url, config) { return this.request ({ method:'OPTIONS', url, ...config }) } head (url, config) { return this.request ({ method:'HEAD', url, ...config }) } get (url, config) { return this.request ({ method:'GET', url, ...config }) } @@ -20,10 +34,10 @@ class Naxios { async request (config) { const o = this.options4 (config) - const response = await fetch (o.url,o) + const response = await fetch (o.url, o) // Axios eagerly reads the response body - response.data = await this.data4 (response,o) + response.data = await this.data4 (response, o) // Axios headers can be accessed as object properties for (let [k,v] of response.headers.entries()) From f4855ec9892f5555e74c42a6df19162a2458841f Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Mon, 22 Sep 2025 10:35:17 +0200 Subject: [PATCH 21/22] Types --- lib/naxios.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/naxios.js b/lib/naxios.js index 7d8bede..0952dca 100644 --- a/lib/naxios.js +++ b/lib/naxios.js @@ -2,7 +2,15 @@ const {Readable} = require('stream') class Naxios { /** - * @type {{headers?: object, duplex?: string}} + * @type {{ + * headers?: object, + * duplex?: string, + * auth?: {username: string, password: string}, + * body?: string | Readable, + * url?: string, + * baseURL?: string, + * validateStatus?: (status: number) => boolean, + * }} */ defaults = {} @@ -15,9 +23,10 @@ class Naxios { */ create (defaults) { return new Naxios (defaults) } + /** - * @param {string | object} url - * @param {object} [config] + * @param {string} url + * @param {Parameters[number]} [config] */ options (url, config) { return this.request ({ method:'OPTIONS', url, ...config }) } head (url, config) { return this.request ({ method:'HEAD', url, ...config }) } @@ -29,7 +38,7 @@ class Naxios { /** * Mimics the axios.request() method, translating it to fetch() API - * @param {Parameters} config + * @param {Parameters[number] & { method: string, url: string }} config */ async request (config) { @@ -41,6 +50,7 @@ class Naxios { // Axios headers can be accessed as object properties for (let [k,v] of response.headers.entries()) + // @ts-expect-error - index access is always legal response.headers[k.toLowerCase()] = v // Axios throws errors for 4xx and 5xx responses @@ -62,10 +72,8 @@ class Naxios { * @param {'arraybuffer' | 'document' | 'json' | 'text' | 'stream'} [parameters.responseType] * @param {object | string | Readable} [parameters.data] * @param {object} parameters.headers - * @param {(status: number) => boolean} [parameters.validateStatus] * @param {function(string): any} [parameters.transformResponse] * @param {any} [parameters.rest] - * @returns {RequestInit & { url: string }} */ options4 ({ url, params, data, headers, ...rest }) { const o = { ...this.defaults, ...rest, headers: new Headers (this.defaults.headers) } @@ -89,7 +97,7 @@ class Naxios { * responseType: 'arraybuffer' | 'document' | 'json' | 'text' | 'stream' | string * }} o */ - data4 (res,o) { + data4 (res, o) { if (o.transformResponse) return res.text().then(o.transformResponse) else switch (o.responseType) { case 'stream': return res.body @@ -98,7 +106,7 @@ class Naxios { case 'document': return res.text() case 'arraybuffer': return res.arrayBuffer() } - let ct = res.headers.get('content-type') ?? '' + const ct = res.headers.get('content-type') ?? '' if (/stream|image|pdf|tar/.test(ct)) return res.body if (/xml/.test(ct)) return res.text() else return res.text().then(x => { @@ -110,14 +118,14 @@ class Naxios { /** * The standard default axios instance - * @type {Naxios} */ const axios = exports = module.exports = Object.setPrototypeOf ( /** * @param {string | object} url - * @param {string | object} [config] + * @param {object} [config] */ function (url, config) { + // @ts-expect-error - FIXME: passing a string url to the constructor will deconstruct in an unexpected way! if (new.target) return new Naxios (url) else config = typeof url === 'object' ? url : { url, ...config } return axios.request (config) From 3553c937e4587c700f72093866419f035b28e87a Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Mon, 22 Sep 2025 10:42:17 +0200 Subject: [PATCH 22/22] Types --- lib/naxios.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/naxios.js b/lib/naxios.js index 0952dca..a78ac1d 100644 --- a/lib/naxios.js +++ b/lib/naxios.js @@ -29,11 +29,38 @@ class Naxios { * @param {Parameters[number]} [config] */ options (url, config) { return this.request ({ method:'OPTIONS', url, ...config }) } + /** + * @param {string} url + * @param {Parameters[number]} [config] + */ head (url, config) { return this.request ({ method:'HEAD', url, ...config }) } + /** + * @param {string} url + * @param {Parameters[number]} [config] + */ get (url, config) { return this.request ({ method:'GET', url, ...config }) } + /** + * @param {string} url + * @param {string | object | Readable} data + * @param {Parameters[number]} [config] + */ put (url, data, config) { return this.request ({ method:'PUT', url, ...config, data }) } + /** + * @param {string} url + * @param {string | object | Readable} data + * @param {Parameters[number]} [config] + */ post (url, data, config) { return this.request ({ method:'POST', url, ...config, data }) } + /** + * @param {string} url + * @param {string | object | Readable} data + * @param {Parameters[number]} [config] + */ patch (url, data, config) { return this.request ({ method:'PATCH', url, ...config, data }) } + /** + * @param {string} url + * @param {Parameters[number]} [config] + */ delete (url, config) { return this.request ({ method:'DELETE', url, ...config }) } /** @@ -43,6 +70,7 @@ class Naxios { async request (config) { const o = this.options4 (config) + /** @type {Response & { data?: any }} */ const response = await fetch (o.url, o) // Axios eagerly reads the response body @@ -68,7 +96,7 @@ class Naxios { * Turn axios configs into fetch() options * @param {object} parameters * @param {string} parameters.url - * @param {ConstructorParameters} parameters.params + * @param {ConstructorParameters[number]} parameters.params * @param {'arraybuffer' | 'document' | 'json' | 'text' | 'stream'} [parameters.responseType] * @param {object | string | Readable} [parameters.data] * @param {object} parameters.headers @@ -76,7 +104,7 @@ class Naxios { * @param {any} [parameters.rest] */ options4 ({ url, params, data, headers, ...rest }) { - const o = { ...this.defaults, ...rest, headers: new Headers (this.defaults.headers) } + const o = { ...this.defaults, ...rest, headers: new Headers (this.defaults.headers), url } if (headers) for (let [k,v] of Object.entries(headers)) o.headers.set(k,v) if (o.auth) o.headers.set('Authorization', 'Basic ' + btoa (o.auth.username + ':' + o.auth.password||'')) if (data) o.body =