diff --git a/bin/reporter.js b/bin/reporter.js index e4a4316..98295b4 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -134,6 +134,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/global.d.ts b/global.d.ts new file mode 100644 index 0000000..5fae59e --- /dev/null +++ b/global.d.ts @@ -0,0 +1,61 @@ +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, + each?: typeof each_type; + skip: { + (...xs:any[]): unknown + each?: () => void; + } + }; + var xdescribe: typeof describe['skip']; + var it: { + 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(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(method: HookFn): void; + function afterAll(method: Function): void; + function afterAll(message: string & Function?, method: Function): void; + function expect(_:any): 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 {}; diff --git a/lib/axios.js b/lib/axios.js index 3ac2048..d20e950 100644 --- a/lib/axios.js +++ b/lib/axios.js @@ -1,29 +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["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 typeof self.options */ get OPTIONS() { return this.options .bind (this) } @@ -39,17 +59,34 @@ class AxiosProvider { const self = AxiosProvider.prototype // eslint-disable-line no-unused-vars +/** + * @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}`) } +/** + * @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/lib/cds-test.js b/lib/cds-test.js index 582fed2..6e64da3 100644 --- a/lib/cds-test.js +++ b/lib/cds-test.js @@ -7,14 +7,21 @@ 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 * 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) { @@ -29,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 @@ -36,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(), ])) @@ -47,6 +56,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,36 +74,47 @@ 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 } } + // @ts-expect-error - cds.root is readonly in types cds.root = folder return this } /** * 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 +126,20 @@ 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: '' } + const log = { output: '', release: ()=>{}, clear: ()=>{} } + // @ts-expect-error - __proto__ hack 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 = '' }) @@ -126,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: ()=>{}, @@ -152,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:` @@ -172,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/lib/data.js b/lib/data.js index d7158dc..93e2b2d 100644 --- a/lib/data.js +++ b/lib/data.js @@ -1,10 +1,16 @@ 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 + /** + * @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 @@ -15,11 +21,18 @@ class DataUtil { global.beforeEach (() => this.reset()) } + /** + * @param {cds.DatabaseService} db - db + */ async deploy(db) { if (!db) db = await cds.connect.to('db') + // @ts-expect-error - dk type await cds.deploy.data(db) } + /** + * @param {cds.DatabaseService} db - db + */ async delete(db) { if (!db) db = await cds.connect.to('db') if (!this._deletes) { @@ -38,7 +51,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 ea2d320..65968ae 100644 --- a/lib/expect.js +++ b/lib/expect.js @@ -1,4 +1,8 @@ const { inspect } = require('node:util') + +/** + * @param {{message: string, status: unknown, data: unknown, 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 } @@ -7,23 +11,57 @@ const format = x => inspect( { colors: true, sorted: true, depth: 11 } ) + +/** + * @type {{ + * (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) => void, + * }} + */ const expect = module.exports = actual => { - const chainable = function (x) { return this.call(x) }; delete chainable.length + /** + * @this {Function} + * @param {any} x + */ + const chainable = function (x) { + return this.call(x) + } return Object.setPrototypeOf(chainable, new Assertion(actual)) } +/** + * @template T + * @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 + /** @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 @@ -38,18 +76,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 {any[] | TemplateStringsArray} args + */ expected ([a, be, ...etc], ...args) { const raw = [a, (this._not ? ' NOT' : '') + be, ...etc] const err = new expected({ raw }, ...args) @@ -59,36 +109,46 @@ class Core { throw err } + /** @param {any[] | 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 } + /** @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))) @@ -97,6 +157,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) @@ -106,6 +167,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}` @@ -113,24 +175,29 @@ 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 & { 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) }, _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}` @@ -144,14 +211,29 @@ class Core { ) } + // 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}`) } + /** + * @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 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}`) @@ -162,15 +244,24 @@ class Core { return that } + /** @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 + * @param {number} y + */ within (x, y) { return this.assert(a => x <= a && a <= y) || this.should`be within ${[x, y]}` } } @@ -195,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 } @@ -216,17 +311,21 @@ 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` } + // @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 } 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) @@ -237,6 +336,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 } @@ -270,6 +370,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 } @@ -284,6 +385,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 } @@ -296,18 +398,28 @@ 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)), + fn => fn.mock.calls.some((/** @type {{arguments: any}}*/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), @@ -332,6 +444,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' } @@ -347,16 +463,24 @@ 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 }) } +/** + * @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(` @@ -365,6 +489,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) diff --git a/lib/fixtures/jest.js b/lib/fixtures/jest.js index fb5e9a6..a07b9a4 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 {Function} message + * @param {Function} [fn] + */ +global.before = (message, fn = message) => global.beforeAll(fn) +/** + * @param {Function} message + * @param {Function} [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..066e054 100644 --- a/lib/fixtures/node-test.js +++ b/lib/fixtures/node-test.js @@ -1,6 +1,10 @@ const { describe, test, before, after, beforeEach, afterEach, mock } = require('node:test') -const _fn = fn => !fn.length ? fn : (_,done) => fn (done) +/** @param {{length: number} & Function} fn */ +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 @@ -24,6 +28,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 +37,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..62e5e3b 100644 --- a/lib/fixtures/test-each.js +++ b/lib/fixtures/test-each.js @@ -1,8 +1,17 @@ // 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 + // @ts-ignore - FIXME: this should be each(...) or this.exports(...)!! return this (format(msg, label), ()=> fn(...args)) })) } diff --git a/lib/naxios.js b/lib/naxios.js index 4c2ba2b..a78ac1d 100644 --- a/lib/naxios.js +++ b/lib/naxios.js @@ -1,35 +1,88 @@ const {Readable} = require('stream') class Naxios { + /** + * @type {{ + * headers?: object, + * duplex?: string, + * auth?: {username: string, password: string}, + * body?: string | Readable, + * url?: string, + * baseURL?: string, + * validateStatus?: (status: number) => boolean, + * }} + */ + defaults = {} + /** + * @param {object} defaults + */ constructor (defaults) { this.defaults = { ...axios.defaults, ...defaults } } + /** + * @param {object} defaults + */ create (defaults) { return new Naxios (defaults) } + + /** + * @param {string} url + * @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 }) } /** * Mimics the axios.request() method, translating it to fetch() API + * @param {Parameters[number] & { method: string, url: string }} config */ async request (config) { const o = this.options4 (config) - const response = await fetch (o.url,o) + /** @type {Response & { data?: any }} */ + 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()) + // @ts-expect-error - index access is always legal 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,9 +94,17 @@ class Naxios { /** * Turn axios configs into fetch() options + * @param {object} parameters + * @param {string} parameters.url + * @param {ConstructorParameters[number]} parameters.params + * @param {'arraybuffer' | 'document' | 'json' | 'text' | 'stream'} [parameters.responseType] + * @param {object | string | Readable} [parameters.data] + * @param {object} parameters.headers + * @param {function(string): any} [parameters.transformResponse] + * @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 = @@ -58,8 +119,13 @@ 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) { + data4 (res, o) { if (o.transformResponse) return res.text().then(o.transformResponse) else switch (o.responseType) { case 'stream': return res.body @@ -68,7 +134,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 => { @@ -80,12 +146,17 @@ 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 {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) }, Naxios.prototype) diff --git a/package-lock.json b/package-lock.json index 5fb03b0..e616acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,11 @@ "chest": "bin/chest.js" }, "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" }, "engines": { @@ -29,6 +33,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 +145,136 @@ "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/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", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "peer": true, + "dependencies": { + "@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", + "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", @@ -1605,6 +1756,12 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "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", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index f92a6ba..b01cf94 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,11 @@ "@sap/cds": ">=8.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" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2e822a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "strict": true, + "noImplicitAny": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "strictBindCallApply": true, + "skipLibCheck": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "alwaysStrict": true, + "esModuleInterop": true, + "checkJs": true, + "allowJs": true, + "declaration": true, + "target": "ES2016", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "dist" + }, + "include": [ + "./global.d.ts", + "./lib/**/*.js" + ], + "paths": { + "@sap/cds": [ + "node_modules/@cap-js/cds-types", + ] + }, + "verbose": true +}