From 3f802da4bce90517981a403b5e38aaff6386af16 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 22 Apr 2026 13:49:32 +0900 Subject: [PATCH] refactor!: replace `loupe.inspect` with pretty-format (#9609) Co-authored-by: Claude Opus 4.6 Co-authored-by: Codex --- docs/api/test.md | 2 +- docs/config/chaiconfig.md | 4 +- docs/config/tasktitlevalueformattruncate.md | 15 + packages/browser/src/client/tester/logger.ts | 6 +- packages/expect/src/jest-expect.ts | 3 +- packages/pretty-format/USAGE.md | 5 + packages/pretty-format/src/collections.ts | 36 +- packages/pretty-format/src/index.ts | 63 +-- packages/pretty-format/src/types.ts | 55 ++- packages/runner/src/suite.ts | 13 +- packages/runner/src/types/runner.ts | 1 + packages/utils/package.json | 3 +- packages/utils/src/display.ts | 154 ++++--- packages/utils/src/index.ts | 2 +- packages/vitest/src/defaults.ts | 2 + packages/vitest/src/node/cli/cli-config.ts | 1 + .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/types/config.ts | 9 + packages/vitest/src/public/browser.ts | 3 +- packages/vitest/src/runtime/config.ts | 1 + pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 1 + test/browser/specs/runner.test.ts | 6 +- test/browser/test/utils.test.ts | 2 +- .../reporters/test-for-title-truncate.test.ts | 40 ++ test/cli/test/reporters/default.test.ts | 94 ++++ .../chai-config/test-each-title.test.ts | 22 - .../fixtures/chai-config/vitest.config.ts | 3 - test/config/test/chai-config.test.ts | 78 ---- test/core/package.json | 1 + test/core/test/exports.test.ts | 1 - test/core/test/pretty-format.test.ts | 409 +++++++++++++++++- test/core/test/utils-display.spec.ts | 6 +- test/core/test/utils.spec.ts | 15 - 34 files changed, 814 insertions(+), 252 deletions(-) create mode 100644 docs/config/tasktitlevalueformattruncate.md create mode 100644 test/cli/fixtures/reporters/test-for-title-truncate.test.ts delete mode 100644 test/config/fixtures/chai-config/test-each-title.test.ts delete mode 100644 test/config/fixtures/chai-config/vitest.config.ts delete mode 100644 test/config/test/chai-config.test.ts diff --git a/docs/api/test.md b/docs/api/test.md index 4dda739ea..18af801f2 100644 --- a/docs/api/test.md +++ b/docs/api/test.md @@ -618,7 +618,7 @@ test.each` ``` ::: tip -Vitest processes `$values` with Chai `format` method. If the value is too truncated, you can increase [chaiConfig.truncateThreshold](/config/chaiconfig#chaiconfig-truncatethreshold) in your config file. +Vitest formats interpolated title values with its display formatter. If the value is too truncated, you can increase [taskTitleValueFormatTruncate](/config/tasktitlevalueformattruncate) in your config file. ::: ## test.for diff --git a/docs/config/chaiconfig.md b/docs/config/chaiconfig.md index 24cf085af..5a6f9b55e 100644 --- a/docs/config/chaiconfig.md +++ b/docs/config/chaiconfig.md @@ -29,6 +29,4 @@ Influences whether or not the `showDiff` flag should be included in the thrown A - **Type:** `number` - **Default:** `40` -Sets length threshold for actual and expected values in assertion errors. If this threshold is exceeded, for example for large data structures, the value is replaced with something like `[ Array(3) ]` or `{ Object (prop1, prop2) }`. Set it to `0` if you want to disable truncating altogether. - -This config option affects truncating values in `test.each` titles and inside the assertion error message. +Sets length threshold for actual and expected values in assertion error messages. If this threshold is exceeded, for example for large data structures, the value is replaced with something like `[ Array(3) ]` or `{ Object (prop1, prop2) }`. Set it to `0` if you want to disable truncating altogether. diff --git a/docs/config/tasktitlevalueformattruncate.md b/docs/config/tasktitlevalueformattruncate.md new file mode 100644 index 000000000..5bb7eb885 --- /dev/null +++ b/docs/config/tasktitlevalueformattruncate.md @@ -0,0 +1,15 @@ +--- +title: taskTitleValueFormatTruncate | Config +outline: deep +--- + +# taskTitleValueFormatTruncate {#tasktitlevalueformattruncate} + +- **Type** `number` +- **Default:** `40` + +Sets the length limit for formatted values interpolated into generated task titles. + +This affects values inserted by APIs like `test.each` and `test.for`, including both `$value` and `%` placeholder formatting. + +Set it to `0` to disable truncation. diff --git a/packages/browser/src/client/tester/logger.ts b/packages/browser/src/client/tester/logger.ts index 6d497cfc0..67601c7e9 100644 --- a/packages/browser/src/client/tester/logger.ts +++ b/packages/browser/src/client/tester/logger.ts @@ -1,4 +1,4 @@ -import { browserFormat } from 'vitest/internal/browser' +import { format } from 'vitest/internal/browser' import { getConfig } from '../utils' import { rpc } from './rpc' import { getBrowserRunner } from './runner' @@ -30,7 +30,7 @@ export function setupConsoleLogSpy(): void { console.dir = (item, options) => { dir(item, options) - sendLog('stdout', browserFormat(item)) + sendLog('stdout', processLog([item])) } console.dirxml = (...args) => { @@ -114,7 +114,7 @@ function stderr(base: (...args: unknown[]) => void) { } function processLog(args: unknown[]) { - return browserFormat(...args) + return format(args, { multiline: true }) } function sendLog( diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index c0eab9773..1482b413f 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -4,6 +4,7 @@ import type { Constructable } from '@vitest/utils' import type { AsymmetricMatcher } from './jest-asymmetric-matchers' import type { Assertion, ChaiPlugin } from './types' import { isMockFunction } from '@vitest/spy' +import { inspect } from '@vitest/utils/display' import { assertTypes, ordinal } from '@vitest/utils/helpers' import c from 'tinyrainbow' import { JEST_MATCHERS_OBJECT } from './constants' @@ -483,7 +484,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { && (args.length === 1 || jestEquals(expected, value, customTesters)) const valueString - = args.length === 1 ? '' : ` with value ${utils.objDisplay(expected)}` + = args.length === 1 ? '' : ` with value ${inspect(expected, { truncate: 40 })}` return this.assert( pass, diff --git a/packages/pretty-format/USAGE.md b/packages/pretty-format/USAGE.md index 64e18a847..6db43f871 100644 --- a/packages/pretty-format/USAGE.md +++ b/packages/pretty-format/USAGE.md @@ -51,11 +51,16 @@ Object { | `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 | +| `quoteKeys` | `boolean` | `true` | Always quote object property keys | +| `singleQuote` | `boolean` | `false` | Print strings using single quotes instead of double quotes | +| `spacingInner` | `string` | `\n` | Whitespace after commas between items or entries | +| `spacingOuter` | `string` | `\n` | Whitespace just inside `[]` / `{}` delimiters | 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 +- `min: true` also changes the defaults of other options to `spacingInner: ' '`, `spacingOuter: ''`, and `printBasicPrototype: false` ## Built-in Plugins diff --git a/packages/pretty-format/src/collections.ts b/packages/pretty-format/src/collections.ts index b17e17fe8..44595ea80 100644 --- a/packages/pretty-format/src/collections.ts +++ b/packages/pretty-format/src/collections.ts @@ -21,7 +21,7 @@ function getKeysOfEnumerableProperties(object: Record, compareK } } - return keys as Array + return keys } /** @@ -37,6 +37,7 @@ export function printIteratorEntries( refs: Refs, printer: Printer, separator = ': ', + length?: number, ): string { let result = '' let width = 0 @@ -51,7 +52,7 @@ export function printIteratorEntries( result += indentationNext if (width++ === config.maxWidth) { - result += '…' + result += typeof length === 'number' ? `…(${length - width + 1})` : '…' break } @@ -100,6 +101,7 @@ export function printIteratorValues( depth: number, refs: Refs, printer: Printer, + length?: number, ): string { let result = '' let width = 0 @@ -114,7 +116,7 @@ export function printIteratorValues( result += indentationNext if (width++ === config.maxWidth) { - result += '…' + result += typeof length === 'number' ? `…(${length - width + 1})` : '…' break } @@ -163,7 +165,7 @@ export function printListItems( result += indentationNext if (i === config.maxWidth) { - result += '…' + result += `…(${length - i})` break } @@ -197,15 +199,16 @@ export function printListItems( * without surrounding punctuation (for example, braces) */ export function printObjectProperties( - val: Record, + val: Record, config: Config, indentation: string, depth: number, refs: Refs, printer: Printer, + compareKeysOverride: CompareKeys = config.compareKeys, ): string { let result = '' - const keys = getKeysOfEnumerableProperties(val, config.compareKeys) + const keys = getKeysOfEnumerableProperties(val, compareKeysOverride) if (keys.length > 0) { result += config.spacingOuter @@ -213,11 +216,20 @@ export function printObjectProperties( const indentationNext = indentation + config.indent for (let i = 0; i < keys.length; i++) { + result += indentationNext + + if (i === config.maxWidth) { + result += `…(${keys.length - i})` + break + } + const key = keys[i] - const name = printer(key, config, indentationNext, depth, refs) + const name = !config.quoteKeys && isUnquotableKey(key) + ? key as string + : printer(key, config, indentationNext, depth, refs) const value = printer(val[key], config, indentationNext, depth, refs) - result += `${indentationNext + name}: ${value}` + result += `${name}: ${value}` if (i < keys.length - 1) { result += `,${config.spacingInner}` @@ -232,3 +244,11 @@ export function printObjectProperties( return result } + +// https://github.com/nodejs/node/blob/61102cdbb3d59155ad5bb4fc9419627a31e63f7a/lib/internal/util/inspect.js#L249 +// /^[a-zA-Z_][a-zA-Z_0-9]*$/ +const keyStrRegExp = /^[a-z_]\w*$/i + +function isUnquotableKey(key: string | symbol): boolean { + return typeof key === 'string' && key !== '__proto__' && keyStrRegExp.test(key) +} diff --git a/packages/pretty-format/src/index.ts b/packages/pretty-format/src/index.ts index 542cf0630..caf37d978 100644 --- a/packages/pretty-format/src/index.ts +++ b/packages/pretty-format/src/index.ts @@ -111,6 +111,7 @@ function printBasicValue( printFunctionName: boolean, escapeRegex: boolean, escapeString: boolean, + singleQuote: boolean, ): string | null { if (val === true || val === false) { return `${val}` @@ -131,10 +132,15 @@ function printBasicValue( return printBigInt(val) } if (typeOf === 'string') { + const q = singleQuote ? '\'' : '"' if (escapeString) { - return `"${val.replaceAll(/"|\\/g, '\\$&')}"` + // escape quote in each case, e.g. + // it's me -> 'it\'s me' + // say "hi" -> "say \"hi\"" + const escapePattern = singleQuote ? /['\\]/g : /["\\]/g + return `${q}${val.replaceAll(escapePattern, '\\$&')}${q}` } - return `"${val}"` + return `${q}${val}${q}` } if (typeOf === 'function') { return printFunction(val, printFunctionName) @@ -229,11 +235,9 @@ function printComplexValue( return hitMaxDepth ? `[${val.constructor.name}]` : `${ - min + !config.printBasicPrototype && val.constructor.name === 'Array' ? '' - : !config.printBasicPrototype && val.constructor.name === 'Array' - ? '' - : `${val.constructor.name} ` + : `${val.constructor.name} ` }[${printListItems(val, config, indentation, depth, refs, printer)}]` } if (toStringed === '[object Map]') { @@ -247,6 +251,7 @@ function printComplexValue( refs, printer, ' => ', + val.size, )}}` } if (toStringed === '[object Set]') { @@ -259,6 +264,7 @@ function printComplexValue( depth, refs, printer, + val.size, )}}` } @@ -267,11 +273,9 @@ function printComplexValue( return hitMaxDepth || isWindow(val) ? `[${getConstructorName(val)}]` : `${ - min + !config.printBasicPrototype && getConstructorName(val) === 'Object' ? '' - : !config.printBasicPrototype && getConstructorName(val) === 'Object' - ? '' - : `${getConstructorName(val)} ` + : `${getConstructorName(val)} ` }{${printObjectProperties( val, config, @@ -300,13 +304,14 @@ const ErrorPlugin: NewPlugin = { const name = val.name !== 'Error' ? val.name : getConstructorName(val as any) return hitMaxDepth ? `[${name}]` - : `${name} {${printIteratorEntries( - Object.entries(entries).values(), + : `${name} {${printObjectProperties( + entries, config, indentation, depth, refs, printer, + null, )}}` }, } @@ -392,6 +397,7 @@ function printer( config.printFunctionName, config.escapeRegex, config.escapeString, + config.singleQuote, ) if (basicResult !== null) { result = basicResult @@ -453,6 +459,10 @@ export const DEFAULT_OPTIONS: Options = { printFunctionName: true, printShadowRoot: true, theme: DEFAULT_THEME, + singleQuote: false, + quoteKeys: true, + spacingInner: '\n', + spacingOuter: '\n', } satisfies Options function validateOptions(options: OptionsReceived) { @@ -525,11 +535,13 @@ function getConfig(options?: OptionsReceived): Config { maxWidth: options?.maxWidth ?? DEFAULT_OPTIONS.maxWidth, min: options?.min ?? DEFAULT_OPTIONS.min, plugins: options?.plugins ?? DEFAULT_OPTIONS.plugins, - printBasicPrototype: options?.printBasicPrototype ?? true, + printBasicPrototype: options?.printBasicPrototype ?? !options?.min, printFunctionName: getPrintFunctionName(options), printShadowRoot: options?.printShadowRoot ?? true, - spacingInner: options?.min ? ' ' : '\n', - spacingOuter: options?.min ? '' : '\n', + spacingInner: options?.spacingInner ?? (options?.min ? ' ' : '\n'), + spacingOuter: options?.spacingOuter ?? (options?.min ? '' : '\n'), + singleQuote: options?.singleQuote ?? DEFAULT_OPTIONS.singleQuote, + quoteKeys: options?.quoteKeys ?? DEFAULT_OPTIONS.quoteKeys, maxOutputLength: options?.maxOutputLength ?? DEFAULT_OPTIONS.maxOutputLength, _outputLengthPerDepth: [], } @@ -547,25 +559,26 @@ function createIndent(indent: number): string { export function format(val: unknown, options?: OptionsReceived): string { if (options) { validateOptions(options) - if (options.plugins) { - const plugin = findPlugin(options.plugins, val) - if (plugin !== null) { - return printPlugin(plugin, val, getConfig(options), '', 0, []) - } - } + } + + const config = getConfig(options) + const plugin = findPlugin(config.plugins, val) + if (plugin !== null) { + return printPlugin(plugin, val, config, '', 0, []) } const basicResult = printBasicValue( val, - getPrintFunctionName(options), - getEscapeRegex(options), - getEscapeString(options), + config.printFunctionName, + config.escapeRegex, + config.escapeString, + config.singleQuote, ) if (basicResult !== null) { return basicResult } - return printComplexValue(val, getConfig(options), '', 0, []) + return printComplexValue(val, config, '', 0, []) } export type { diff --git a/packages/pretty-format/src/types.ts b/packages/pretty-format/src/types.ts index dfaea1c85..59dc06e3f 100644 --- a/packages/pretty-format/src/types.ts +++ b/packages/pretty-format/src/types.ts @@ -84,12 +84,19 @@ export interface PrettyFormatOptions { maxOutputLength?: number /** * Whether to minimize added whitespace, including indentation and line breaks. + * + * When `true`, pretty-format defaults `spacingInner` to `' '`, `spacingOuter` to `''`, + * and ignores indentation. It also changes the default for `printBasicPrototype` + * from `true` to `false`, although an explicit `printBasicPrototype` still wins. + * Explicit `spacingInner` / `spacingOuter` overrides still apply. * @default false */ min?: boolean /** * Whether to print `Object` / `Array` prefixes for plain objects and arrays. - * @default true + * + * Defaults to `true`, unless `min` is `true`, in which case it defaults to `false`. + * An explicit `printBasicPrototype` value always overrides the `min` default. */ printBasicPrototype?: boolean /** @@ -111,6 +118,50 @@ export interface PrettyFormatOptions { * @default [] */ plugins?: Plugins + /** + * Whitespace inserted after commas between items or entries. + * + * For example, in `{a: 1, b: 2}` or `[1, 2]`, this controls the gap after each comma: + * `{a: 1,${spacingInner}b: 2}` + * `[1,${spacingInner}2]` + * + * Defaults to `'\n'` in regular mode and `' '` when `min` is `true`. + * Can be overridden independently of `min`. + */ + spacingInner?: string + /** + * Whitespace inserted immediately inside collection/object delimiters. + * + * For example, this controls the space or newline right after the opening delimiter + * and right before the closing delimiter: + * `{${spacingOuter}a: 1${spacingOuter}}` + * `[${spacingOuter}1${spacingOuter}]` + * + * Defaults to `'\n'` in regular mode and `''` when `min` is `true`. + * Can be overridden independently of `min`. + */ + spacingOuter?: string + /** + * Whether to print strings using single quotes instead of double quotes. + * + * For example: + * `"hello"` when `false` + * `'hello'` when `true` + * + * @default false + */ + singleQuote?: boolean + /** + * Whether to always quote object property keys. + * + * For example: + * `{"a": 1}` when `true` + * `{a: 1}` when `false` and the key is a valid identifier + * `{"my-key": 1}` still stays quoted because it is not a valid identifier + * + * @default true + */ + quoteKeys?: boolean } export type OptionsReceived = PrettyFormatOptions @@ -131,6 +182,8 @@ export interface Config { printShadowRoot: boolean spacingInner: string spacingOuter: string + singleQuote: boolean + quoteKeys: boolean maxOutputLength: number /** * Per-depth budget accumulator for {@link maxOutputLength}. diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index e5caa1e75..9e737d17d 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -1,3 +1,4 @@ +import type { InspectOptions } from '@vitest/utils/display' import type { UserFixtures } from './fixture' import type { VitestRunner } from './types/runner' import type { @@ -18,7 +19,7 @@ import type { TestFunction, TestOptions, } from './types/tasks' -import { format, formatRegExp, objDisplay } from '@vitest/utils/display' +import { format, formatRegExp, inspect } from '@vitest/utils/display' import { isNegativeNaN, isObject, @@ -1019,6 +1020,10 @@ function formatTitle(template: string, items: any[], idx: number) { }) } + const inspectOptions: InspectOptions = { + truncate: runner.config.taskTitleValueFormatTruncate, + } + const isObjectItem = isObject(items[0]) function formatAttribute(s: string) { return s.replace(/\$([$\w.]+)/g, (_, key: string) => { @@ -1028,9 +1033,7 @@ function formatTitle(template: string, items: any[], idx: number) { } const arrayElement = isArrayKey ? objectAttr(items, key) : undefined const value = isObjectItem ? objectAttr(items[0], key, arrayElement) : arrayElement - return objDisplay(value, { - truncate: runner?.config?.chaiConfig?.truncateThreshold, - }) + return inspect(value, inspectOptions) }) } @@ -1042,7 +1045,7 @@ function formatTitle(template: string, items: any[], idx: number) { // format "%" (match) => { if (i < count) { - output += format(match[0], items[i++]) + output += format([match[0], items[i++]], inspectOptions) } else { output += match[0] diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index d05ab8056..1a0be58f7 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -36,6 +36,7 @@ export interface VitestRunnerConfig { chaiConfig: { truncateThreshold?: number } | undefined + taskTitleValueFormatTruncate: number | undefined maxConcurrency: number testTimeout: number hookTimeout: number diff --git a/packages/utils/package.json b/packages/utils/package.json index 7d78661fb..47423be00 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -96,7 +96,6 @@ "@jridgewell/trace-mapping": "catalog:", "@types/convert-source-map": "^2.0.3", "@types/estree": "catalog:", - "diff-sequences": "^29.6.3", - "loupe": "^3.2.1" + "diff-sequences": "^29.6.3" } } diff --git a/packages/utils/src/display.ts b/packages/utils/src/display.ts index 574da5667..2d2b65c2c 100644 --- a/packages/utils/src/display.ts +++ b/packages/utils/src/display.ts @@ -4,24 +4,6 @@ import { format as prettyFormat, plugins as prettyFormatPlugins, } from '@vitest/pretty-format' -import * as loupe from 'loupe' - -type Inspect = (value: unknown, options: Options) => string -interface Options { - showHidden: boolean - depth: number - colors: boolean - customInspect: boolean - showProxy: boolean - maxArrayLength: number - breakLength: number - truncate: number - seen: unknown[] - inspect: Inspect - stylize: (value: string, styleType: string) => string -} - -export type LoupeOptions = Partial const { AsymmetricMatcher, @@ -122,25 +104,13 @@ function createNodeFilterFromSelector(selector: string): (node: any) => boolean export const formatRegExp: RegExp = /%[sdjifoOc%]/g -interface FormatOptions { - prettifyObject?: boolean -} - -function baseFormat(args: unknown[], options: FormatOptions = {}): string { - const formatArg = (item: unknown, inspecOptions?: LoupeOptions) => { - if (options.prettifyObject) { - return stringify(item, undefined, { - printBasicPrototype: false, - escapeString: false, - }) - } - return inspect(item, inspecOptions) - } +export function format(args: unknown[], options: InspectOptions = {}): string { + const formatArg = (item: unknown) => inspect(item, options) if (typeof args[0] !== 'string') { const objects = [] for (let i = 0; i < args.length; i++) { - objects.push(formatArg(args[i], { depth: 0, colors: false })) + objects.push(formatArg(args[i])) } return objects.join(' ') } @@ -168,7 +138,7 @@ function baseFormat(args: unknown[], options: FormatOptions = {}): string { if (typeof value.toString === 'function' && value.toString !== Object.prototype.toString) { return value.toString() } - return formatArg(value, { depth: 0, colors: false }) + return formatArg(value) } return String(value) } @@ -192,7 +162,6 @@ function baseFormat(args: unknown[], options: FormatOptions = {}): string { case '%f': return Number.parseFloat(String(args[i++])).toString() case '%o': - return formatArg(args[i++], { showHidden: true, showProxy: true }) case '%O': return formatArg(args[i++]) case '%c': { @@ -233,47 +202,100 @@ function baseFormat(args: unknown[], options: FormatOptions = {}): string { return str } -export function format(...args: unknown[]): string { - return baseFormat(args) +export interface InspectOptions extends StringifyOptions { + truncate?: number + multiline?: boolean } -export function browserFormat(...args: unknown[]): string { - return baseFormat(args, { prettifyObject: true }) -} +export function inspect( + obj: unknown, + options?: InspectOptions, +): string { + const { truncate, multiline, ...stringifyOptions } = options ?? {} + const prettyFormatOptions: PrettyFormatOptions = { + singleQuote: true, + quoteKeys: false, + min: true, + spacingInner: ' ', + spacingOuter: ' ', + printBasicPrototype: false, + compareKeys: null, + ...(multiline ? { min: false, spacingInner: undefined, spacingOuter: undefined } : {}), + } + const threshold = truncate ?? 0 + const formatted = stringify(obj, undefined, { + ...prettyFormatOptions, + ...stringifyOptions, + maxLength: threshold || undefined, + }) -export function inspect(obj: unknown, options: LoupeOptions = {}): string { - if (options.truncate === 0) { - options.truncate = Number.POSITIVE_INFINITY + if (threshold === 0 || formatted.length <= threshold) { + return formatted } - return loupe.inspect(obj, options) + + // if stringify's adaptive maxDepth (down to 1) fails to truncate enough, + // - for known types (e.g. string, object, array, etc), apply best effort truncation. + // - for other values, fallback to maxDepth = 0 which should can show minimal output. + + const type = Object.prototype.toString.call(obj) + if (typeof obj === 'string') { + let end = threshold - 1 + if (end > 0 && isHighSurrogate(formatted[end - 1])) { + end = end - 1 + } + return `'${formatted.slice(1, end)}…'` + } + if ( + type === '[object Array]' + || type === '[object Object]' + || type === '[object Set]' + || type === '[object Map]' + ) { + return stringifyByMaxWidth(obj, threshold, { + ...prettyFormatOptions, + ...stringifyOptions, + maxDepth: 1, + }) + } + + return stringify(obj, undefined, { + ...prettyFormatOptions, + ...stringifyOptions, + maxDepth: 0, + }) } -export function objDisplay(obj: unknown, options: LoupeOptions = {}): string { - if (typeof options.truncate === 'undefined') { - options.truncate = 40 +function stringifyByMaxWidth(object: unknown, threshold: number, options: StringifyOptions): string { + function evaluate(x: number) { + return stringify(object, undefined, { + ...options, + maxWidth: x, + }) } - const str = inspect(obj, options) - const type = Object.prototype.toString.call(obj) + const opt = binarySearch( + 0, + threshold, + x => evaluate(x).length <= threshold, + ) + return evaluate(opt) +} - if (options.truncate && str.length >= options.truncate) { - if (type === '[object Function]') { - const fn = obj as () => void - return !fn.name ? '[Function]' : `[Function: ${fn.name}]` - } - else if (type === '[object Array]') { - return `[ Array(${(obj as []).length}) ]` - } - else if (type === '[object Object]') { - const keys = Object.keys(obj as object) - const kstr - = keys.length > 2 - ? `${keys.splice(0, 2).join(', ')}, ...` - : keys.join(', ') - return `{ Object (${kstr}) }` +// find max(x \in [x, y) | f(x) = true) +// if f(x0) is false, then returns x0. +function binarySearch(x0: number, x1: number, f: (x: number) => boolean): number { + while (x0 + 1 < x1) { + const x = Math.floor((x0 + x1) / 2) + if (f(x)) { + x0 = x } else { - return str + x1 = x } } - return str + return x0 +} + +// https://github.com/chaijs/loupe/pull/79 +function isHighSurrogate(char: string): boolean { + return char >= '\uD800' && char <= '\uDBFF' } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a9c0a549f..ec8ffc24a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,4 @@ -export type { LoupeOptions, StringifyOptions } from './display' +export type { StringifyOptions } from './display' export type { DeferPromise } from './helpers' export type { SafeTimers } from './timers' export type { diff --git a/packages/vitest/src/defaults.ts b/packages/vitest/src/defaults.ts index 4f06742b3..7471d73cc 100644 --- a/packages/vitest/src/defaults.ts +++ b/packages/vitest/src/defaults.ts @@ -96,6 +96,7 @@ export const configDefaults: Readonly<{ exclude: string[] } slowTestThreshold: number + taskTitleValueFormatTruncate: number disableConsoleIntercept: boolean detectAsyncLeaks: boolean }> = Object.freeze({ @@ -134,6 +135,7 @@ export const configDefaults: Readonly<{ exclude: defaultExclude, }, slowTestThreshold: 300, + taskTitleValueFormatTruncate: 40, disableConsoleIntercept: false, detectAsyncLeaks: false, }) diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index daa83ffdc..e30ec47fe 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -963,6 +963,7 @@ export const cliOptionsConfig: VitestCLIOptions = { projects: null, watchTriggerPatterns: null, tags: null, + taskTitleValueFormatTruncate: null, } export const benchCliOptionsConfig: Pick< diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index c49676612..5943e738a 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -21,6 +21,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { bail: config.bail, defines: config.defines, chaiConfig: config.chaiConfig, + taskTitleValueFormatTruncate: config.taskTitleValueFormatTruncate, setupFiles: config.setupFiles, allowOnly: config.allowOnly, testTimeout: config.testTimeout, diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 7fad9efd8..f61409396 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -800,6 +800,15 @@ export interface InlineConfig { */ chaiConfig?: ChaiConfig + /** + * Sets length limit for formatted values interpolated into generated task titles. + * + * This affects values inserted by APIs like `test.each` and `test.for`. + * + * @default 40 + */ + taskTitleValueFormatTruncate?: number + /** * Stop test execution when given number of tests have failed. */ diff --git a/packages/vitest/src/public/browser.ts b/packages/vitest/src/public/browser.ts index c0e5316e2..3405142e6 100644 --- a/packages/vitest/src/public/browser.ts +++ b/packages/vitest/src/public/browser.ts @@ -11,9 +11,8 @@ export { export { type OTELCarrier, Traces } from '../utils/traces' export { collectTests, startTests } from '@vitest/runner' export * as SpyModule from '@vitest/spy' -export type { LoupeOptions, ParsedStack, StringifyOptions } from '@vitest/utils' +export type { ParsedStack, StringifyOptions } from '@vitest/utils' export { - browserFormat, format, inspect, stringify, diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 27bdb2b75..0cf9ffd23 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -78,6 +78,7 @@ export interface SerializedConfig { showDiff?: boolean truncateThreshold?: number } | undefined + taskTitleValueFormatTruncate: number | undefined api: { allowExec: boolean | undefined allowWrite: boolean | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d2c401c4..bd2dc3dbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ catalogs: istanbul-reports: specifier: ^3.2.0 version: 3.2.0 + loupe: + specifier: ^3.2.1 + version: 3.2.1 magic-string: specifier: ^0.30.21 version: 0.30.21 @@ -1005,9 +1008,6 @@ importers: diff-sequences: specifier: ^29.6.3 version: 29.6.3 - loupe: - specifier: ^3.2.1 - version: 3.2.1 packages/vitest: dependencies: @@ -1515,6 +1515,9 @@ importers: immutable: specifier: 5.1.5 version: 5.1.5 + loupe: + specifier: 'catalog:' + version: 3.2.1 memfs: specifier: ^4.56.11 version: 4.56.11(tslib@2.8.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 24d4e6d10..8b8a03cb0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -74,6 +74,7 @@ catalog: istanbul-lib-report: ^3.0.1 istanbul-lib-source-maps: ^5.0.6 istanbul-reports: ^3.2.0 + loupe: ^3.2.1 magic-string: ^0.30.21 magicast: ^0.5.2 msw: ^2.12.10 diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 5808d9e98..c37a79258 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -139,12 +139,12 @@ describe('console logging tests', async () => { expect(stdout).toContain('hello from console.debug') expect(stdout).toContain(` { - "hello": "from dir", + hello: 'from dir', } `.trim()) expect(stdout).toContain(` { - "hello": "from dirxml", + hello: 'from dirxml', } `.trim()) expect(stdout).toContain('dom
') @@ -160,7 +160,7 @@ describe('console logging tests', async () => { expect(stdout).not.toContain('[console-time-fake]: 0 ms') expect(stdout).toContain('hello from one') expect(stdout).toContain(`hello from two { - "hello": "object", + hello: 'object', }`) }) diff --git a/test/browser/test/utils.test.ts b/test/browser/test/utils.test.ts index f0ca2a356..b4099511d 100644 --- a/test/browser/test/utils.test.ts +++ b/test/browser/test/utils.test.ts @@ -11,7 +11,7 @@ beforeEach(() => { utils.configurePrettyDOM({}) }) -it('utils package correctly uses loupe', async () => { +it('utils package correctly uses inspect', async () => { expect(inspect({ test: 1 })).toBe('{ test: 1 }') }) diff --git a/test/cli/fixtures/reporters/test-for-title-truncate.test.ts b/test/cli/fixtures/reporters/test-for-title-truncate.test.ts new file mode 100644 index 000000000..3f355e181 --- /dev/null +++ b/test/cli/fixtures/reporters/test-for-title-truncate.test.ts @@ -0,0 +1,40 @@ +import { test } from 'vitest' + +test.for` + length | param + ${30} | ${'0123456789'.repeat(3)} + ${40} | ${'0123456789'.repeat(4)} + ${50} | ${'0123456789'.repeat(5)} +`('$ (string: $length) $param', () => {}); + +test.for` + length | param + ${3} | ${['one', 'two', 'three']} + ${4} | ${['one', 'two', 'three', 'four']} + ${5} | ${['one', 'two', 'three', 'four', 'five']} +`('$ (array: $length) $param', () => {}); + +test.for` + length | param + ${3} | ${{ one: 1, two: 2, three: 3 }} + ${4} | ${{ one: 1, two: 2, three: 3, four: 4 }} + ${5} | ${{ one: 1, two: 2, three: 3, four: 4, five: 5 }} +`('$ (object: $length) $param', () => {}); + +test.for([ + [30, '0123456789'.repeat(3)], + [40, '0123456789'.repeat(4)], + [50, '0123456789'.repeat(5)], +])(`% (string: %d) %o`, () => {}) + +test.for([ + [3, ['one', 'two', 'three']], + [4, ['one', 'two', 'three', 'four']], + [5, ['one', 'two', 'three', 'four', 'five']], +])(`% (array: %d) %o`, () => {}) + +test.for([ + [3, { one: 1, two: 2, three: 3 }], + [4, { one: 1, two: 2, three: 3, four: 4 }], + [5, { one: 1, two: 2, three: 3, four: 4, five: 5 }], +])(`% (object: %d) %o`, () => {}) diff --git a/test/cli/test/reporters/default.test.ts b/test/cli/test/reporters/default.test.ts index 8fa8c1088..9037b097e 100644 --- a/test/cli/test/reporters/default.test.ts +++ b/test/cli/test/reporters/default.test.ts @@ -267,6 +267,100 @@ describe('default reporter', async () => { `) }) + test('test.each/for title truncate', async () => { + // default (40) + let result = await runVitest({ + include: ['fixtures/reporters/test-for-title-truncate.test.ts'], + config: false, + }) + expect(result.errorTree()).toMatchInlineSnapshot(` + { + "fixtures/reporters/test-for-title-truncate.test.ts": { + "$ (array: 3) [ 'one', 'two', 'three' ]": "passed", + "$ (array: 4) [ 'one', 'two', 'three', 'four' ]": "passed", + "$ (array: 5) [ 'one', 'two', 'three', 'four', …(1) ]": "passed", + "$ (object: 3) { one: 1, two: 2, three: 3 }": "passed", + "$ (object: 4) { one: 1, two: 2, three: 3, four: 4 }": "passed", + "$ (object: 5) { one: 1, two: 2, three: 3, …(2) }": "passed", + "$ (string: 30) '012345678901234567890123456789'": "passed", + "$ (string: 40) '01234567890123456789012345678901234567…'": "passed", + "$ (string: 50) '01234567890123456789012345678901234567…'": "passed", + "% (array: 3) [ 'one', 'two', 'three' ]": "passed", + "% (array: 4) [ 'one', 'two', 'three', 'four' ]": "passed", + "% (array: 5) [ 'one', 'two', 'three', 'four', …(1) ]": "passed", + "% (object: 3) { one: 1, two: 2, three: 3 }": "passed", + "% (object: 4) { one: 1, two: 2, three: 3, four: 4 }": "passed", + "% (object: 5) { one: 1, two: 2, three: 3, …(2) }": "passed", + "% (string: 30) '012345678901234567890123456789'": "passed", + "% (string: 40) '01234567890123456789012345678901234567…'": "passed", + "% (string: 50) '01234567890123456789012345678901234567…'": "passed", + }, + } + `) + + // 20 + result = await runVitest({ + include: ['fixtures/reporters/test-for-title-truncate.test.ts'], + config: false, + taskTitleValueFormatTruncate: 20, + }) + expect(result.errorTree()).toMatchInlineSnapshot(` + { + "fixtures/reporters/test-for-title-truncate.test.ts": { + "$ (array: 3) [ 'one', …(2) ]": "passed", + "$ (array: 4) [ 'one', …(3) ]": "passed", + "$ (array: 5) [ 'one', …(4) ]": "passed", + "$ (object: 3) { one: 1, …(2) }": "passed", + "$ (object: 4) { one: 1, …(3) }": "passed", + "$ (object: 5) { one: 1, …(4) }": "passed", + "$ (string: 30) '012345678901234567…'": "passed", + "$ (string: 40) '012345678901234567…'": "passed", + "$ (string: 50) '012345678901234567…'": "passed", + "% (array: 3) [ 'one', …(2) ]": "passed", + "% (array: 4) [ 'one', …(3) ]": "passed", + "% (array: 5) [ 'one', …(4) ]": "passed", + "% (object: 3) { one: 1, …(2) }": "passed", + "% (object: 4) { one: 1, …(3) }": "passed", + "% (object: 5) { one: 1, …(4) }": "passed", + "% (string: 30) '012345678901234567…'": "passed", + "% (string: 40) '012345678901234567…'": "passed", + "% (string: 50) '012345678901234567…'": "passed", + }, + } + `) + + // no truncate + result = await runVitest({ + include: ['fixtures/reporters/test-for-title-truncate.test.ts'], + config: false, + taskTitleValueFormatTruncate: 0, + }) + expect(result.errorTree()).toMatchInlineSnapshot(` + { + "fixtures/reporters/test-for-title-truncate.test.ts": { + "$ (array: 3) [ 'one', 'two', 'three' ]": "passed", + "$ (array: 4) [ 'one', 'two', 'three', 'four' ]": "passed", + "$ (array: 5) [ 'one', 'two', 'three', 'four', 'five' ]": "passed", + "$ (object: 3) { one: 1, two: 2, three: 3 }": "passed", + "$ (object: 4) { one: 1, two: 2, three: 3, four: 4 }": "passed", + "$ (object: 5) { one: 1, two: 2, three: 3, four: 4, five: 5 }": "passed", + "$ (string: 30) '012345678901234567890123456789'": "passed", + "$ (string: 40) '0123456789012345678901234567890123456789'": "passed", + "$ (string: 50) '01234567890123456789012345678901234567890123456789'": "passed", + "% (array: 3) [ 'one', 'two', 'three' ]": "passed", + "% (array: 4) [ 'one', 'two', 'three', 'four' ]": "passed", + "% (array: 5) [ 'one', 'two', 'three', 'four', 'five' ]": "passed", + "% (object: 3) { one: 1, two: 2, three: 3 }": "passed", + "% (object: 4) { one: 1, two: 2, three: 3, four: 4 }": "passed", + "% (object: 5) { one: 1, two: 2, three: 3, four: 4, five: 5 }": "passed", + "% (string: 30) '012345678901234567890123456789'": "passed", + "% (string: 40) '0123456789012345678901234567890123456789'": "passed", + "% (string: 50) '01234567890123456789012345678901234567890123456789'": "passed", + }, + } + `) + }) + test('project name color', async () => { const { stdout } = await runVitestCli( { preserveAnsi: true }, diff --git a/test/config/fixtures/chai-config/test-each-title.test.ts b/test/config/fixtures/chai-config/test-each-title.test.ts deleted file mode 100644 index 42750f67c..000000000 --- a/test/config/fixtures/chai-config/test-each-title.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from 'vitest' - -test.each` - length | param - ${30} | ${'0123456789'.repeat(3)} - ${40} | ${'0123456789'.repeat(4)} - ${50} | ${'0123456789'.repeat(5)} -`('$param (length = $length)', () => {}); - -test.each` - param - ${['one', 'two', 'three']} - ${['one', 'two', 'three', 'four']} - ${['one', 'two', 'three', 'four', 'five']} -`('$param', () => {}); - -test.each` - param - ${{ one: 1, two: 2, three: 3 }} - ${{ one: 1, two: 2, three: 3, four: 4 }} - ${{ one: 1, two: 2, three: 3, four: 4, five: 5 }} -`('$param', () => {}); diff --git a/test/config/fixtures/chai-config/vitest.config.ts b/test/config/fixtures/chai-config/vitest.config.ts deleted file mode 100644 index abed6b211..000000000 --- a/test/config/fixtures/chai-config/vitest.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({}) diff --git a/test/config/test/chai-config.test.ts b/test/config/test/chai-config.test.ts deleted file mode 100644 index 08c4f471d..000000000 --- a/test/config/test/chai-config.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { runVitest } from '../../test-utils' - -describe('truncateThreshold', () => { - it('default', async () => { - const result = await runVitest({ - root: 'fixtures/chai-config', - reporters: ['tap-flat'], - }) - expect(cleanOutput(result.stdout)).toMatchInlineSnapshot(` - "TAP version 13 - 1..9 - ok 1 - test-each-title.test.ts > '012345678901234567890123456789' (length = 30) - ok 2 - test-each-title.test.ts > '0123456789012345678901234567890123456…' (length = 40) - ok 3 - test-each-title.test.ts > '0123456789012345678901234567890123456…' (length = 50) - ok 4 - test-each-title.test.ts > [ 'one', 'two', 'three' ] - ok 5 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four' ] - ok 6 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four', …(1) ] - ok 7 - test-each-title.test.ts > { one: 1, two: 2, three: 3 } - ok 8 - test-each-title.test.ts > { one: 1, two: 2, three: 3, four: 4 } - ok 9 - test-each-title.test.ts > { one: 1, two: 2, three: 3, …(2) }" - `) - expect(result.exitCode).toBe(0) - }) - - it('40', async () => { - const result = await runVitest({ - root: 'fixtures/chai-config', - reporters: ['tap-flat'], - chaiConfig: { - truncateThreshold: 40, - }, - }) - expect(cleanOutput(result.stdout)).toMatchInlineSnapshot(` - "TAP version 13 - 1..9 - ok 1 - test-each-title.test.ts > '012345678901234567890123456789' (length = 30) - ok 2 - test-each-title.test.ts > '0123456789012345678901234567890123456…' (length = 40) - ok 3 - test-each-title.test.ts > '0123456789012345678901234567890123456…' (length = 50) - ok 4 - test-each-title.test.ts > [ 'one', 'two', 'three' ] - ok 5 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four' ] - ok 6 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four', …(1) ] - ok 7 - test-each-title.test.ts > { one: 1, two: 2, three: 3 } - ok 8 - test-each-title.test.ts > { one: 1, two: 2, three: 3, four: 4 } - ok 9 - test-each-title.test.ts > { one: 1, two: 2, three: 3, …(2) }" - `) - expect(result.exitCode).toBe(0) - }) - - it('0', async () => { - const result = await runVitest({ - root: 'fixtures/chai-config', - reporters: ['tap-flat'], - chaiConfig: { - truncateThreshold: 0, - }, - }) - expect(cleanOutput(result.stdout)).toMatchInlineSnapshot(` - "TAP version 13 - 1..9 - ok 1 - test-each-title.test.ts > '012345678901234567890123456789' (length = 30) - ok 2 - test-each-title.test.ts > '0123456789012345678901234567890123456789' (length = 40) - ok 3 - test-each-title.test.ts > '01234567890123456789012345678901234567890123456789' (length = 50) - ok 4 - test-each-title.test.ts > [ 'one', 'two', 'three' ] - ok 5 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four' ] - ok 6 - test-each-title.test.ts > [ 'one', 'two', 'three', 'four', 'five' ] - ok 7 - test-each-title.test.ts > { one: 1, two: 2, three: 3 } - ok 8 - test-each-title.test.ts > { one: 1, two: 2, three: 3, four: 4 } - ok 9 - test-each-title.test.ts > { one: 1, two: 2, three: 3, four: 4, five: 5 }" - `) - expect(result.exitCode).toBe(0) - }) -}) - -function cleanOutput(output: string) { - // remove non-deterministic output - return output.replaceAll(/\s*# time=.*/g, '').trim() -} diff --git a/test/core/package.json b/test/core/package.json index 75d99e282..18538abd2 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -34,6 +34,7 @@ "@vueuse/integrations": "^14.2.1", "axios": "^1.13.4", "immutable": "5.1.5", + "loupe": "catalog:", "memfs": "^4.56.11", "obug": "^2.1.1", "react": "^19.2.4", diff --git a/test/core/test/exports.test.ts b/test/core/test/exports.test.ts index f294229c8..7a365ed24 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -68,7 +68,6 @@ it('exports snapshot', async ({ skip, task }) => { "SpyModule": "object", "Traces": "function", "__INTERNAL": "object", - "browserFormat": "function", "collectTests": "function", "format": "function", "getOriginalPosition": "function", diff --git a/test/core/test/pretty-format.test.ts b/test/core/test/pretty-format.test.ts index 950a1cc97..38ec10c0f 100644 --- a/test/core/test/pretty-format.test.ts +++ b/test/core/test/pretty-format.test.ts @@ -1,4 +1,7 @@ +import { inspect as nodeInspect } from 'node:util' import { format, plugins } from '@vitest/pretty-format' +import { inspect as prettyInspect } from '@vitest/utils/display' +import { inspect as loupeInspect } from 'loupe' import { describe, expect, test } from 'vitest' describe('maxOutputLength', () => { @@ -707,6 +710,18 @@ describe('maxDepth option', () => { }) describe('maxWidth option', () => { + test('object', () => { + const input = { one: 1, two: 2, three: 3, four: 4, five: 5 } + expect(format(input, { maxWidth: 3, compareKeys: null })).toMatchInlineSnapshot(` + "Object { + "one": 1, + "two": 2, + "three": 3, + …(2) + }" + `) + }) + test('truncates arrays', () => { expect(format([1, 2, 3, 4, 5], { maxWidth: 3 })).toMatchInlineSnapshot( ` @@ -714,7 +729,7 @@ describe('maxWidth option', () => { 1, 2, 3, - … + …(2) ]" `, ) @@ -727,7 +742,7 @@ describe('maxWidth option', () => { 1, 2, 3, - … + …(2) }" `, ) @@ -740,7 +755,7 @@ describe('maxWidth option', () => { "Map { "a" => 1, "b" => 2, - … + …(2) }" `, ) @@ -886,6 +901,104 @@ describe('escapeRegex option', () => { }) }) +describe('singleQuote option', () => { + test('uses double quotes by default', () => { + expect(format('hello')).toMatchInlineSnapshot(`""hello""`) + }) + + test('uses single quotes when true', () => { + expect(format('hello', { singleQuote: true })).toMatchInlineSnapshot(`"'hello'"`) + }) + + test('escapes single quotes inside string when singleQuote + escapeString', () => { + expect(format('it\'s', { singleQuote: true })).toMatchInlineSnapshot(`"'it\\'s'"`) + }) + + test('escapes backslash when singleQuote + escapeString', () => { + expect(format('a\\b', { singleQuote: true })).toMatchInlineSnapshot(`"'a\\\\b'"`) + }) + + test('does not escape double quotes when singleQuote', () => { + expect(format('say "hi"', { singleQuote: true })).toMatchInlineSnapshot(`"'say "hi"'"`) + }) + + test('applies to object values', () => { + expect(format({ a: 'b' }, { singleQuote: true, min: true })).toMatchInlineSnapshot( + `"{'a': 'b'}"`, + ) + }) + + test('applies to Map keys and values', () => { + expect(format(new Map([['k', 'v']]), { singleQuote: true, min: true })).toMatchInlineSnapshot( + `"Map {'k' => 'v'}"`, + ) + }) +}) + +describe('quoteKeys option', () => { + test('forced quote', () => { + const input = { + '': 0, + '$a': 0, + '0a': 0, + 'a$': 0, + 'a-b': 0, + } + expect(format(input, { quoteKeys: false })).toMatchInlineSnapshot(` + "Object { + "": 0, + "$a": 0, + "0a": 0, + "a$": 0, + "a-b": 0, + }" + `) + }) + + test('no quote', () => { + const input = { + a: 0, + a0: 0, + a_b: 0, + } + expect(format(input, { quoteKeys: false })).toMatchInlineSnapshot(` + "Object { + a: 0, + a0: 0, + a_b: 0, + }" + `) + }) + + test('prototype', () => { + const input = Object.create(null) + // eslint-disable-next-line + input.__proto__ = 0 + expect(format(input, { quoteKeys: false })).toMatchInlineSnapshot(` + "Object { + "__proto__": 0, + }" + `) + }) +}) + +describe('spacingInner / spacingOuter options', () => { + test('min: true defaults', () => { + // min: true → spacingInner: ' ', spacingOuter: '' + expect(format({ a: 1, b: 2 }, { min: true })).toMatchInlineSnapshot(`"{"a": 1, "b": 2}"`) + }) + + test('spacingOuter override with min: true', () => { + // override spacingOuter to add space around braces (loupe-like) + expect(format({ a: 1 }, { min: true, spacingOuter: ' ' })).toMatchInlineSnapshot(`"{ "a": 1 }"`) + }) + + test('spacingInner override', () => { + // min: true still places comma before spacingInner + expect(format([1, 2], { min: true, spacingInner: ' | ' })).toMatchInlineSnapshot(`"[1, | 2]"`) + }) +}) + describe('ErrorPlugin', () => { test('Error with message', () => { const err = new Error('boom') @@ -990,8 +1103,6 @@ describe('plugins', () => { }) }) -// -- validation -- - describe('validation', () => { test('throws on unknown option', () => { expect(() => { @@ -1000,3 +1111,291 @@ describe('validation', () => { }).toThrowErrorMatchingInlineSnapshot(`[Error: pretty-format: Unknown option "badOption".]`) }) }) + +// -- prettyInspect -- + +describe('prettyInspect', () => { + test('no truncation by default (truncate: 0)', () => { + const long = 'a'.repeat(200) + expect(prettyInspect(long)).toBe(`'${long}'`) + expect(prettyInspect(long, { truncate: 0 })).toBe(`'${long}'`) + }) + + test('no truncation when value fits within threshold', () => { + expect(prettyInspect('short', { truncate: 100 })).toMatchInlineSnapshot(`"'short'"`) + }) + + test('truncates string', () => { + const long = '0123456789012345678901234567890123456789' + expect(prettyInspect(long, { truncate: 20 })).toMatchInlineSnapshot(`"'012345678901234567…'"`) + }) + + test('truncates surragete pair correctly', () => { + expect(prettyInspect('😀'.repeat(5), { truncate: 14 })).toMatchInlineSnapshot(`"'😀😀😀😀😀'"`) + expect(prettyInspect('😀'.repeat(6), { truncate: 14 })).toMatchInlineSnapshot(`"'😀😀😀😀😀😀'"`) + expect(prettyInspect('😀'.repeat(7), { truncate: 14 })).toMatchInlineSnapshot(`"'😀😀😀😀😀😀…'"`) + expect(prettyInspect('😀'.repeat(8), { truncate: 14 })).toMatchInlineSnapshot(`"'😀😀😀😀😀😀…'"`) + expect(prettyInspect(`a${'😀'.repeat(5)}`, { truncate: 14 })).toMatchInlineSnapshot(`"'a😀😀😀😀😀'"`) + expect(prettyInspect(`a${'😀'.repeat(6)}`, { truncate: 14 })).toMatchInlineSnapshot(`"'a😀😀😀😀😀…'"`) + expect(prettyInspect(`a${'😀'.repeat(7)}`, { truncate: 14 })).toMatchInlineSnapshot(`"'a😀😀😀😀😀…'"`) + expect(prettyInspect(`a${'😀'.repeat(8)}`, { truncate: 14 })).toMatchInlineSnapshot(`"'a😀😀😀😀😀…'"`) + }) + + test('truncates array', () => { + expect(prettyInspect([1, 2, 3, 4, 5, 6], { truncate: 20 })).toMatchInlineSnapshot(`"[ 1, 2, 3, 4, 5, 6 ]"`) + expect(prettyInspect([1, 2, 3, 4, 5, 6, 7], { truncate: 20 })).toMatchInlineSnapshot(`"[ 1, 2, 3, 4, …(3) ]"`) + }) + + test('truncates object', () => { + expect(prettyInspect({ a: 1, b: 2, c: 3 }, { truncate: 20 })).toMatchInlineSnapshot(`"{ a: 1, b: 2, c: 3 }"`) + expect(prettyInspect({ a: 1, b: 2, c: 3, d: 4 }, { truncate: 20 })).toMatchInlineSnapshot(`"{ a: 1, b: 2, …(2) }"`) + }) + + test('truncate other types', () => { + expect(prettyInspect(new Map([['a', 1]]), { truncate: 25 })).toMatchInlineSnapshot(`"Map { 'a' => 1 }"`) + expect(prettyInspect(new Map([['a', 1], ['b', 2]]), { truncate: 25 })).toMatchInlineSnapshot(`"Map { 'a' => 1, …(1) }"`) + expect(prettyInspect(new Set([1, 2, 3, 4]), { truncate: 20 })).toMatchInlineSnapshot(`"Set { 1, 2, 3, 4 }"`) + expect(prettyInspect(new Set([1, 2, 3, 4, 5]), { truncate: 20 })).toMatchInlineSnapshot(`"Set { 1, 2, …(3) }"`) + }) + + test('multiline', () => { + expect(prettyInspect({ a: 1, b: 2 }, { multiline: true })).toMatchInlineSnapshot(` + "{ + a: 1, + b: 2, + }" + `) + }) +}) + +// -- three-way inspect comparison -- +// Compare prettyInspect output against node's util.inspect and loupe.inspect. +// Organized by agreement pattern to show where outputs align or diverge. + +describe('inspect comparison (prettyInspect vs node vs loupe)', () => { + const nodeOpts = { depth: null, maxArrayLength: null } // depth 2 by default + + // -- all three agree -- + + test.for([ + null, + undefined, + true, + false, + -0, + 42, + -123, + 3.14, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 123n, + -123n, + 'hello', + '', + /test/gi, + new Date(10e11), + Symbol('test'), + {}, + [], + [1, 2, 3], + [[1], [2]], + { a: 1, b: 2 }, + { b: 1, a: 2 }, + { a: { b: { c: 1 } } }, + [{ a: 1 }, { b: 2 }], + ])('all three match: %s', (val) => { + const result = prettyInspect(val) + expect(result).toBe(nodeInspect(val, nodeOpts)) + expect(result).toBe(loupeInspect(val)) + }) + + // -- prettyInspect matches node, diverges from loupe -- + + test('custom class — matches node (loupe omits space before brace)', () => { + class CustomClass { + public key: string + constructor() { + this.key = 'value' + } + } + expect(prettyInspect(new CustomClass())).toMatchInlineSnapshot(`"CustomClass { key: 'value' }"`) + expect(nodeInspect(new CustomClass(), nodeOpts)).toMatchInlineSnapshot(`"CustomClass { key: 'value' }"`) + expect(loupeInspect(new CustomClass())).toMatchInlineSnapshot(`"CustomClass{ key: 'value' }"`) + }) + + test('0 — matches node (loupe shows +0)', () => { + expect(prettyInspect(0)).toMatchInlineSnapshot(`"0"`) + expect(nodeInspect(0)).toMatchInlineSnapshot(`"0"`) + expect(loupeInspect(0)).toMatchInlineSnapshot(`"+0"`) + }) + + test('typed array', () => { + const input = new Uint8Array([1, 2, 3]) + expect(prettyInspect(input)).toMatchInlineSnapshot(`"Uint8Array [ 1, 2, 3 ]"`) + expect(nodeInspect(input, nodeOpts)).toMatchInlineSnapshot(`"Uint8Array(3) [ 1, 2, 3 ]"`) + expect(loupeInspect(input)).toMatchInlineSnapshot(`"Uint8Array[ 1, 2, 3 ]"`) + }) + + test('non-enumerable properties — matches node (loupe shows them via getOwnPropertyNames)', () => { + const val = { visible: true } + Object.defineProperty(val, 'hidden', { enumerable: false, value: 'secret' }) + expect(prettyInspect(val)).toMatchInlineSnapshot(`"{ visible: true }"`) + expect(nodeInspect(val, nodeOpts)).toMatchInlineSnapshot(`"{ visible: true }"`) + expect(loupeInspect(val)).toMatchInlineSnapshot(`"{ visible: true, hidden: 'secret' }"`) + }) + + // -- prettyInspect diverges from both -- + + test('string with single quotes — no escaping (escapeString: false)', () => { + expect(prettyInspect('it\'s')).toMatchInlineSnapshot(`"'it's'"`) + expect(nodeInspect('it\'s')).toMatchInlineSnapshot(`""it's""`) + expect(loupeInspect('it\'s')).toMatchInlineSnapshot(`"'it\\'s'"`) + }) + + test('named function — format differences', () => { + function myFn() {} + expect(prettyInspect(myFn)).toMatchInlineSnapshot(`"[Function myFn]"`) + expect(nodeInspect(myFn)).toMatchInlineSnapshot(`"[Function: myFn]"`) + expect(loupeInspect(myFn)).toMatchInlineSnapshot(`"[Function myFn]"`) + }) + + test('anonymous function', () => { + expect(prettyInspect((() => {}) as unknown)).toMatchInlineSnapshot(`"[Function anonymous]"`) + expect(nodeInspect(() => {})).toMatchInlineSnapshot(`"[Function (anonymous)]"`) + expect(loupeInspect(() => {})).toMatchInlineSnapshot(`"[Function]"`) + }) + + test('async function — loses AsyncFunction tag', () => { + async function asyncFn() {} + expect(prettyInspect(asyncFn)).toMatchInlineSnapshot(`"[Function asyncFn]"`) + expect(nodeInspect(asyncFn)).toMatchInlineSnapshot(`"[AsyncFunction: asyncFn]"`) + expect(loupeInspect(asyncFn)).toMatchInlineSnapshot(`"[AsyncFunction asyncFn]"`) + }) + + test('generator function — loses GeneratorFunction tag', () => { + function* genFn() { + yield 1 + } + expect(prettyInspect(genFn)).toMatchInlineSnapshot(`"[Function genFn]"`) + expect(nodeInspect(genFn)).toMatchInlineSnapshot(`"[GeneratorFunction: genFn]"`) + expect(loupeInspect(genFn)).toMatchInlineSnapshot(`"[GeneratorFunction genFn]"`) + }) + + test('Error — bracket format', () => { + expect(prettyInspect(new Error('boom'))).toMatchInlineSnapshot(`"[Error: boom]"`) + expect(nodeInspect(new Error('boom'))).toMatch('Error: boom\n') + expect(loupeInspect(new Error('boom'))).toMatchInlineSnapshot(`"Error: boom"`) + }) + + test('Map — space before brace, no size prefix', () => { + const m = new Map([['a', 1], ['b', 2]]) + expect(prettyInspect(m)).toMatchInlineSnapshot(`"Map { 'a' => 1, 'b' => 2 }"`) + expect(nodeInspect(m)).toMatchInlineSnapshot(`"Map(2) { 'a' => 1, 'b' => 2 }"`) + expect(loupeInspect(m)).toMatchInlineSnapshot(`"Map{ 'a' => 1, 'b' => 2 }"`) + }) + + test('Set — space before brace, no size prefix', () => { + const s = new Set([1, 2, 3]) + expect(prettyInspect(s)).toMatchInlineSnapshot(`"Set { 1, 2, 3 }"`) + expect(nodeInspect(s)).toMatchInlineSnapshot(`"Set(3) { 1, 2, 3 }"`) + expect(loupeInspect(s)).toMatchInlineSnapshot(`"Set{ 1, 2, 3 }"`) + }) + + test('circular reference — no ref labels', () => { + const val: any = {} + val.self = val + expect(prettyInspect(val)).toMatchInlineSnapshot(`"{ self: [Circular] }"`) + expect(nodeInspect(val)).toMatchInlineSnapshot(`" { self: [Circular *1] }"`) + // loupe: would need circular-safe call, skip + }) + + test('WeakMap', () => { + expect(prettyInspect(new WeakMap())).toMatchInlineSnapshot(`"WeakMap {}"`) + expect(nodeInspect(new WeakMap())).toMatchInlineSnapshot(`"WeakMap { }"`) + expect(loupeInspect(new WeakMap())).toMatchInlineSnapshot(`"WeakMap{…}"`) + }) + + test('WeakSet', () => { + expect(prettyInspect(new WeakSet())).toMatchInlineSnapshot(`"WeakSet {}"`) + expect(nodeInspect(new WeakSet())).toMatchInlineSnapshot(`"WeakSet { }"`) + expect(loupeInspect(new WeakSet())).toMatchInlineSnapshot(`"WeakSet{…}"`) + }) + + test('Promise', () => { + expect(prettyInspect(Promise.resolve())).toMatchInlineSnapshot(`"Promise {}"`) + expect(nodeInspect(Promise.resolve())).toMatchInlineSnapshot(`"Promise { undefined }"`) + expect(loupeInspect(Promise.resolve())).toMatchInlineSnapshot(`"Promise{…}"`) + }) + + // -- truncation (prettyInspect vs loupe only) -- + // loupe threads a character budget through recursion, truncating structurally. + // prettyInspect does surface-level truncation (structural summary for containers). + + describe('truncation', () => { + test('short string — both fit', () => { + expect(prettyInspect('hi', { truncate: 40 })).toMatchInlineSnapshot(`"'hi'"`) + expect(loupeInspect('hi', { truncate: 40 })).toMatchInlineSnapshot(`"'hi'"`) + }) + + test('long string', () => { + const s = '0123456789012345678901234567890123456789' + expect(prettyInspect(s, { truncate: 20 })).toMatchInlineSnapshot(`"'012345678901234567…'"`) + expect(loupeInspect(s, { truncate: 20 })).toMatchInlineSnapshot(`"'01234567890123456…'"`) + }) + + test('short array — both fit', () => { + expect(prettyInspect([1, 2, 3], { truncate: 40 })).toMatchInlineSnapshot(`"[ 1, 2, 3 ]"`) + expect(loupeInspect([1, 2, 3], { truncate: 40 })).toMatchInlineSnapshot(`"[ 1, 2, 3 ]"`) + }) + + test('long array', () => { + const arr = [1, 2, 3, 4, 5] + expect(prettyInspect(arr, { truncate: 15 })).toMatchInlineSnapshot(`"[ 1, 2, …(3) ]"`) + expect(loupeInspect(arr, { truncate: 15 })).toMatchInlineSnapshot(`"[ 1, 2, …(3) ]"`) + }) + + test('array with long string values', () => { + const arr = ['one', 'two', 'three', 'four', 'five'] + expect(prettyInspect(arr, { truncate: 40 })).toMatchInlineSnapshot(`"[ 'one', 'two', 'three', 'four', …(1) ]"`) + expect(loupeInspect(arr, { truncate: 40 })).toMatchInlineSnapshot(`"[ 'one', 'two', 'three', 'four', …(1) ]"`) + }) + + test('short object — both fit', () => { + expect(prettyInspect({ a: 1 }, { truncate: 40 })).toMatchInlineSnapshot(`"{ a: 1 }"`) + expect(loupeInspect({ a: 1 }, { truncate: 40 })).toMatchInlineSnapshot(`"{ a: 1 }"`) + }) + + test('long object', () => { + const obj = { one: 1, two: 2, three: 3, four: 4, five: 5 } + expect(prettyInspect(obj, { truncate: 40 })).toMatchInlineSnapshot(`"{ one: 1, two: 2, three: 3, …(2) }"`) + expect(loupeInspect(obj, { truncate: 40 })).toMatchInlineSnapshot(`"{ one: 1, two: 2, three: 3, …(2) }"`) + }) + + test('nested object — stringify adaptive maxDepth halves depth until it fits', () => { + const obj = { a: { b: { c: 'deep' } } } + // full output is "{ a: { b: { c: 'deep' } } }" (28 chars) + // stringify halves maxDepth, collapsing inner object to [Object] + expect(prettyInspect(obj, { truncate: 20 })).toMatchInlineSnapshot(`"{ a: [Object] }"`) + expect(loupeInspect(obj, { truncate: 20 })).toMatchInlineSnapshot(`"{ a: { …(1) } }"`) + }) + + test('Map', () => { + const m = new Map([['a', 1], ['b', 2], ['c', 3]]) + expect(prettyInspect(m, { truncate: 20 })).toMatchInlineSnapshot(`"Map { …(3) }"`) + expect(loupeInspect(m, { truncate: 20 })).toMatchInlineSnapshot(`"Map{ …(3) }"`) + }) + + test('Set', () => { + const s = new Set([1, 2, 3, 4, 5]) + expect(prettyInspect(s, { truncate: 15 })).toMatchInlineSnapshot(`"Set { 1, …(4) }"`) + expect(loupeInspect(s, { truncate: 15 })).toMatchInlineSnapshot(`"Set{ 1, …(4) }"`) + }) + + test('function', () => { + function myLongFunctionName() {} + expect(prettyInspect(myLongFunctionName, { truncate: 10 })).toMatchInlineSnapshot(`"[Function myLongFunctionName]"`) + expect(loupeInspect(myLongFunctionName, { truncate: 10 })).toMatchInlineSnapshot(`"[Function …]"`) + }) + }) +}) diff --git a/test/core/test/utils-display.spec.ts b/test/core/test/utils-display.spec.ts index d538edd07..dec630ce8 100644 --- a/test/core/test/utils-display.spec.ts +++ b/test/core/test/utils-display.spec.ts @@ -74,11 +74,11 @@ describe('format', () => { ['%%', 'string'], ['prefix', Symbol('test')], ])('format(%s)', (formatString, ...args) => { - expect(format(formatString, ...args), `failed ${formatString}`).toBe(util.format(formatString, ...args)) + expect(format([formatString, ...args]), `failed ${formatString}`).toBe(util.format(formatString, ...args)) }) test('cannot serialize some values', () => { - expect(() => format('%j', 100n)).toThrowErrorMatchingInlineSnapshot(`[TypeError: Do not know how to serialize a BigInt]`) + expect(() => format(['%j', 100n])).toThrowErrorMatchingInlineSnapshot(`[TypeError: Do not know how to serialize a BigInt]`) }) test.each( @@ -105,6 +105,6 @@ describe('format', () => { }, ], )('formats objects $name (loupe doesn\'t respect depth)', ({ args, result }) => { - expect(format(...args)).toBe(result) + expect(format(args)).toBe(result) }) }) diff --git a/test/core/test/utils.spec.ts b/test/core/test/utils.spec.ts index 4e792d3a8..a9f135373 100644 --- a/test/core/test/utils.spec.ts +++ b/test/core/test/utils.spec.ts @@ -1,4 +1,3 @@ -import { objDisplay } from '@vitest/utils/display' import { assertTypes, deepClone, deepMerge, isNegativeNaN, objectAttr, toArray } from '@vitest/utils/helpers' import { parseSingleFFOrSafariStack } from '@vitest/utils/source-map' import { EvaluatedModules } from 'vite/module-runner' @@ -279,20 +278,6 @@ describe('objectAttr', () => { }) }) -describe('objDisplay', () => { - test.each` - value | expected - ${'a'.repeat(100)} | ${`'${'a'.repeat(37)}…'`} - ${'🐱'.repeat(100)} | ${`'${'🐱'.repeat(18)}…'`} - ${`a${'🐱'.repeat(100)}…`} | ${`'a${'🐱'.repeat(18)}…'`} - `('Do not truncate strings anywhere but produce valid unicode strings for $value', ({ value, expected }) => { - // encodeURI can be used to detect invalid strings including invalid code-points - // note: our code should not split surrogate pairs, but may split graphemes - expect(() => encodeURI(objDisplay(value))).not.toThrow() - expect(objDisplay(value)).toEqual(expected) - }) -}) - describe('isNegativeNaN', () => { test.each` value | expected -- 2.51.2