Skip to content
54 changes: 37 additions & 17 deletions packages/pretty-format/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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: [],
Expand Down Expand Up @@ -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,
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/pretty-format/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface PrettyFormatOptions {
indent?: number
maxDepth?: number
maxWidth?: number
maxOutputLength?: number
min?: boolean
printBasicPrototype?: boolean
printFunctionName?: boolean
Expand All @@ -71,6 +72,8 @@ export interface Config {
printShadowRoot: boolean
spacingInner: string
spacingOuter: string
maxOutputLength: number
outputLength: number
}

export type Printer = (
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
187 changes: 187 additions & 0 deletions test/core/test/pretty-format.test.ts
Original file line number Diff line number Diff line change
@@ -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],
]"
`)
})
})
Loading