Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bin/reporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { mock } from 'node:test'

declare global {
// when extending global, only var can be used
var describe: {
each: () => void;
skip: {
each: () => void;
}
} | (() => void);
var xdescribe: typeof describe['skip'];
var it: {
each: () => void,
skip: () => void
}
var test: typeof it;
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 {};
49 changes: 43 additions & 6 deletions lib/axios.js
Original file line number Diff line number Diff line change
@@ -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) }
Expand All @@ -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
Expand Down
40 changes: 28 additions & 12 deletions lib/cds-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ 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'))}

/**
* 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) {

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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) {
Expand All @@ -104,18 +119,19 @@ class Test extends require('./axios') {

/**
* Captures console.log output.
* @param {(message?: any, ...optionalParams: any[]) => void} capture
*/
log (_capture) {
log (capture) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed, as underscore is generally used for "variables I don't use", "don't care", or "throwaway" (both, in Python, as well as in the ESLint rules no-unused-vars).
If there's a good reason for keeping the underscore, do let me know.

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 = '' })
Expand Down
20 changes: 18 additions & 2 deletions lib/data.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
const cds = require('@sap/cds')

class DataUtil {
/** @type {ReturnType<typeof DELETE.from>[] | 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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion lib/expect.js
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -8,22 +12,39 @@ const format = x => inspect(
)

const expect = module.exports = actual => {
const chainable = function (x) { return this.call(x) }; delete chainable.length
Copy link
Author

@daogrady daogrady Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrite

This caused

The operand of a 'delete' operator cannot be a read-only property.ts(2704)

And it does not seem to have any effect anyway:

Screenshot 2025-03-27 at 14 09 46

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 {
Array = Array.isArray
/** @type {Is<Error>} */
Error = x => x instanceof Error || x?.stack && x.message
/** @type {Is<Symbol>} */
Symbol = x => typeof x === 'symbol'
/** @type {Is<Object>} */
Object = x => typeof x === 'object' // && x && !is.array(x)
/** @type {Is<String>} */
String = x => typeof x === 'string' || x instanceof String
/** @type {Is<Number>} */
Number = x => typeof x === 'number' || x instanceof Number
/** @type {Is<Boolean>} */
Boolean = x => typeof x === 'boolean' || x instanceof Boolean
/** @type {Is<Promise<unknown>>} */
Promise = x => x instanceof Promise
/** @type {Is<RegExp>} */
RegExp = x => x instanceof RegExp
/** @type {Is<Date>} */
Date = x => x instanceof Date
/** @type {Is<Set<unknown>>} */
Set = x => x instanceof Set
/** @type {Is<Map<unknown, unknown>>} */
Map = x => x instanceof Map
array = this.Array
error = this.Error
Expand Down
12 changes: 10 additions & 2 deletions lib/fixtures/jest.js
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions lib/fixtures/node-test.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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(),
Expand All @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this happened implicitly before and TS didn't like it that way.

},
setTimeout(){}
}
Loading
Loading