diff --git a/index.js b/index.js index 51e764a..f58a2b4 100644 --- a/index.js +++ b/index.js @@ -61,23 +61,43 @@ const typedArrayPrototypeGetSymbolToStringTag = Symbol.toStringTag ).get -function isTypedArrayWithEntries (value) { +/** @param {object & {length?: unknown}} value */ +function getTypedArrayType (value) { // @ts-expect-error - return typedArrayPrototypeGetSymbolToStringTag.call(value) !== undefined && value.length !== 0 + const type = typedArrayPrototypeGetSymbolToStringTag.call(value) + return type !== undefined && value.length !== 0 ? type : undefined } +/** @param {number | bigint} value */ function wrapInQuotes (value) { return `"${value}"` } -function stringifyTypedArray (array, separator, maximumBreadth, bigint) { +/** + * @param {ArrayLike} array + * @param {string} separator + * @param {number} maximumBreadth + * @param {boolean | 'string'} bigint + * @param {((value: number | bigint) => never) | undefined} fail + * @param {string} type + */ +function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail, type) { if (array.length < maximumBreadth) { maximumBreadth = array.length } + const whitespace = separator === ',' ? '' : ' ' + if (type[0] === 'F') { + let value = array[0] + let res = `"0":${whitespace}${Number.isFinite(value) ? value : fail ? fail(value) : 'null'}` + for (let i = 1; i < maximumBreadth; i++) { + value = array[i] + res += `${separator}"${i}":${whitespace}${Number.isFinite(value) ? value : fail ? fail(value) : 'null'}` + } + return res + } const method = bigint === 'string' && array.length > 0 && typeof array[0] === 'bigint' ? wrapInQuotes : String - const whitespace = separator === ',' ? '' : ' ' let res = `"0":${whitespace}${method(array[0])}` for (let i = 1; i < maximumBreadth; i++) { res += `${separator}"${i}":${whitespace}${method(array[i])}` @@ -332,7 +352,7 @@ function configure (options) { whitespace = ' ' } const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (deterministic && !isTypedArrayWithEntries(value)) { + if (deterministic && getTypedArrayType(value) === undefined) { keys = sort(keys, comparator) } stack.push(value) @@ -536,8 +556,9 @@ function configure (options) { let res = '' let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, join, maximumBreadth, bigint) + const typedArrayType = getTypedArrayType(value) + if (typedArrayType !== undefined) { + res += stringifyTypedArray(value, join, maximumBreadth, bigint, fail, typedArrayType) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = join @@ -645,8 +666,9 @@ function configure (options) { } let separator = '' let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (hasLength && isTypedArrayWithEntries(value)) { - res += stringifyTypedArray(value, ',', maximumBreadth, bigint) + const typedArrayType = hasLength ? getTypedArrayType(value) : undefined + if (typedArrayType !== undefined) { + res += stringifyTypedArray(value, ',', maximumBreadth, bigint, fail, typedArrayType) keys = keys.slice(value.length) maximumPropertiesToStringify -= value.length separator = ',' diff --git a/test.js b/test.js index 2bb26a6..7da2985 100644 --- a/test.js +++ b/test.js @@ -2,6 +2,77 @@ const { test } = require('tap') const { stringify } = require('./index.js') const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') +const { runInNewContext } = require('vm') + +for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { + const TypedArray = globalThis[/** @type {'Float16Array' | 'Float32Array' | 'Float64Array'} */ (name)] + test(`${name} serializes non-finite values`, { skip: TypedArray === undefined }, + /** @param {import('tap').Test} assert */ + (assert) => { + const strict = stringify.configure({ strict: true }) + for (const space of [undefined, 2]) { + for (const number of [NaN, Infinity, -Infinity]) { + for (const values of [[number, 1], [1, number]]) { + const value = { metrics: new TypedArray(values) } + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + assert.throws(() => strict(value, null, space), { + message: `Object can not safely be stringified. Received type number (${number})` + }) + } + } + const finite = new TypedArray([-0, 1.5, -2]) + assert.equal(strict(finite, null, space), JSON.stringify(finite, null, space)) + const empty = new TypedArray(0) + assert.equal(strict(empty, null, space), JSON.stringify(empty, null, space)) + const limited = stringify.configure({ strict: true, maximumBreadth: 1 }) + assert.equal(limited(new TypedArray([1, NaN]), null, space), + JSON.stringify({ 0: 1, '...': '1 item not stringified' }, null, space)) + } + assert.end() + }) +} + +test('floating typed arrays retain their intrinsic type', + /** @param {import('tap').Test} assert */ + (assert) => { + class FloatSubclass extends Float32Array {} + const foreign = runInNewContext('new Float64Array([1, Infinity])') + const subclass = new FloatSubclass([1, -Infinity]) + const disguised = new Float32Array([1, NaN]) + Object.defineProperties(disguised, { + constructor: { value: Int32Array }, + [Symbol.toStringTag]: { value: 'Int32Array' } + }) + for (const value of [foreign, subclass, disguised]) { + for (const space of [undefined, 2]) { + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + } + } + assert.end() + }) + +test('integer and bigint typed arrays retain their formatting', + /** @param {import('tap').Test} assert */ + (assert) => { + for (const TypedArray of [Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, + Uint16Array, Int32Array, Uint32Array]) { + const value = new TypedArray([0, 1, 2]) + for (const space of [undefined, 2]) { + assert.equal(stringify(value, null, space), JSON.stringify(value, null, space)) + } + } + for (const TypedArray of [BigInt64Array, BigUint64Array]) { + const value = new TypedArray([0n, 1n, 2n]) + for (const bigint of /** @type {const} */ ([true, 'string'])) { + const serialize = stringify.configure({ bigint }) + const expected = bigint === 'string' ? { 0: '0', 1: '1', 2: '2' } : { 0: 0, 1: 1, 2: 2 } + for (const space of [undefined, 2]) { + assert.equal(serialize(value, null, space), JSON.stringify(expected, null, space)) + } + } + } + assert.end() + }) /** * @param {string} key