Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions __tests__/core/type-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
IReferenceType,
IUnionType
} from "../../src/internal"
import { prettyPrintDescription } from "../../src/core/type/type-checker"

type DifferingKeys<ActualT, ExpectedT> = {
[K in keyof ActualT | keyof ExpectedT]: K extends keyof ActualT
Expand Down Expand Up @@ -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")
})
})
}
172 changes: 155 additions & 17 deletions src/core/type/type-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getStateTreeNode,
isStateTreeNode,
isPrimitiveType,
isPlainObject,
IAnyType,
ExtractCSTWithSTN,
isTypeCheckingEnabled,
Expand Down Expand Up @@ -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 `<Unserializable: ${e}>`
}
}

/**
* 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"
? `<function${value.name ? " " + value.name : ""}>`
: isStateTreeNode(value)
? `<${value}>`
: `\`${safeStringify(value)}\``
if (typeof value === "function") {
return `<function${value.name ? " " + value.name : ""}>`
}
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 {
Expand Down Expand Up @@ -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)"
: "")
Expand Down Expand Up @@ -179,8 +318,7 @@ function validationErrorsToString<IT extends IAnyType>(
}

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 ")
)
}