UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

1,242 lines (1,109 loc) 29 kB
// @ts-check /** * @module test * * Provides a test runner for Socket Runtime. * * Example usage: * ```js * import { test } from 'socket:test' * * test('test name', async t => { * t.equal(1, 1) * }) * ``` */ import { format } from '../util.js' import deepEqual from './fast-deep-equal.js' import process from '../process.js' import os from '../os.js' import initContext from './context.js' import { toElement, event as dispatchEventHelper, isElementVisible, waitFor, waitForText } from './dom-helpers.js' const { SOCKET_TEST_RUNNER_TIMEOUT = getDefaultTestRunnerTimeout() } = process.env const NEW_LINE_REGEX = /\n/g const OBJ_TO_STRING = Object.prototype.toString const AT_REGEX = new RegExp( // non-capturing group for 'at ' '^(?:[^\\s]*\\s*\\bat\\s+)' + // captures function call description '(?:(.*)\\s+\\()?' + // captures file path plus line no '((?:\\/|[a-zA-Z]:\\\\)[^:\\)]+:(\\d+)(?::(\\d+))?)\\)$' ) /** * @type {string} * @ignore */ let CACHED_FILE /** * @returns {number} - The default timeout for tests in milliseconds. */ export function getDefaultTestRunnerTimeout () { if (os.platform() === 'win32') { return 2 * 1024 } else { return 500 } } /** * @typedef {(t: Test) => (void | Promise<void>)} TestFn */ /** * @class */ export class Test { /** * @type {string} * @ignore */ name = null /** * @type {null|number} * @ignore */ _planned = null /** * @type {null|number} * @ignore */ _actual = null /** * @type {TestFn} * @ignore */ fn = null /** * @type {TestRunner} * @ignore */ runner = null /** * @type{{ pass: number, fail: number }} * @ignore */ _result = { pass: 0, fail: 0 } /** * @type {boolean} * @ignore */ done = false /** * @type {boolean} * @ignore */ strict = false /** * @constructor * @param {string} name * @param {TestFn} fn * @param {TestRunner} runner */ constructor (name, fn, runner) { this.name = name this.fn = fn this.runner = runner this.strict = runner.strict } /** * @param {string} msg * @returns {void} */ comment (msg) { this.runner.report('# ' + msg) } /** * Plan the number of assertions. * * @param {number} n * @returns {void} */ plan (n) { this._planned = n } /** * @template T * @param {T} actual * @param {T} expected * @param {string} [msg] * @returns {void} */ deepEqual (actual, expected, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( deepEqual(actual, expected), actual, expected, msg || 'should be equivalent', 'deepEqual' ) } /** * @template T * @param {T} actual * @param {T} expected * @param {string} [msg] * @returns {void} */ notDeepEqual (actual, expected, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( !deepEqual(actual, expected), actual, expected, msg || 'should not be equivalent', 'notDeepEqual' ) } /** * @template T * @param {T} actual * @param {T} expected * @param {string} [msg] * @returns {void} */ equal (actual, expected, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( // eslint-disable-next-line eqeqeq actual == expected, actual, expected, msg || 'should be equal', 'equal' ) } /** * @param {unknown} actual * @param {unknown} expected * @param {string} [msg] * @returns {void} */ notEqual (actual, expected, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( // eslint-disable-next-line eqeqeq actual != expected, actual, expected, msg || 'should not be equal', 'notEqual' ) } /** * @param {string} [msg] * @returns {void} */ fail (msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( false, 'fail called', 'fail not called', msg || 'fail called', 'fail' ) } /** * @param {unknown} actual * @param {string} [msg] * @returns {void} */ ok (actual, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( !!actual, actual, 'truthy value', msg || 'should be truthy', 'ok' ) } /** * @param {string} [msg] * @returns {void} */ pass (msg) { return this.ok(true, msg) } /** * @param {Error | null | undefined} err * @param {string} [msg] * @returns {void} */ ifError (err, msg) { if (this.strict && !msg) throw new Error('tapzero msg required') this._assert( !err, err, 'no error', msg || String(err), 'ifError' ) } /** * @param {Function} fn * @param {RegExp | any} [expected] * @param {string} [message] * @returns {void} */ throws (fn, expected, message) { if (typeof expected === 'string') { message = expected expected = undefined } if (this.strict && !message) throw new Error('tapzero msg required') /** * @type {Error | null} * @ignore */ let caught = null try { fn() } catch (err) { caught = /** @type {Error} */ (err) } let pass = !!caught if (expected instanceof RegExp) { pass = !!(caught && expected.test(caught.message)) } else if (expected) { throw new Error(`t.throws() not implemented for expected: ${typeof expected}`) } /** * @ignore */ this._assert( pass, caught, expected, message || 'show throw', 'throws' ) } // DOM Assertions /** * Sleep for ms with an optional msg * * @param {number} ms * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.sleep(100) * ``` */ async sleep (ms, msg) { msg = msg || `Sleep for ${ms}ms` await (new Promise((resolve) => setTimeout(resolve, ms))) this.pass(msg) } /** * Request animation frame with an optional msg. Falls back to a 0ms setTimeout when * tests are run headlessly. * * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.requestAnimationFrame() * ``` */ async requestAnimationFrame (msg = null) { if (globalThis.document && globalThis.document.hasFocus()) { // RAF only works when the window is focused await new Promise(resolve => globalThis.requestAnimationFrame(resolve)) } else { await new Promise((resolve) => setTimeout(resolve, 0)) } if (msg) this.pass(msg) } /** * Dispatch the `click`` method on an element specified by selector. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.click('.class button', 'Click a button') * ``` */ async click (selector, msg) { msg = msg || `Clicked on ${typeof selector === 'string' ? selector : 'element'}` const el = toElement(selector) if (globalThis.HTMLElement && !(el instanceof globalThis.HTMLElement)) { throw new Error('selector needs to be instance of HTMLElement or resolve to one') } // @ts-ignore el.click() await this.requestAnimationFrame() this.pass(msg) } /** * Dispatch the click window.MouseEvent on an element specified by selector. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.eventClick('.class button', 'Click a button with an event') * ``` */ async eventClick (selector, msg) { const element = toElement(selector) msg = msg || `Fired click event on ${typeof selector === 'string' ? selector : 'element'}` dispatchEventHelper({ event: new globalThis.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }), element }) await this.requestAnimationFrame() this.pass(msg) } /** * Dispatch an event on the target. * * @param {string | Event} event - The event name or Event instance to dispatch. * @param {string|HTMLElement|Element} target - A CSS selector string, or an instance of HTMLElement, or Element to dispatch the event on. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.dispatchEvent('my-event', '#my-div', 'Fire the my-event event') * ``` */ async dispatchEvent (event, target, msg) { const element = toElement(target) msg = msg || `Fired event ${typeof event === 'string' ? ` ${event}` : ''} on ${typeof target === 'string' ? target : 'element'}` dispatchEventHelper({ event, element }) await this.requestAnimationFrame() this.pass(msg) } /** * Call the focus method on element specified by selector. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.focus('#my-div') * ``` */ async focus (selector, msg) { msg = msg || `Focused on ${typeof selector === 'string' ? selector : 'element'}` const el = toElement(selector) if (globalThis.HTMLElement && !(el instanceof globalThis.HTMLElement)) { throw new Error('selector needs to be instance of HTMLElement or resolve to one') } // @ts-ignore el.focus() await this.requestAnimationFrame() this.pass(msg) } /** * Call the blur method on element specified by selector. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.blur('#my-div') * ``` */ async blur (selector, msg) { msg = msg || `Blurred from ${typeof selector === 'string' ? selector : 'element'}` const el = toElement(selector) if (globalThis.HTMLElement && !(el instanceof globalThis.HTMLElement)) { throw new Error('selector needs to be instance of HTMLElement or resolve to one') } // @ts-ignore el.blur() await this.requestAnimationFrame() this.pass(msg) } /** * Consecutively set the str value of the element specified by selector to simulate typing. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {string} str - The string to type into the :focus element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.typeValue('#my-div', 'Hello World', 'Type "Hello World" into #my-div') * ``` */ async type (selector, str, msg) { msg = msg || `Typed by value ${str}${typeof selector === 'string' ? ` to ${selector}` : ''}` const el = toElement(selector) if (!('value' in el)) throw new Error('Element missing value attribute') for (const c of str.split('')) { await this.requestAnimationFrame() el.value = el.value != null ? el.value + c : c el.dispatchEvent( new Event('input', { bubbles: true, cancelable: true }) ) } await this.requestAnimationFrame() this.pass(msg) } /** * appendChild an element el to a parent selector element. * * @param {string|HTMLElement|Element} parentSelector - A CSS selector string, or an instance of HTMLElement, or Element to appendChild on. * @param {HTMLElement|Element} el - A element to append to the parent element. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * const myElement = createElement('div') * await t.appendChild('#parent-selector', myElement, 'Append myElement into #parent-selector') * ``` */ async appendChild (parentSelector, el, msg = 'Appended child element') { const parentEl = toElement(parentSelector) const childEl = el parentEl.appendChild(childEl) await this.requestAnimationFrame() this.pass(msg) } /** * Remove an element from the DOM. * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element to remove from the DOM. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.removeElement('#dom-selector', 'Remove #dom-selector') * ``` */ async removeElement (selector, msg = 'Removed element') { const el = toElement(selector) el.remove() await this.requestAnimationFrame() this.pass(msg) } /** * Test if an element is visible * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element to test visibility on. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.elementVisible('#dom-selector','Element is visible') * ``` */ async elementVisible (selector, msg) { msg = msg || `Element ${typeof selector === 'string' ? ` to ${selector}` : ''} is visible` const el = toElement(selector) const previousEl = el.previousElementSibling const visible = isElementVisible(el, previousEl) await this.requestAnimationFrame() this.ok(visible, msg) } /** * Test if an element is invisible * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element to test visibility on. * @param {string} [msg] * @returns {Promise<void>} * * @example * ```js * await t.elementInvisible('#dom-selector','Element is invisible') * ``` */ async elementInvisible (selector, msg) { msg = msg || `Element ${typeof selector === 'string' ? ` to ${selector}` : ''} is not visible` const el = toElement(selector) const previousEl = el.previousElementSibling const visible = isElementVisible(el, previousEl) await this.requestAnimationFrame() this.ok(!visible, msg) } /** * Test if an element is invisible * * @param {string|(() => HTMLElement|Element|null|undefined)} querySelectorOrFn - A query string or a function that returns an element. * @param {Object} [opts] * @param {boolean} [opts.visible] - The element needs to be visible. * @param {number} [opts.timeout] - The maximum amount of time to wait. * @param {string} [msg] * @returns {Promise<HTMLElement|Element|void>} * * @example * ```js * await t.waitFor('#dom-selector', { visible: true },'#dom-selector is on the page and visible') * ``` */ waitFor (querySelectorOrFn, opts, msg) { if (typeof opts === 'string') { msg = opts opts = {} } if (!opts) opts = {} let selector let selectorFn if (typeof querySelectorOrFn === 'string') { selector = querySelectorOrFn } if (typeof querySelectorOrFn === 'function') { selectorFn = querySelectorOrFn } if (!selector && !selectorFn) throw new Error('A query selector string or selector function is required') msg = msg || `Waiting for element ${typeof selector === 'string' ? ` ${selector}` : ''}` return waitFor({ ...opts, selector }, selectorFn) .then((el) => { this.pass(msg); return el }) .catch(err => { this.ifError(err, msg) }) } /** * @typedef {Object} WaitForTextOpts * @property {string} [text] - The text to wait for * @property {number} [timeout] * @property {Boolean} [multipleTags] * @property {RegExp} [regex] The regex to wait for */ /** * Test if an element is invisible * * @param {string|HTMLElement|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element. * @param {WaitForTextOpts | string | RegExp} [opts] * @param {string} [msg] * @returns {Promise<HTMLElement|Element|void>} * * @example * ```js * await t.waitForText('#dom-selector', 'Text to wait for') * ``` * * @example * ```js * await t.waitForText('#dom-selector', /hello/i) * ``` * * @example * ```js * await t.waitForText('#dom-selector', { * text: 'Text to wait for', * multipleTags: true * }) * ``` */ waitForText (selector, opts, msg) { const element = toElement(selector) if (typeof opts === 'string') { opts = { text: opts } } if (opts instanceof RegExp) { opts = { regex: opts } } if (!opts) throw new Error('Missing text, regex or search options object') msg = msg || `Waiting for text ${opts?.text ? ` ${opts.text}` : ''}${opts?.regex ? ` ${opts.regex}` : ''}` return waitForText({ ...opts, element }) .then((el) => { this.pass(msg); return el }) .catch(err => { this.ifError(err, msg) }) } /** * Run a querySelector as an assert and also get the results * * @param {string} selector - A CSS selector string, or an instance of HTMLElement, or Element to select. * @param {string} [msg] * @returns {HTMLElement | Element} * * @example * ```js * const element = await t.querySelector('#dom-selector') * ``` */ querySelector (selector, msg) { const el = globalThis.document?.querySelector?.(selector) ?? null msg = msg || `querySelector(${selector})` this.ok(el, msg) return el } /** * Run a querySelectorAll as an assert and also get the results * * @param {string} selector - A CSS selector string, or an instance of HTMLElement, or Element to select. * @param {string} [msg] @returns {Array<HTMLElement | Element>} * * @example * ```js * const elements = await t.querySelectorAll('#dom-selector', '') * ``` */ querySelectorAll (selector, msg) { const elems = globalThis.document?.querySelectorAll?.(selector) ?? [] const elementArray = Array.from(elems) msg = msg || `querySelectorAll(${selector})` this.ok(elementArray.length, msg) return elementArray } /** * Retrieves the computed styles for a given element. * * @param {string|Element} selector - The CSS selector or the Element object for which to get the computed styles. * @param {string} [msg] - An optional message to display when the operation is successful. Default message will be generated based on the type of selector. * @returns {CSSStyleDeclaration} - The computed styles of the element. * @throws {Error} - Throws an error if the element has no `ownerDocument` or if `ownerDocument.defaultView` is not available. * * @example * ```js * // Using CSS selector * const style = getComputedStyle('.my-element', 'Custom success message'); * ``` * * @example * ```js * // Using Element object * const el = document.querySelector('.my-element'); * const style = getComputedStyle(el); * ``` */ getComputedStyle (selector, msg) { msg = msg || `Get computed style ${typeof selector === 'string' ? ` for ${selector}` : ''}` const el = toElement(selector) const ownerDocument = el.ownerDocument if (!ownerDocument || !ownerDocument.defaultView) { throw new Error('element has no ownerDocument') } const computedStyle = ownerDocument.defaultView.getComputedStyle(el) this.pass(msg) return computedStyle } /** * @param {boolean} pass * @param {unknown} actual * @param {unknown} expected * @param {string} description * @param {string} operator * @returns {void} * @ignore */ _assert ( pass, actual, expected, description, operator ) { if (this.done) { throw new Error( 'assertion occurred after test was finished: ' + this.name ) } if (this._planned !== null) { this._actual = ((this._actual || 0) + 1) if (this._actual > this._planned) { throw new Error(`More tests than planned in TEST *${this.name}*`) } } const report = this.runner.report const prefix = pass ? 'ok' : 'not ok' const id = this.runner.nextId() report(`${prefix} ${id} ${description}`) if (pass) { this._result.pass++ return } const atErr = new Error(description) let err = atErr if (actual && OBJ_TO_STRING.call(actual) === '[object Error]') { err = /** @type {Error} */ (actual) actual = err.message } this._result.fail++ report(' ---') report(` operator: ${operator}`) let ex = toJSON(expected) || 'undefined' let ac = toJSON(actual) || 'undefined' if (Math.max(ex.length, ac.length) > 65) { ex = ex.replace(NEW_LINE_REGEX, '\n ') ac = ac.replace(NEW_LINE_REGEX, '\n ') report(` expected: |-\n ${ex}`) report(` actual: |-\n ${ac}`) } else { report(` expected: ${ex}`) report(` actual: ${ac}`) } const at = findAtLineFromError(atErr) if (at) { report(` at: ${at}`) } report(' stack: |-') const st = format(err || '').split('\n').slice(1) for (const line of st) { report(` ${line}`) } report(' ...') } /** * @returns {Promise<{ * pass: number, * fail: number * }>} */ async run () { this.runner.report('# ' + this.name) const maybeP = this.fn(this) if (maybeP && typeof maybeP.then === 'function') { await maybeP } this.done = true if (this._planned !== null) { if (this._planned > (this._actual || 0)) { throw new Error(`Test ended before the planned number planned: ${this._planned} actual: ${this._actual || 0} ` ) } } return this._result } } /** * @returns {string} * @ignore */ function getTapZeroFileName () { if (CACHED_FILE) return CACHED_FILE const e = new Error('temp') const lines = (e.stack || '').split('\n') for (const line of lines) { const m = AT_REGEX.exec(line) if (!m) { continue } let fileName = m[2] if (m[4] && fileName.endsWith(`:${m[4]}`)) { fileName = fileName.slice(0, fileName.length - m[4].length - 1) } if (m[3] && fileName.endsWith(`:${m[3]}`)) { fileName = fileName.slice(0, fileName.length - m[3].length - 1) } CACHED_FILE = fileName break } return CACHED_FILE || '' } /** * @param {Error} e * @returns {string} * @ignore */ function findAtLineFromError (e) { const lines = (e.stack || '').split('\n') const dir = getTapZeroFileName() for (const line of lines) { const m = AT_REGEX.exec(line) if (!m) { continue } if (m[2].slice(0, dir.length) === dir) { continue } return `${m[1] || '<anonymous>'} (${m[2]})` } return '' } /** * @class */ export class TestRunner { /** * @type {(lines: string) => void} * @ignore */ report = printLine /** * @type {Test[]} * @ignore */ tests = [] /** * @type {Test[]} * @ignore */ onlyTests = [] /** * @type {boolean} * @ignore */ scheduled = false /** * @type {number} * @ignore */ _id = 0 /** * @type {boolean} * @ignore */ completed = false /** * @type {boolean} * @ignore */ rethrowExceptions = true /** * @type {boolean} * @ignore */ strict = false /** * @type {function | void} * @ignore */ _onFinishCallback = undefined /** * @constructor * @param {(lines: string) => void} [report] */ constructor (report = null) { if (report) { this.report = report } } /** * @returns {string} */ nextId () { return String(++this._id) } /** * @type {number} */ get length () { return this.tests.length + this.onlyTests.length } /** * @param {string} name * @param {TestFn} fn * @param {boolean} only * @returns {void} */ add (name, fn, only) { if (this.completed) { // TODO: calling add() after run() throw new Error('Cannot add() a test case after tests completed.') } const t = new Test(name, fn, this) const arr = only ? this.onlyTests : this.tests arr.push(t) if (!this.scheduled) { this.scheduled = true setTimeout(() => { const promise = this.run() if (this.rethrowExceptions) { promise.then(null, rethrowImmediate) } }, SOCKET_TEST_RUNNER_TIMEOUT) } } /** * @returns {Promise<void>} */ async run () { const ts = this.onlyTests.length > 0 ? this.onlyTests : this.tests this.report('TAP version 13') let total = 0 let success = 0 let fail = 0 for (const test of ts) { // TODO: parallel execution const result = await test.run() total += result.fail + result.pass success += result.pass fail += result.fail } this.completed = true // timeout before reporting await new Promise((resolve) => setTimeout(resolve, 256)) this.report('') this.report(`1..${total}`) this.report(`# tests ${total}`) this.report(`# pass ${success}`) if (fail) { this.report(`# fail ${fail}`) } else { this.report('') this.report('# ok') } if (this._onFinishCallback) { this._onFinishCallback({ total, success, fail }) } else { if (fail) process.exit(1) } } /** * @param {(result: { total: number, success: number, fail: number }) => void} callback * @returns {void} */ onFinish (callback) { if (typeof callback === 'function') { this._onFinishCallback = callback } else throw new Error('onFinish() expects a function') } } /** * @param {string} line * @returns {void} * @ignore */ function printLine (line) { console.log(line) } /** * @ignore */ export const GLOBAL_TEST_RUNNER = new TestRunner() initContext(GLOBAL_TEST_RUNNER) /** * @param {string} name * @param {TestFn} [fn] * @returns {void} */ export function only (name, fn) { if (!fn) return GLOBAL_TEST_RUNNER.add(name, fn, true) } /** * @param {string} _name * @param {TestFn} [_fn] * @returns {void} */ export function skip (_name, _fn) {} /** * @param {boolean} strict * @returns {void} */ export function setStrict (strict) { GLOBAL_TEST_RUNNER.strict = strict } /** * @typedef {{ * (name: string, fn?: TestFn): void * only(name: string, fn?: TestFn): void * skip(name: string, fn?: TestFn): void * }} testWithProperties * @ignore */ /** * @type {testWithProperties} * @param {string} name * @param {TestFn} [fn] * @returns {void} */ export function test (name, fn) { if (!fn) return GLOBAL_TEST_RUNNER.add(name, fn, false) } test.only = only test.skip = skip export default test // os types test.linux = function (name, fn) { if (os.type() === 'Linux') { return test(name, fn) } } test.windows = test.win32 = function (name, fn) { if (os.platform() === 'win32') { return test(name, fn) } } test.unix = function (name, fn) { if (os.host() === 'unix') { return test(name, fn) } } test.macosx = test.macos = test.mac = function (name, fn) { if (os.host() === 'macosx') { return test(name, fn) } } test.darwin = function (name, fn) { if (os.type() === 'Darwin') { return test(name, fn) } } test.iphone = test.ios = function (name, fn) { if (os.host() === 'iphoneos') { return test(name, fn) } } test.iphone.simulator = test.ios.simulator = function (name, fn) { if (os.host() === 'iphone-simulator') { return test(name, fn) } } test.android = function (name, fn) { if (os.host() === 'androidos') { return test(name, fn) } } test.android.emulator = function (name, fn) { if (os.host() === 'android-emulator') { return test(name, fn) } } test.desktop = function (name, fn) { if (/linux|macosx|unix|win32/.test(os.host())) { return test(name, fn) } } test.mobile = function (name, fn) { if (/android|android-emulator|iphoneos|iphone-simulator/.test(os.host())) { return test(name, fn) } } /** * @param {Error} err * @returns {void} * @ignore */ function rethrowImmediate (err) { setTimeout(rethrow, 0) /** * @returns {void} * @ignore */ function rethrow () { throw err } } /** * JSON.stringify `thing` while preserving `undefined` values in * the output. * * @param {unknown} thing * @returns {string} * @ignore */ function toJSON (thing) { /** * @type {(_k: string, v: unknown) => unknown} * @ignore */ const replacer = (_k, v) => (v === undefined) ? '_tz_undefined_tz_' : v const json = JSON.stringify(thing, replacer, ' ') || 'undefined' return json.replace(/"_tz_undefined_tz_"/g, 'undefined') }