diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index b582d7813..4a58c2ae5 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -11,7 +11,6 @@ permissions: issues: write pull-requests: write - jobs: reply-labeled: runs-on: ubuntu-latest diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index 71f6e7bf7..c4eee0d25 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -292,7 +292,7 @@ utils.configurePrettyDOM({ - **`maxLength`** - Maximum length of the output string (default: `7000`) - **`filterNode`** - A CSS selector string or function to filter out nodes from the output. When a string is provided, elements matching the selector will be excluded. When a function is provided, it should return `false` to exclude a node. - **`highlight`** - Enable syntax highlighting (default: `true`) -- And other options from [`pretty-format`](https://npmx.dev/package/@vitest/pretty-format) +- And other options from [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format) #### Filtering with CSS Selectors 4.1.0 {#filtering-with-css-selectors} diff --git a/docs/config/diff.md b/docs/config/diff.md index ec8789175..710d66dd0 100644 --- a/docs/config/diff.md +++ b/docs/config/diff.md @@ -10,6 +10,8 @@ outline: deep `DiffOptions` object or a path to a module which exports `DiffOptions`. Useful if you want to customize diff display. +Vitest diff rendering uses [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format) under the hood and a part of `DiffOptions` is forwarded to the pretty-format configuration, while the rest affects diff rendering itself. + For example, as a config object: ```ts diff --git a/docs/config/snapshotformat.md b/docs/config/snapshotformat.md index e161c236f..76721ebce 100644 --- a/docs/config/snapshotformat.md +++ b/docs/config/snapshotformat.md @@ -5,12 +5,29 @@ outline: deep # snapshotFormat -- **Type:** `PrettyFormatOptions` +- **Type:** `Omit & { compareKeys?: null | undefined }` -Format options for snapshot testing. These options are passed down to our fork of [`pretty-format`](https://npmx.dev/package/pretty-format). In addition to the `pretty-format` options we support `printShadowRoot: boolean`. +Format options for snapshot testing. These options configure the snapshot-specific formatting layer built on top of [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format). + +For the full option surface of `PrettyFormatOptions`, see [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format). This page focuses on the Vitest snapshot-specific defaults and constraints. + +Vitest snapshots already apply these defaults before your `snapshotFormat` overrides: + +- `printBasicPrototype: false` +- `escapeString: false` +- `escapeRegex: true` +- `printFunctionName: false` + +Vitest also supports formatter options such as `printShadowRoot` and `maxOutputLength` in `snapshotFormat`. + +`printShadowRoot` controls whether shadow-root contents are included in DOM snapshots. + +`maxOutputLength` is an approximate per-depth output budget, not a hard cap on the final rendered string. + +By default, snapshot keys are sorted using the formatter's default behavior. Set `compareKeys` to `null` to disable key sorting. Custom compare functions are not supported in `snapshotFormat`. ::: tip -Beware that `plugins` field on this object will be ignored. +Beware that `plugins` on this object will be ignored. -If you need to extend snapshot serializer via pretty-format plugins, please, use [`expect.addSnapshotSerializer`](/api/expect#expect-addsnapshotserializer) API or [snapshotSerializers](/config/snapshotserializers) option. +If you need to extend snapshot serialization via pretty-format plugins, use [`expect.addSnapshotSerializer`](/api/expect#expect-addsnapshotserializer) or [`snapshotSerializers`](/config/snapshotserializers) instead. ::: diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index 2ca266c4a..eb115a513 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -33,6 +33,8 @@ exports['toUpperCase 1'] = '"FOOBAR"' The snapshot artifact should be committed alongside code changes, and reviewed as part of your code review process. On subsequent test runs, Vitest will compare the rendered output with the previous snapshot. If they match, the test will pass. If they don't match, either the test runner found a bug in your code that should be fixed, or the implementation has changed and the snapshot needs to be updated. +Vitest stores a serialized representation of the received value. Snapshot rendering is powered by [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format). [`snapshotFormat`](/config/snapshotformat) allows configuring general snapshot formatting behavior in Vitest. For further customization, you can implement your own [custom serializers](#custom-serializer) or [custom snapshot matchers](#custom-snapshot-matchers). + ::: warning When using Snapshots with async concurrent tests, `expect` from the local [Test Context](/guide/test-context) must be used to ensure the right test is detected. ::: @@ -198,8 +200,6 @@ Pretty foo: Object { } ``` -We are using Jest's `pretty-format` for serializing snapshots. You can read more about it here: [pretty-format](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#serialize). - ## Custom Snapshot Matchers experimental 4.1.3 {#custom-snapshot-matchers} You can build custom snapshot matchers using the composable functions exported from `vitest/runtime`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting). @@ -285,7 +285,7 @@ This does not really affect the functionality but might affect your commit diff #### 2. `printBasicPrototype` is default to `false` -Both Jest and Vitest's snapshots are powered by [`pretty-format`](https://github.com/facebook/jest/blob/main/packages/pretty-format). In Vitest we set `printBasicPrototype` default to `false` to provide a cleaner snapshot output, while in Jest <29.0.0 it's `true` by default. +Both Jest and Vitest snapshots are powered by `pretty-format`, but Vitest applies its own snapshot defaults on top of [`@vitest/pretty-format`](https://npmx.dev/package/@vitest/pretty-format). In particular, Vitest sets `printBasicPrototype` to `false` to provide a cleaner snapshot output, while in Jest <29.0.0 it is `true` by default. ```ts import { expect, test } from 'vitest' diff --git a/packages/pretty-format/README.md b/packages/pretty-format/README.md index 4c24447e0..d36596330 100644 --- a/packages/pretty-format/README.md +++ b/packages/pretty-format/README.md @@ -2,6 +2,6 @@ [![NPM version](https://img.shields.io/npm/v/@vitest/pretty-format?color=a1b858&label=)](https://npmx.dev/package/@vitest/pretty-format) -Jest's `pretty-format` implementation that only supports ESM. +Vitest's fork of Jest's [`pretty-format`](https://npmx.dev/package/pretty-format). -[GitHub](https://github.com/vitest-dev/vitest/tree/main/packages/pretty-format) | [Documentation](https://vitest.dev/) +[GitHub](https://github.com/vitest-dev/vitest/tree/main/packages/pretty-format) | [Documentation](https://github.com/vitest-dev/vitest/blob/main/packages/pretty-format/USAGE.md) diff --git a/packages/pretty-format/USAGE.md b/packages/pretty-format/USAGE.md new file mode 100644 index 000000000..20a7d856e --- /dev/null +++ b/packages/pretty-format/USAGE.md @@ -0,0 +1,170 @@ +# @vitest/pretty-format + +Vitest's fork of Jest's [`pretty-format`](https://npmx.dev/package/pretty-format), published as an ESM-only package. + +This package powers several formatting paths in Vitest: + +- snapshot serialization +- assertion diff rendering +- matcher and error messages +- browser `prettyDOM` output + +## Usage + +```ts +import { format } from '@vitest/pretty-format' + +const value = { + user: 'Ada', + items: [1, 2, 3], +} + +console.log(format(value)) +/* +-- output -- +Object { + "items": Array [ + 1, + 2, + 3, + ], + "user": "Ada", +} +*/ +``` + +## Options + +| key | type | default | notes | +| :-------------------- | :--------------- | :---------- | :------------------------------------------------------------------- | +| `callToJSON` | `boolean` | `true` | Call `toJSON` if present | +| `compareKeys` | `function\|null` | `undefined` | Compare function for sorting object keys. Use `null` to skip sorting | +| `escapeRegex` | `boolean` | `false` | Escape special characters in regular expressions | +| `escapeString` | `boolean` | `true` | Escape special characters in strings | +| `highlight` | `boolean` | `false` | Highlight syntax with terminal colors | +| `indent` | `number` | `2` | Spaces per indentation level | +| `maxDepth` | `number` | `Infinity` | Maximum depth to print | +| `maxOutputLength` | `number` | `1_000_000` | Approximate per-depth output budget | +| `maxWidth` | `number` | `Infinity` | Maximum number of items to print in collections | +| `min` | `boolean` | `false` | Minimize added whitespace | +| `plugins` | `array` | `[]` | Plugins to serialize application-specific data types | +| `printBasicPrototype` | `boolean` | `true` | Print `Object` and `Array` prefixes for plain objects and arrays | +| `printFunctionName` | `boolean` | `true` | Include or omit the function name | +| `printShadowRoot` | `boolean` | `true` | Include shadow-root contents when formatting DOM nodes | + +Important: + +- `plugins: []` means the package does not auto-enable its built-in plugins by default +- Vitest features opt into their own plugin stacks and option presets + +## Built-in Plugins + +The package exports these built-in plugins: + +- `ReactTestComponent` +- `ReactElement` +- `DOMElement` +- `DOMCollection` +- `Immutable` +- `AsymmetricMatcher` +- `Error` + +You can use them directly with `format(..., { plugins })`: + +```ts +import { format, plugins } from '@vitest/pretty-format' + +console.log( + format(document.body, { + plugins: [plugins.DOMElement, plugins.DOMCollection], + }), +) +``` + +## Vitest Extensions + +Besides the inherited `pretty-format` API surface, Vitest currently adds and documents these notable behaviors: + +### `printShadowRoot` + +Controls whether DOM serialization includes shadow-root contents. + +```ts +format(element, { + printShadowRoot: false, +}) +``` + +### `maxOutputLength` + +Approximate per-depth output budget used to prevent pathological expansion of large recursive structures. + +This is a heuristic safety valve, not a hard cap on the final string length. + +```ts +format(value, { + maxOutputLength: 100_000, +}) +``` + +## How Vitest Uses It + +### Snapshots + +Snapshots use `@vitest/pretty-format` with snapshot-specific defaults such as: + +- `printBasicPrototype: false` +- `escapeString: false` +- `escapeRegex: true` +- `printFunctionName: false` + +Default snapshot plugin stack: + +- `ReactTestComponent` +- `ReactElement` +- `DOMElement` +- `DOMCollection` +- `Immutable` +- `AsymmetricMatcher` +- `MockSerializer` + +Snapshot formatting is configured through [`test.snapshotFormat`](https://vitest.dev/config/snapshotformat), while serializer registration goes through [`expect.addSnapshotSerializer`](https://vitest.dev/api/expect#expect-addsnapshotserializer) or [`snapshotSerializers`](https://vitest.dev/config/snapshotserializers). + +### Diffs + +Assertion diffs use a different preset and plugin stack. + +Default diff plugins: + +- `ReactTestComponent` +- `ReactElement` +- `DOMElement` +- `DOMCollection` +- `Immutable` +- `AsymmetricMatcher` +- `Error` + +### Vitest `stringify` + +Matcher and error messages commonly go through Vitest's internal [`stringify`](https://github.com/vitest-dev/vitest/blob/59b0e6411be2b4aa5f2b339d02691aa83d5e403f/packages/utils/src/display.ts#L49) utility, which uses: + +- `ReactTestComponent` +- `ReactElement` +- `DOMElement` +- `DOMCollection` +- `Immutable` +- `AsymmetricMatcher` + +`stringify` also adds wrapper-level behavior on top of `@vitest/pretty-format`: + +- `maxLength`: if the formatted output grows too large, `stringify` retries with a smaller `maxDepth` to keep the result bounded +- `filterNode`: swaps the default DOM plugin for a filtered variant so selected nodes are omitted from the output +- fallback on formatter errors: if formatting throws, `stringify` retries with `callToJSON: false` + +### Browser `prettyDOM` + +Browser `prettyDOM` builds on Vitest's `stringify` path and enables browser-oriented defaults such as: + +- `highlight: true` + +It can also replace the default DOM plugin with a filtered variant when `filterNode` is configured. diff --git a/packages/pretty-format/src/types.ts b/packages/pretty-format/src/types.ts index 2c7544cae..dfaea1c85 100644 --- a/packages/pretty-format/src/types.ts +++ b/packages/pretty-format/src/types.ts @@ -38,12 +38,41 @@ export interface Options } export interface PrettyFormatOptions { + /** + * Call `toJSON` on objects before formatting them. + * Ignored after the formatter has already called `toJSON` once for a value. + * @default true + */ callToJSON?: boolean + /** + * Whether to escape special characters in regular expressions. + * @default false + */ escapeRegex?: boolean + /** + * Whether to escape special characters in strings. + * @default true + */ escapeString?: boolean + /** + * Whether to highlight syntax using terminal colors. + * @default false + */ highlight?: boolean + /** + * Number of spaces to use for each level of indentation. + * @default 2 + */ indent?: number + /** + * Maximum depth to recurse into nested values. + * @default Infinity + */ maxDepth?: number + /** + * Maximum number of items to print in arrays, sets, maps, and similar collections. + * @default Infinity + */ maxWidth?: number /** * Approximate per-depth-level budget for output length. @@ -53,11 +82,34 @@ export interface PrettyFormatOptions { * @default 1_000_000 */ maxOutputLength?: number + /** + * Whether to minimize added whitespace, including indentation and line breaks. + * @default false + */ min?: boolean + /** + * Whether to print `Object` / `Array` prefixes for plain objects and arrays. + * @default true + */ printBasicPrototype?: boolean + /** + * Whether to include the function name when formatting functions. + * @default true + */ printFunctionName?: boolean + /** + * Whether to include shadow-root contents when formatting DOM nodes. + * @default true + */ printShadowRoot?: boolean + /** + * Compare function used when sorting object keys. Set to `null` to disable sorting. + */ compareKeys?: CompareKeys + /** + * Plugins used to serialize application-specific data types. + * @default [] + */ plugins?: Plugins } diff --git a/test/core/test/pretty-format.test.ts b/test/core/test/pretty-format.test.ts index 12a54ddcf..950a1cc97 100644 --- a/test/core/test/pretty-format.test.ts +++ b/test/core/test/pretty-format.test.ts @@ -1,4 +1,4 @@ -import { format } from '@vitest/pretty-format' +import { format, plugins } from '@vitest/pretty-format' import { describe, expect, test } from 'vitest' describe('maxOutputLength', () => { @@ -201,3 +201,802 @@ describe('maxOutputLength', () => { `) }) }) + +describe('basic types', () => { + test('null', () => { + expect(format(null)).toMatchInlineSnapshot(`"null"`) + }) + + test('undefined', () => { + expect(format(undefined)).toMatchInlineSnapshot(`"undefined"`) + }) + + test('true', () => { + expect(format(true)).toMatchInlineSnapshot(`"true"`) + }) + + test('false', () => { + expect(format(false)).toMatchInlineSnapshot(`"false"`) + }) + + test('positive number', () => { + expect(format(123)).toMatchInlineSnapshot(`"123"`) + }) + + test('negative number', () => { + expect(format(-123)).toMatchInlineSnapshot(`"-123"`) + }) + + test('zero', () => { + expect(format(0)).toMatchInlineSnapshot(`"0"`) + }) + + test('negative zero', () => { + expect(format(-0)).toMatchInlineSnapshot(`"-0"`) + }) + + test('NaN', () => { + expect(format(Number.NaN)).toMatchInlineSnapshot(`"NaN"`) + }) + + test('Infinity', () => { + expect(format(Number.POSITIVE_INFINITY)).toMatchInlineSnapshot(`"Infinity"`) + }) + + test('-Infinity', () => { + expect(format(Number.NEGATIVE_INFINITY)).toMatchInlineSnapshot(`"-Infinity"`) + }) + + test('float', () => { + expect(format(3.14)).toMatchInlineSnapshot(`"3.14"`) + }) + + test('positive bigint', () => { + expect(format(123n)).toMatchInlineSnapshot(`"123n"`) + }) + + test('negative bigint', () => { + expect(format(-123n)).toMatchInlineSnapshot(`"-123n"`) + }) + + test('zero bigint', () => { + expect(format(0n)).toMatchInlineSnapshot(`"0n"`) + }) + + test('string', () => { + expect(format('string')).toMatchInlineSnapshot(`""string""`) + }) + + test('empty string', () => { + expect(format('')).toMatchInlineSnapshot(`""""`) + }) + + test('string with double quotes and backslash (escapeString default)', () => { + expect(format('"\'\\')) + .toMatchInlineSnapshot(`""\\"'\\\\""`) + }) + + test('multiline string', () => { + expect(format('line 1\nline 2')).toMatchInlineSnapshot(` + ""line 1 + line 2"" + `) + }) + + test('named symbol', () => { + expect(format(Symbol('test'))).toMatchInlineSnapshot(`"Symbol(test)"`) + }) + + test('unnamed symbol', () => { + expect(format(Symbol(''))).toMatchInlineSnapshot(`"Symbol()"`) + }) + + test('named function', () => { + function named() {} + expect(format(named)).toMatchInlineSnapshot(`"[Function named]"`) + }) + + test('anonymous function', () => { + expect(format(() => {})).toMatchInlineSnapshot(`"[Function anonymous]"`) + }) + + test('named generator function', () => { + function* gen() { + yield 1 + } + expect(format(gen)).toMatchInlineSnapshot(`"[Function gen]"`) + }) + + test('date', () => { + expect(format(new Date(10e11))).toMatchInlineSnapshot(`"2001-09-09T01:46:40.000Z"`) + }) + + test('invalid date', () => { + expect(format(new Date(Infinity))).toMatchInlineSnapshot(`"Date { NaN }"`) + }) + + test('regexp from literal', () => { + expect(format(/regexp/gi)).toMatchInlineSnapshot(`"/regexp/gi"`) + }) + + test('regexp from constructor', () => { + expect(format(/regexp/)).toMatchInlineSnapshot(`"/regexp/"`) + }) + + test('error', () => { + expect(format(new Error('test'))).toMatchInlineSnapshot(`"[Error: test]"`) + }) + + test('typed error with message', () => { + expect(format(new TypeError('message'))).toMatchInlineSnapshot(`"[TypeError: message]"`) + }) + + test('WeakMap', () => { + expect(format(new WeakMap())).toMatchInlineSnapshot(`"WeakMap {}"`) + }) + + test('WeakSet', () => { + expect(format(new WeakSet())).toMatchInlineSnapshot(`"WeakSet {}"`) + }) + + test('Promise', () => { + expect(format(Promise.resolve())).toMatchInlineSnapshot(`"Promise {}"`) + }) +}) + +// -- collections -- + +describe('arrays', () => { + test('empty', () => { + expect(format([])).toMatchInlineSnapshot(`"Array []"`) + }) + + test('with items', () => { + expect(format([1, 2, 3])).toMatchInlineSnapshot(` + "Array [ + 1, + 2, + 3, + ]" + `) + }) + + test('sparse with only holes', () => { + // eslint-disable-next-line no-sparse-arrays + expect(format([, , ,])).toMatchInlineSnapshot(` + "Array [ + , + , + , + ]" + `) + }) + + test('sparse with items', () => { + // eslint-disable-next-line no-sparse-arrays + expect(format([1, , , 4])).toMatchInlineSnapshot(` + "Array [ + 1, + , + , + 4, + ]" + `) + }) + + test('sparse with undefined', () => { + // eslint-disable-next-line no-sparse-arrays + expect(format([1, , undefined, , 4])).toMatchInlineSnapshot( + ` + "Array [ + 1, + , + undefined, + , + 4, + ]" + `, + ) + }) + + test('nested', () => { + expect(format([[1, 2]])).toMatchInlineSnapshot( + ` + "Array [ + Array [ + 1, + 2, + ], + ]" + `, + ) + }) + + test('typed array empty', () => { + expect(format(new Uint8Array(0))).toMatchInlineSnapshot(`"Uint8Array []"`) + }) + + test('typed array with items', () => { + expect(format(new Uint32Array(3))).toMatchInlineSnapshot( + ` + "Uint32Array [ + 0, + 0, + 0, + ]" + `, + ) + }) + + test('ArrayBuffer', () => { + expect(format(new ArrayBuffer(3))).toMatchInlineSnapshot( + ` + "ArrayBuffer [ + 0, + 0, + 0, + ]" + `, + ) + }) + + test('DataView', () => { + expect(format(new DataView(new ArrayBuffer(3)))).toMatchInlineSnapshot( + ` + "DataView [ + 0, + 0, + 0, + ]" + `, + ) + }) +}) + +describe('objects', () => { + test('empty', () => { + expect(format({})).toMatchInlineSnapshot(`"Object {}"`) + }) + + test('with properties', () => { + expect(format({ prop1: 'value1', prop2: 'value2' })).toMatchInlineSnapshot( + ` + "Object { + "prop1": "value1", + "prop2": "value2", + }" + `, + ) + }) + + test('keys are sorted by default', () => { + expect(format({ b: 1, a: 2 })).toMatchInlineSnapshot( + ` + "Object { + "a": 2, + "b": 1, + }" + `, + ) + }) + + test('deeply nested', () => { + expect(format({ a: { b: { c: 'val' } } })).toMatchInlineSnapshot( + ` + "Object { + "a": Object { + "b": Object { + "c": "val", + }, + }, + }" + `, + ) + }) + + test('Object.create(null)', () => { + expect(format(Object.create(null))).toMatchInlineSnapshot(`"Object {}"`) + }) + + test('custom constructor name', () => { + class Foo { x = 1 } + expect(format(new Foo())).toMatchInlineSnapshot(` + "Foo { + "x": 1, + }" + `) + }) + + test('with symbol properties', () => { + const val: any = { prop: 'value' } + val[Symbol('sym')] = 'symval' + expect(format(val)).toMatchInlineSnapshot( + ` + "Object { + "prop": "value", + Symbol(sym): "symval", + }" + `, + ) + }) + + test('skips non-enumerable string keys', () => { + const val = { enumerable: true } + Object.defineProperty(val, 'hidden', { enumerable: false, value: false }) + expect(format(val)).toMatchInlineSnapshot(` + "Object { + "enumerable": true, + }" + `) + }) + + test('skips non-enumerable symbol keys', () => { + const val = { enumerable: true } + Object.defineProperty(val, Symbol('hidden'), { enumerable: false, value: false }) + expect(format(val)).toMatchInlineSnapshot(` + "Object { + "enumerable": true, + }" + `) + }) + + test('circular reference', () => { + const val: any = {} + val.self = val + expect(format(val)).toMatchInlineSnapshot(` + "Object { + "self": [Circular], + }" + `) + }) + + test('parallel references', () => { + const inner = {} + expect(format({ a: inner, b: inner })).toMatchInlineSnapshot( + ` + "Object { + "a": Object {}, + "b": Object {}, + }" + `, + ) + }) +}) + +describe('Map', () => { + test('empty', () => { + expect(format(new Map())).toMatchInlineSnapshot(`"Map {}"`) + }) + + test('with string keys', () => { + const val = new Map([['a', 1], ['b', 2]]) + expect(format(val)).toMatchInlineSnapshot( + ` + "Map { + "a" => 1, + "b" => 2, + }" + `, + ) + }) + + test('with non-string keys', () => { + const val = new Map([ + [false, 'bool'], + [null, 'null'], + [undefined, 'undef'], + [42, 'num'], + ]) + expect(format(val)).toMatchInlineSnapshot( + ` + "Map { + false => "bool", + null => "null", + undefined => "undef", + 42 => "num", + }" + `, + ) + }) +}) + +describe('Set', () => { + test('empty', () => { + expect(format(new Set())).toMatchInlineSnapshot(`"Set {}"`) + }) + + test('with values', () => { + expect(format(new Set(['a', 'b']))).toMatchInlineSnapshot( + ` + "Set { + "a", + "b", + }" + `, + ) + }) +}) + +describe('Arguments', () => { + function returnArguments(..._args: Array) { + // eslint-disable-next-line prefer-rest-params + return arguments + } + + test('empty', () => { + expect(format(returnArguments())).toMatchInlineSnapshot(`"Arguments []"`) + }) + + test('with values', () => { + expect(format(returnArguments(1, 2, 3))).toMatchInlineSnapshot( + ` + "Arguments [ + 1, + 2, + 3, + ]" + `, + ) + }) +}) + +describe('indent option', () => { + const val = [{ a: 1 }] + + test('default (2 spaces)', () => { + expect(format(val)).toMatchInlineSnapshot( + ` + "Array [ + Object { + "a": 1, + }, + ]" + `, + ) + }) + + test('0 spaces', () => { + expect(format(val, { indent: 0 })).toMatchInlineSnapshot( + ` + "Array [ + Object { + "a": 1, + }, + ]" + `, + ) + }) + + test('4 spaces', () => { + expect(format(val, { indent: 4 })).toMatchInlineSnapshot( + ` + "Array [ + Object { + "a": 1, + }, + ]" + `, + ) + }) +}) + +describe('maxDepth option', () => { + test('truncates nested structures', () => { + const val = { a: { b: { c: 'deep' } }, arr: [[1]] } + expect(format(val, { maxDepth: 1 })).toMatchInlineSnapshot( + ` + "Object { + "a": [Object], + "arr": [Array], + }" + `, + ) + }) + + test('maxDepth with Map and Set', () => { + const val = { m: new Map([['k', 'v']]), s: new Set([1]) } + expect(format(val, { maxDepth: 1 })).toMatchInlineSnapshot( + ` + "Object { + "m": [Map], + "s": [Set], + }" + `, + ) + }) +}) + +describe('maxWidth option', () => { + test('truncates arrays', () => { + expect(format([1, 2, 3, 4, 5], { maxWidth: 3 })).toMatchInlineSnapshot( + ` + "Array [ + 1, + 2, + 3, + … + ]" + `, + ) + }) + + test('truncates sets', () => { + expect(format(new Set([1, 2, 3, 4, 5]), { maxWidth: 3 })).toMatchInlineSnapshot( + ` + "Set { + 1, + 2, + 3, + … + }" + `, + ) + }) + + test('truncates maps', () => { + const val = new Map([['a', 1], ['b', 2], ['c', 3], ['d', 4]]) + expect(format(val, { maxWidth: 2 })).toMatchInlineSnapshot( + ` + "Map { + "a" => 1, + "b" => 2, + … + }" + `, + ) + }) +}) + +describe('min option', () => { + test('basic values', () => { + expect(format({ a: [1, 2], b: 'str' }, { min: true })).toMatchInlineSnapshot( + `"{"a": [1, 2], "b": "str"}"`, + ) + }) + + test('Map and Set', () => { + expect(format(new Map([['k', 'v']]), { min: true })).toMatchInlineSnapshot( + `"Map {"k" => "v"}"`, + ) + expect(format(new Set([1, 2]), { min: true })).toMatchInlineSnapshot( + `"Set {1, 2}"`, + ) + }) + + test('does not allow indent !== 0 with min', () => { + expect(() => format(1, { indent: 1, min: true })) + .toThrowErrorMatchingInlineSnapshot(`[Error: pretty-format: Options "min" and "indent" cannot be used together.]`) + }) +}) + +describe('compareKeys option', () => { + test('null preserves insertion order', () => { + expect(format({ b: 1, a: 2 })).toMatchInlineSnapshot( + ` + "Object { + "a": 2, + "b": 1, + }" + `, + ) + expect(format({ b: 1, a: 2 }, { compareKeys: null })).toMatchInlineSnapshot( + ` + "Object { + "b": 1, + "a": 2, + }" + `, + ) + }) + + test('custom sort (reverse)', () => { + const compareKeys = (a: string, b: string) => (a > b ? -1 : 1) + expect(format({ a: 1, b: 2 }, { compareKeys })).toMatchInlineSnapshot( + ` + "Object { + "b": 2, + "a": 1, + }" + `, + ) + }) +}) + +describe('callToJSON option', () => { + test('calls toJSON by default', () => { + const val = { toJSON: () => ({ replaced: true }), orig: 1 } + expect(format(val)).toMatchInlineSnapshot( + ` + "Object { + "replaced": true, + }" + `, + ) + }) + + test('skips toJSON when false', () => { + const val = { toJSON: () => 'ignored', orig: 1 } + const result = format(val, { callToJSON: false }) + expect(result).toMatchInlineSnapshot(` + "Object { + "orig": 1, + "toJSON": [Function toJSON], + }" + `) + }) + + test('does not call toJSON recursively', () => { + const val = { toJSON: () => ({ toJSON: () => 'deep' }) } + expect(format(val)).toMatchInlineSnapshot( + ` + "Object { + "toJSON": [Function toJSON], + }" + `, + ) + }) +}) + +describe('printBasicPrototype option', () => { + test('includes prototype name by default', () => { + expect(format({})).toMatchInlineSnapshot(`"Object {}"`) + expect(format([])).toMatchInlineSnapshot(`"Array []"`) + }) + + test('omits basic prototype names when false', () => { + expect(format({}, { printBasicPrototype: false })).toMatchInlineSnapshot(`"{}"`) + expect(format([], { printBasicPrototype: false })).toMatchInlineSnapshot(`"[]"`) + }) + + test('still shows custom constructor names when false', () => { + class Custom {} + expect(format(new Custom(), { printBasicPrototype: false })).toMatchInlineSnapshot(`"Custom {}"`) + }) +}) + +describe('printFunctionName option', () => { + test('prints function name by default', () => { + function myFn() {} + expect(format(myFn)).toMatchInlineSnapshot(`"[Function myFn]"`) + }) + + test('hides function name when false', () => { + function myFn() {} + expect(format(myFn, { printFunctionName: false })).toMatchInlineSnapshot(`"[Function]"`) + }) +}) + +describe('escapeString option', () => { + test('escapes by default', () => { + expect(format('"hello"')).toMatchInlineSnapshot(`""\\"hello\\"""`) + }) + + test('does not escape when false', () => { + expect(format('"hello"', { escapeString: false })).toMatchInlineSnapshot(`"""hello"""`) + }) +}) + +describe('escapeRegex option', () => { + test('no escaping by default', () => { + expect(format(/regexp\d/gi)).toMatchInlineSnapshot(`"/regexp\\d/gi"`) + }) + + test('escapes when true', () => { + expect(format(/regexp\d/gi, { escapeRegex: true })).toMatchInlineSnapshot(`"/regexp\\\\d/gi"`) + }) +}) + +describe('ErrorPlugin', () => { + test('Error with message', () => { + const err = new Error('boom') + expect(format(err, { plugins: [plugins.Error] })).toMatchInlineSnapshot(` + "Error { + "message": "boom", + }" + `) + }) + + test('TypeError', () => { + const err = new TypeError('bad type') + expect(format(err, { plugins: [plugins.Error] })).toMatchInlineSnapshot(` + "TypeError { + "message": "bad type", + }" + `) + }) + + test('Error with cause', () => { + const err = new Error('outer', { cause: 'inner' }) + expect(format(err, { plugins: [plugins.Error] })).toMatchInlineSnapshot(` + "Error { + "message": "outer", + "cause": "inner", + }" + `) + }) + + test('AggregateError', () => { + const err = new AggregateError([new Error('a'), new Error('b')], 'multiple') + const result = format(err, { plugins: [plugins.Error] }) + expect(result).toMatchInlineSnapshot(` + "AggregateError { + "message": "multiple", + "errors": Array [ + Error { + "message": "a", + }, + Error { + "message": "b", + }, + ], + }" + `) + }) + + test('circular error', () => { + const err = new Error('loop') as any + err.self = err + const result = format(err, { plugins: [plugins.Error] }) + expect(result).toMatchInlineSnapshot(` + "Error { + "message": "loop", + "self": [Circular], + }" + `) + }) +}) + +describe('plugins', () => { + test('custom plugin with test/print', () => { + class Foo { value = 42 } + const result = format(new Foo(), { + plugins: [{ + test: (val: unknown) => val instanceof Foo, + print: (val: any) => `Foo(${val.value})`, + }], + }) + expect(result).toMatchInlineSnapshot(`"Foo(42)"`) + }) + + test('custom plugin with test/serialize', () => { + class Bar {} + const result = format(new Bar(), { + plugins: [{ + test: (val: unknown) => val instanceof Bar, + serialize: () => 'serialized Bar', + }], + }) + expect(result).toMatchInlineSnapshot(`"serialized Bar"`) + }) + + test('plugin returning empty string', () => { + const result = format('x', { + plugins: [{ + test: () => true, + print: () => '', + }], + }) + expect(result).toMatchInlineSnapshot(`""`) + }) + + test('throws if plugin returns non-string', () => { + expect(() => format(1, { + plugins: [{ + test: () => true, + // @ts-expect-error testing runtime + print: (val: unknown) => val, + }], + })).toThrowErrorMatchingInlineSnapshot(`[TypeError: pretty-format: Plugin must return type "string" but instead returned "number".]`) + }) +}) + +// -- validation -- + +describe('validation', () => { + test('throws on unknown option', () => { + expect(() => { + // @ts-expect-error testing runtime + format({}, { badOption: true }) + }).toThrowErrorMatchingInlineSnapshot(`[Error: pretty-format: Unknown option "badOption".]`) + }) +})