diff --git a/packages/pretty-format/src/index.ts b/packages/pretty-format/src/index.ts index d5f0d886b3c5..7dcee15ebdb1 100644 --- a/packages/pretty-format/src/index.ts +++ b/packages/pretty-format/src/index.ts @@ -380,29 +380,44 @@ function printer( refs: Refs, hasCalledToJSON?: boolean, ): string { + let result: string + const plugin = findPlugin(config.plugins, val) if (plugin !== null) { - return printPlugin(plugin, val, config, indentation, depth, refs) + result = printPlugin(plugin, val, config, indentation, depth, refs) + } + else { + const basicResult = printBasicValue( + val, + config.printFunctionName, + config.escapeRegex, + config.escapeString, + ) + if (basicResult !== null) { + result = basicResult + } + else { + result = printComplexValue( + val, + config, + indentation, + depth, + refs, + hasCalledToJSON, + ) + } } - const basicResult = printBasicValue( - val, - config.printFunctionName, - config.escapeRegex, - config.escapeString, - ) - if (basicResult !== null) { - return basicResult + // Check string length budget: + // accumulate output length and if exceeded, + // force no further recursion by patching maxDepth. + // Inspired by Node's util.inspect bail out approach. + config.outputLength += result.length + if (config.outputLength > config.maxOutputLength) { + config.maxDepth = 0 } - return printComplexValue( - val, - config, - indentation, - depth, - refs, - hasCalledToJSON, - ) + return result } const DEFAULT_THEME: Theme = { @@ -425,6 +440,9 @@ export const DEFAULT_OPTIONS: Options = { highlight: false, indent: 2, maxDepth: Number.POSITIVE_INFINITY, + // Practical default hard-limit to avoid too long string being generated + // (Node's limit is buffer.constants.MAX_STRING_LENGTH ~ 512MB) + maxOutputLength: 1_000_000, maxWidth: Number.POSITIVE_INFINITY, min: false, plugins: [], @@ -509,6 +527,8 @@ function getConfig(options?: OptionsReceived): Config { printShadowRoot: options?.printShadowRoot ?? true, spacingInner: options?.min ? ' ' : '\n', spacingOuter: options?.min ? '' : '\n', + maxOutputLength: options?.maxOutputLength ?? DEFAULT_OPTIONS.maxOutputLength, + outputLength: 0, } } diff --git a/packages/pretty-format/src/types.ts b/packages/pretty-format/src/types.ts index eac12b7946e6..2e37ebb4f595 100644 --- a/packages/pretty-format/src/types.ts +++ b/packages/pretty-format/src/types.ts @@ -45,6 +45,7 @@ export interface PrettyFormatOptions { indent?: number maxDepth?: number maxWidth?: number + maxOutputLength?: number min?: boolean printBasicPrototype?: boolean printFunctionName?: boolean @@ -71,6 +72,8 @@ export interface Config { printShadowRoot: boolean spacingInner: string spacingOuter: string + maxOutputLength: number + outputLength: number } export type Printer = ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d44e98c9965..bd29eac78a63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1464,6 +1464,9 @@ importers: '@vitest/mocker': specifier: workspace:* version: link:../../packages/mocker + '@vitest/pretty-format': + specifier: workspace:* + version: link:../../packages/pretty-format '@vitest/runner': specifier: workspace:* version: link:../../packages/runner diff --git a/test/core/package.json b/test/core/package.json index db42398579f4..ff850bb52af2 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -22,6 +22,7 @@ "@test/vite-external": "link:./projects/vite-external", "@vitest/expect": "workspace:*", "@vitest/mocker": "workspace:*", + "@vitest/pretty-format": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/test-dep-cjs": "file:./deps/dep-cjs", "@vitest/test-dep-nested-cjs": "file:./deps/dep-nested-cjs", diff --git a/test/core/test/pretty-format.test.ts b/test/core/test/pretty-format.test.ts new file mode 100644 index 000000000000..70c188862f65 --- /dev/null +++ b/test/core/test/pretty-format.test.ts @@ -0,0 +1,187 @@ +import { format } from '@vitest/pretty-format' +import { describe, expect, test } from 'vitest' + +describe('maxOutputLength', () => { + function createObjectGraph(n: number) { + // owner + // |-> cats + // |-> cat0 -> owner + // |-> cat1 -> owner + // |-> cat2 + // |-> ... + // |-> dogs + // |-> dog0 + // |-> dog1 + // |-> dog2 + // |-> ... + interface Owner { + dogs: Pet[] + cats: Pet[] + } + interface Pet { + name: string + owner: Owner + } + const owner: Owner = { dogs: [], cats: [] } + for (let i = 0; i < n; i++) { + owner.dogs.push({ name: `dog${i}`, owner }) + } + for (let i = 0; i < n; i++) { + owner.cats.push({ name: `cat${i}`, owner }) + } + return owner + } + + test('quadratic growth example depending on format root', () => { + const owner = createObjectGraph(3) + + // when starting from owner, each pet is expanded once, so no amplification, just linear growth. + expect(format(owner)).toMatchInlineSnapshot(` + "Object { + "cats": Array [ + Object { + "name": "cat0", + "owner": [Circular], + }, + Object { + "name": "cat1", + "owner": [Circular], + }, + Object { + "name": "cat2", + "owner": [Circular], + }, + ], + "dogs": Array [ + Object { + "name": "dog0", + "owner": [Circular], + }, + Object { + "name": "dog1", + "owner": [Circular], + }, + Object { + "name": "dog2", + "owner": [Circular], + }, + ], + }" + `) + + // when starting from owner.cats, each cat re-expands the full dogs list via owner. + // this exhibits quadratic growth, which is what the budget is designed to prevent. + expect(format(owner.cats)).toMatchInlineSnapshot(` + "Array [ + Object { + "name": "cat0", + "owner": Object { + "cats": [Circular], + "dogs": Array [ + Object { + "name": "dog0", + "owner": [Circular], + }, + Object { + "name": "dog1", + "owner": [Circular], + }, + Object { + "name": "dog2", + "owner": [Circular], + }, + ], + }, + }, + Object { + "name": "cat1", + "owner": Object { + "cats": [Circular], + "dogs": Array [ + Object { + "name": "dog0", + "owner": [Circular], + }, + Object { + "name": "dog1", + "owner": [Circular], + }, + Object { + "name": "dog2", + "owner": [Circular], + }, + ], + }, + }, + Object { + "name": "cat2", + "owner": Object { + "cats": [Circular], + "dogs": Array [ + Object { + "name": "dog0", + "owner": [Circular], + }, + Object { + "name": "dog1", + "owner": [Circular], + }, + Object { + "name": "dog2", + "owner": [Circular], + }, + ], + }, + }, + ]" + `) + }) + + test('budget prevents blowup on large graphs', () => { + // quickly hit the kill switch due to quadratic growth + expect([10, 20, 30, 1000, 2000, 3000].map(n => format(createObjectGraph(n).cats).length)) + .toMatchInlineSnapshot(` + [ + 9729, + 36659, + 80789, + 273009, + 374009, + 299009, + ] + `) + + // depending on object/array shape, output can exceed the limit 1mb + // but the output size is proportional to the amount of objects and the size of array. + expect(format(createObjectGraph(10000).cats).length).toMatchInlineSnapshot(`936779`) + expect(format(createObjectGraph(20000).cats).length).toMatchInlineSnapshot(`1236779`) + }) + + test('early elements expanded, later elements folded after budget trips', () => { + // First few objects are fully expanded, but once budget is exceeded, + // maxDepth = 0 means no more expansion. + const arr = Array.from({ length: 10 }, (_, i) => ({ i })) + expect(format(arr, { maxOutputLength: 100 })).toMatchInlineSnapshot(` + "Array [ + Object { + "i": 0, + }, + Object { + "i": 1, + }, + Object { + "i": 2, + }, + Object { + "i": 3, + }, + [Object], + [Object], + [Object], + [Object], + [Object], + [Object], + ]" + `) + }) +})