diff --git a/__tests__/core/type-system.test.ts b/__tests__/core/type-system.test.ts index 1ad563283..9b995eed2 100644 --- a/__tests__/core/type-system.test.ts +++ b/__tests__/core/type-system.test.ts @@ -41,6 +41,7 @@ import type { IReferenceType, IUnionType } from "../../src/internal" +import { prettyPrintDescription } from "../../src/core/type/type-checker" type DifferingKeys = { [K in keyof ActualT | keyof ExpectedT]: K extends keyof ActualT @@ -1421,3 +1422,144 @@ test("#1627 - union dispatch function is typed", () => { types.null ) }) + +if (process.env.NODE_ENV !== "production") { + describe("error message JSON formatting", () => { + test("small snapshots are still printed compactly on a single line", () => { + const Model = types.model({ x: types.number }) + expect(() => Model.create({ x: "not a number" } as any)).toThrow( + `[mobx-state-tree] Error while converting \`{"x":"not a number"}\` to \`AnonymousModel\`:\n\n at path "/x" value \`"not a number"\` is not assignable to type: \`number\` (Value is not a number).` + ) + }) + + test("long snapshots are pretty-printed across multiple lines", () => { + const Model = types.model("Model", { + a: types.number, + b: types.number, + c: types.number, + d: types.number + }) + let message = "" + try { + Model.create({ + a: "wrong", + b: 2, + c: 3, + d: 4, + someExtraLongKey: "to push us past the inline length threshold" + } as any) + } catch (e) { + message = (e as Error).message + } + // the snapshot is rendered with newlines and indentation + expect(message).toContain(`Error while converting \`{\n`) + expect(message).toContain(`\n "a": "wrong",\n`) + }) + + test("long strings inside snapshots are truncated", () => { + const Model = types.model({ value: types.number }) + const longString = "a".repeat(500) + let message = "" + try { + Model.create({ value: longString } as any) + } catch (e) { + message = (e as Error).message + } + expect(message).toContain("more characters)") + expect(message).not.toContain("a".repeat(200)) + }) + + test("large arrays inside snapshots are truncated", () => { + const Model = types.model({ value: types.number }) + const bigArray = Array.from({ length: 50 }).map((_, i) => ({ index: i })) + let message = "" + try { + Model.create({ value: bigArray } as any) + } catch (e) { + message = (e as Error).message + } + expect(message).toContain("more items") + // only the first few array items are shown, not all 50 + expect(message).not.toContain(`"index": 10`) + }) + }) + + describe("error message type-shape formatting", () => { + test("short type shapes stay on a single line", () => { + const Model = types.model({ x: types.number }) + expect(() => Model.create({ x: "nope" } as any)).toThrow( + "is not assignable to type: `number` (Value is not a number)." + ) + }) + + test("long type shapes are pretty-printed across multiple lines", () => { + const Inner = types.model("Inner", { + first: types.number, + second: types.number, + third: types.number + }) + const Outer = types.model("Outer", { + maintenance_mode: types.maybe(types.boolean), + min_password_length: types.number, + max_upload_size: types.number, + inner: Inner + }) + let message = "" + try { + types.maybe(Outer).create({ unexpected: true } as any) + } catch (e) { + message = (e as Error).message + } + // the "snapshot like" type description is broken across lines and indented + expect(message).toContain("a snapshot like `({\n") + expect(message).toContain("\n min_password_length: number;\n") + // nested model shapes are indented further + expect(message).toContain("\n inner: {\n") + }) + }) + + describe("prettyPrintDescription stability", () => { + // build a description guaranteed to exceed the inline threshold + const long = (shape: string) => shape + " ".repeat(80) + + test("short descriptions are returned verbatim", () => { + expect(prettyPrintDescription("{ a: number; b: string }")).toBe( + "{ a: number; b: string }" + ) + expect(prettyPrintDescription("(string | number)")).toBe("(string | number)") + }) + + test("never throws and never loses characters, regardless of input", () => { + const adversarial = [ + "", // empty + "}}}}".padEnd(90, "x"), // over-closed: indent would go negative + "{".repeat(90), // never-closed + long('{ a: "deeply;{}nested"; b: number }'), // separators inside a string literal + long('{ a: "ends with backslash\\\\"; b: number }'), // trailing backslash in literal + long("{ a: 'single quoted; with } brace'; b: number }"), + long("(({{[[ unbalanced garbage ]]}}))"), + long("\u{1f600}{ a: number; b: number }"), // multi-byte chars + long("{ a: { b: { c: { d: { e: number } } } } }") // deep nesting + ] + for (const input of adversarial) { + let output = "" + expect(() => (output = prettyPrintDescription(input))).not.toThrow() + // formatting only adds whitespace, so stripping all whitespace is lossless + expect(output.replace(/\s/g, "")).toBe(input.replace(/\s/g, "")) + } + }) + + test("separators inside string literals are not broken onto new lines", () => { + const output = prettyPrintDescription(long('{ kind: "a;b;c"; value: number }')) + expect(output).toContain('"a;b;c"') + // the literal stays intact on a single line + expect(output).not.toContain('"a;\n') + }) + + test("over-closed shapes still format without negative indentation", () => { + const output = prettyPrintDescription(long("} a: number; {")) + // indentation never goes negative (no RangeError) and we still get a string back + expect(typeof output).toBe("string") + }) + }) +} diff --git a/src/core/type/type-checker.ts b/src/core/type/type-checker.ts index acf47fe06..d9c475f2a 100644 --- a/src/core/type/type-checker.ts +++ b/src/core/type/type-checker.ts @@ -5,6 +5,7 @@ import { getStateTreeNode, isStateTreeNode, isPrimitiveType, + isPlainObject, IAnyType, ExtractCSTWithSTN, isTypeCheckingEnabled, @@ -35,31 +36,169 @@ export interface IValidationError { /** Type validation result, which is an array of type validation errors */ export type IValidationResult = IValidationError[] -function safeStringify(value: any) { +// Limits used when rendering snapshots/values in error messages, so that long +// strings or large arrays/objects don't take up the whole screen. +const MAX_STRING_LENGTH = 100 +const MAX_ARRAY_LENGTH = 3 +const MAX_OBJECT_KEYS = 30 +const MAX_DEPTH = 5 +// Values (and type shapes) whose single-line representation is longer than this +// are pretty-printed across multiple lines instead. +const MAX_INLINE_LENGTH = 80 + +function safeStringify(value: any, indent?: number) { try { - return JSON.stringify(value) + return JSON.stringify(value, null, indent) } catch (e) { // istanbul ignore next return `` } } +/** + * Returns a clone of `value` in which overly long strings are clipped and large + * arrays/objects (or values nested too deeply) are summarized, so the result is + * safe to print in an error message without flooding the screen. + */ +function truncateForDisplay(value: any, depth: number): any { + if (typeof value === "string") { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}… (${value.length - MAX_STRING_LENGTH} more characters)` + : value + } + if (Array.isArray(value)) { + if (depth >= MAX_DEPTH) return "[…]" + const items = value + .slice(0, MAX_ARRAY_LENGTH) + .map(item => truncateForDisplay(item, depth + 1)) + if (value.length > MAX_ARRAY_LENGTH) { + items.push(`… ${value.length - MAX_ARRAY_LENGTH} more items`) + } + return items + } + if (isPlainObject(value)) { + if (depth >= MAX_DEPTH) return "{…}" + const result: { [key: string]: any } = {} + const keys = Object.keys(value) + keys.slice(0, MAX_OBJECT_KEYS).forEach(key => { + result[key] = truncateForDisplay(value[key], depth + 1) + }) + if (keys.length > MAX_OBJECT_KEYS) { + result["…"] = `${keys.length - MAX_OBJECT_KEYS} more keys` + } + return result + } + return value +} + /** * @internal * @hidden */ export function prettyPrintValue(value: any) { - return typeof value === "function" - ? `` - : isStateTreeNode(value) - ? `<${value}>` - : `\`${safeStringify(value)}\`` + if (typeof value === "function") { + return `` + } + if (isStateTreeNode(value)) { + return `<${value}>` + } + + // Small values are printed compactly on a single line, exactly as before, so + // they stay easy to read. JSON.stringify returns `undefined` for values like + // `undefined` itself, which the template literal coerces back to a string. + const full = safeStringify(value) + if (full === undefined || full.length <= MAX_INLINE_LENGTH) { + return `\`${full}\`` + } + + // Large values are clipped (long strings, big arrays/objects, deep nesting) + // and pretty-printed across multiple lines so they don't flood the screen. + const truncated = truncateForDisplay(value, 0) + const compact = safeStringify(truncated) + if (compact !== undefined && compact.length <= MAX_INLINE_LENGTH) { + return `\`${compact}\`` + } + return `\`${safeStringify(truncated, 2)}\`` } -function shortenPrintValue(valueInString: string) { - return valueInString.length < 280 - ? valueInString - : `${valueInString.substring(0, 272)}......${valueInString.substring(valueInString.length - 8)}` +/** + * Re-indents a type description (as produced by `IType.describe()`) across + * multiple lines when it is too long to comfortably read on a single line, by + * breaking after the `{`, `}` and `;` separators used in model shapes (while + * leaving union `|` and array `[]` parts inline). Characters inside string + * literals (e.g. literal types like `"a;b"`) are left untouched. + * + * Short descriptions are returned unchanged. As this runs while formatting an + * error that is already being thrown, it must never throw itself: any unexpected + * input falls back to the original, unformatted description. + * + * @internal + * @hidden + */ +export function prettyPrintDescription(description: string): string { + if (description.length <= MAX_INLINE_LENGTH) { + return description + } + + try { + let result = "" + let indent = 0 + let stringDelimiter: string | null = null + let escaped = false + const newline = () => { + // drop any trailing spaces (e.g. the "{ " / "; " separators) before breaking, + // and never let a malformed (over-closed) shape produce a negative indent + result = result.replace(/[ \t]+$/, "") + result += "\n" + " ".repeat(Math.max(0, indent)) + } + + for (let i = 0; i < description.length; i++) { + const char = description[i] + + if (stringDelimiter) { + result += char + if (escaped) { + escaped = false + } else if (char === "\\") { + escaped = true + } else if (char === stringDelimiter) { + stringDelimiter = null + } + continue + } + + switch (char) { + case '"': + case "'": + stringDelimiter = char + result += char + break + case "{": + indent++ + result += "{" + newline() + while (description[i + 1] === " ") i++ + break + case "}": + indent-- + newline() + result += "}" + break + case ";": + result += ";" + newline() + while (description[i + 1] === " ") i++ + break + default: + result += char + } + } + + return result + } catch (e) { + // istanbul ignore next - defensive: never let formatting hide the real error + return description + } } function toErrorString(error: IValidationError): string { @@ -88,9 +227,9 @@ function toErrorString(error: IValidationError): string { (type ? isPrimitiveType(type) || isPrimitive(value) ? `.` - : `, expected an instance of \`${(type as IAnyType).name}\` or a snapshot like \`${( - type as IAnyType - ).describe()}\` instead.` + + : `, expected an instance of \`${(type as IAnyType).name}\` or a snapshot like \`${prettyPrintDescription( + (type as IAnyType).describe() + )}\` instead.` + (isSnapshotCompatible ? " (Note that a snapshot of the provided value is compatible with the targeted type)" : "") @@ -179,8 +318,7 @@ function validationErrorsToString( } return ( - `Error while converting ${shortenPrintValue(prettyPrintValue(value))} to \`${ - type.name - }\`:\n\n ` + errors.map(toErrorString).join("\n ") + `Error while converting ${prettyPrintValue(value)} to \`${type.name}\`:\n\n ` + + errors.map(toErrorString).join("\n ") ) }