From 1e1ee4917560a1f7849973950e7ec1712e702451 Mon Sep 17 00:00:00 2001 From: bcoll Date: Fri, 1 Sep 2023 23:02:47 +0100 Subject: [PATCH] Throw pretty errors with unexpected `new Miniflare()` options --- packages/miniflare/src/index.ts | 94 ++- packages/miniflare/src/shared/error.ts | 1 + packages/miniflare/src/zod-format.ts | 539 ++++++++++++++++++ packages/miniflare/test/index.spec.ts | 31 +- .../test/plugins/core/modules.spec.ts | 3 +- .../test/snapshots/zod-format.spec.ts.md | 307 ++++++++++ .../test/snapshots/zod-format.spec.ts.snap | Bin 0 -> 1519 bytes packages/miniflare/test/zod-format.spec.ts | 221 +++++++ 8 files changed, 1182 insertions(+), 14 deletions(-) create mode 100644 packages/miniflare/src/zod-format.ts create mode 100644 packages/miniflare/test/snapshots/zod-format.spec.ts.md create mode 100644 packages/miniflare/test/snapshots/zod-format.spec.ts.snap create mode 100644 packages/miniflare/test/zod-format.spec.ts diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 3612d84c5..cb89ecebe 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -7,6 +7,7 @@ import os from "os"; import path from "path"; import { Duplex, Transform, Writable } from "stream"; import { ReadableStream } from "stream/web"; +import util from "util"; import zlib from "zlib"; import type { CacheStorage, @@ -96,6 +97,7 @@ import { } from "./shared"; import { Storage } from "./storage"; import { CoreBindings, CoreHeaders } from "./workers"; +import { _formatZodError } from "./zod-format"; // ===== `Miniflare` User Options ===== export type MiniflareOptions = SharedOptions & @@ -109,12 +111,21 @@ type PluginSharedOptions = { [Key in keyof Plugins]: OptionalZodTypeOf; }; +function hasMultipleWorkers(opts: unknown): opts is { workers: unknown[] } { + return ( + typeof opts === "object" && + opts !== null && + "workers" in opts && + Array.isArray(opts.workers) + ); +} + function validateOptions( - opts: MiniflareOptions + opts: unknown ): [PluginSharedOptions, PluginWorkerOptions[]] { // Normalise options into shared and worker-specific const sharedOpts = opts; - const multipleWorkers = "workers" in opts; + const multipleWorkers = hasMultipleWorkers(opts); const workerOpts = multipleWorkers ? opts.workers : [opts]; if (workerOpts.length === 0) { throw new MiniflareCoreError("ERR_NO_WORKERS", "No workers defined"); @@ -127,16 +138,76 @@ function validateOptions( ); // Validate all options - for (const [key, plugin] of PLUGIN_ENTRIES) { - // @ts-expect-error `QueuesPlugin` doesn't define shared options - pluginSharedOpts[key] = plugin.sharedOptions?.parse(sharedOpts); - for (let i = 0; i < workerOpts.length; i++) { - // Make sure paths are correct in validation errors - const path = multipleWorkers ? ["workers", i] : undefined; - // @ts-expect-error `CoreOptionsSchema` has required options which are - // missing in other plugins' options. - pluginWorkerOpts[i][key] = plugin.options.parse(workerOpts[i], { path }); + try { + for (const [key, plugin] of PLUGIN_ENTRIES) { + // @ts-expect-error types of individual plugin options are unknown + pluginSharedOpts[key] = plugin.sharedOptions?.parse(sharedOpts); + for (let i = 0; i < workerOpts.length; i++) { + // Make sure paths are correct in validation errors + const path = multipleWorkers ? ["workers", i] : undefined; + // @ts-expect-error types of individual plugin options are unknown + pluginWorkerOpts[i][key] = plugin.options.parse(workerOpts[i], { + path, + }); + } + } + } catch (e) { + if (e instanceof z.ZodError) { + let formatted: string | undefined; + try { + formatted = _formatZodError(e, opts); + } catch (formatError) { + // If formatting failed for some reason, we'd like to know, so log a + // bunch of debugging information, including the full validation error + // so users at least know what was wrong. + + const title = "[Miniflare] Validation Error Format Failure"; + const message = [ + "### Input", + "```", + util.inspect(opts, { depth: null }), + "```", + "", + "### Validation Error", + "```", + e.stack, + "```", + "", + "### Format Error", + "```", + typeof formatError === "object" && + formatError !== null && + "stack" in formatError && + typeof formatError.stack === "string" + ? formatError.stack + : String(formatError), + "```", + ].join("\n"); + const githubIssueUrl = new URL( + "https://github.com/cloudflare/miniflare/issues/new" + ); + githubIssueUrl.searchParams.set("title", title); + githubIssueUrl.searchParams.set("body", message); + + formatted = [ + "Unable to format validation error.", + "Please open the following URL in your browser to create a GitHub issue:", + githubIssueUrl, + "", + message, + "", + ].join("\n"); + } + const error = new MiniflareCoreError( + "ERR_VALIDATION", + `Unexpected options passed to \`new Miniflare()\` constructor:\n${formatted}` + ); + // Add the `cause` as a getter, so it isn't logged automatically with the + // error, but can still be accessed if needed + Object.defineProperty(error, "cause", { get: () => e }); + throw error; } + throw e; } // Validate names unique @@ -1322,3 +1393,4 @@ export * from "./runtime"; export * from "./shared"; export * from "./storage"; export * from "./workers"; +export * from "./zod-format"; diff --git a/packages/miniflare/src/shared/error.ts b/packages/miniflare/src/shared/error.ts index d8bd34a58..dd514e689 100644 --- a/packages/miniflare/src/shared/error.ts +++ b/packages/miniflare/src/shared/error.ts @@ -24,6 +24,7 @@ export type MiniflareCoreErrorCode = | "ERR_PERSIST_REMOTE_UNSUPPORTED" // Remote storage is not supported for this database | "ERR_FUTURE_COMPATIBILITY_DATE" // Compatibility date in the future | "ERR_NO_WORKERS" // No workers defined + | "ERR_VALIDATION" // Options failed to parse | "ERR_DUPLICATE_NAME" // Multiple workers defined with same name | "ERR_DIFFERENT_UNIQUE_KEYS" // Multiple Durable Object bindings declared for same class with different unsafe unique keys | "ERR_MULTIPLE_OUTBOUNDS"; // Both `outboundService` and `fetchMock` specified diff --git a/packages/miniflare/src/zod-format.ts b/packages/miniflare/src/zod-format.ts new file mode 100644 index 000000000..9c4104b35 --- /dev/null +++ b/packages/miniflare/src/zod-format.ts @@ -0,0 +1,539 @@ +// TODO(someday): publish this as a separate package +// noinspection JSUnusedAssignment +// ^ WebStorm incorrectly thinks some variables might not have been initialised +// before use without this. TypeScript is better at catching these errors. :) + +import assert from "assert"; +import util from "util"; +import { + $ as $colors, + blue, + cyan, + dim, + green, + magenta, + red, + yellow, +} from "kleur/colors"; +import { z } from "zod"; + +// This file contains a `_formatZodError(error, input)` function for formatting +// a Zod `error` that came from parsing a specific `input`. This works by +// building an "annotated" version of the `input`, with roughly the same shape, +// but including messages from the `error`. This is then printed to a string. +// +// When the Zod `error` includes an issue at a specific path, that path is +// replaced with an `Annotation` in the "annotated" version. This `Annotation`, +// includes any messages for that path, along with the actual value at that +// path. `Annotation`s may be grouped if they are part of a union. In this case, +// they'll be printed in a different colour and with a group ID to indicate that +// only one of the issues needs to be fixed for all issues in the group to be +// resolved. +// +// This is best illustrated with some examples: +// +// # Primitive Input +// +// ``` +// const schema = z.boolean(); +// const input = 42; +// const error = schema.safeParse(input).error; +// ↳ ZodError [ +// { +// "code": "invalid_type", +// "expected": "boolean", +// "received": "number", +// "path": [], +// "message": "Expected boolean, received number" +// } +// ] +// +// /* Annotated */ +// { +// [Symbol(kMessages)]: [ 'Expected boolean, received number' ], +// [Symbol(kActual)]: 42 +// } +// +// const formatted = _formatZodError(error, input); +// ↳ 42 +// ^ Expected boolean, received number +// ``` +// +// In this example, we only have a single issue, with `"path": []`. This path +// represents the root, so the root of the input has been replaced with an +// annotation in the annotated version. +// +// ### Object Input +// +// ```` +// const schema = z.object({ a: z.number(), b: z.number(), c: z.object({ d: z.number() })}); +// const input = { a: false, b: 42, c: { d: "not a number" } }; +// const error = schema.safeParse(input).error; +// ↳ ZodError: [ +// { +// "code": "invalid_type", +// "expected": "number", +// "received": "boolean", +// "path": [ "a" ], +// "message": "Expected number, received boolean" +// }, +// { +// "code": "invalid_type", +// "expected": "number", +// "received": "string", +// "path": [ "c", "d" ], +// "message": "Expected number, received boolean" +// } +// ] +// +// /* Annotated after 1st issue */ +// { +// a: { +// [Symbol(kMessages)]: [ 'Expected number, received boolean' ], +// [Symbol(kActual)]: false +// }, +// b: undefined, +// c: undefined, +// } +// +// /* Annotated after 2nd issue */ +// { +// a: { +// [Symbol(kMessages)]: [ 'Expected number, received boolean' ], +// [Symbol(kActual)]: false +// }, +// b: undefined, +// c: { +// d: { +// [Symbol(kMessages)]: [ 'Expected number, received string' ], +// [Symbol(kActual)]: 'not a number' +// } +// } +// } +// +// const formatted = _formatZodError(error, input); +// ↳ { +// a: false, +// ^ Expected number, received boolean +// ..., +// c: { +// d: true, +// ^ Expected number, received boolean +// }, +// } +// ```` +// +// If the error contains multiple issues, we annotate them one at a time. +// For object inputs, the annotated object starts out with the same keys as +// the object, just with `undefined` values. Keys with values that are +// `undefined` at the end of annotation will be printed as "...". Annotations +// are inserted on paths where there are issues. Note annotations are +// effectively the leaves of the annotated tree. +// +// ### Array Input +// +// ``` +// const schema = z.array(z.number()); +// const input = [1, 2, false, 4]; +// const error = schema.safeParse(input).error; +// ↳ ZodError: [ +// { +// "code": "invalid_type", +// "expected": "number", +// "received": "boolean", +// "path": [ 2 ], +// "message": "Expected number, received boolean" +// } +// ] +// +// /* Annotated */ +// [ +// <2 empty items>, +// { +// [Symbol(kMessages)]: [ 'Expected number, received boolean' ], +// [Symbol(kActual)]: false +// }, +// <1 empty item> +// ] +// +// const formatted = _formatZodError(error, input); +// ↳ [ +// ..., +// /* [2] */ false, +// ^ Expected number, received boolean +// ..., +// ] +// ``` +// +// In this case the annotated value is now an array, rather than a plain-object, +// to match the shape of the input. Note it has the same length as the input, +// including empty items for array indices that don't have errors. Empty items +// will be coalesced and printed as "...". +// +// ### Union Schema and Groups +// +// ``` +// const schema = z.union([z.object({ a: z.number() }), z.object({ b: z.string() })]); +// const input = { c: false }; +// const error = schema.safeParse(input).error; +// ↳ [ +// { +// "code": "invalid_union", +// "path": [], +// "message": "Invalid input", +// "unionErrors": [ +// { +// "name": "ZodError", +// "issues": [ +// { +// "code": "invalid_type", +// "expected": "number", +// "received": "undefined", +// "path": [ "a" ], +// "message": "Required" +// } +// ] +// }, +// { +// "name": "ZodError", +// "issues": [ +// { +// "code": "invalid_type", +// "expected": "string", +// "received": "undefined", +// "path": [ "b" ], +// "message": "Required" +// } +// ] +// } +// ] +// } +// ] +// +// /* Annotated */ +// { +// c: undefined, +// a: { +// [Symbol(kMessages)]: [ 'Required' ], +// [Symbol(kActual)]: undefined, +// [Symbol(kGroupId)]: 0 +// }, +// b: { +// [Symbol(kMessages)]: [ 'Required' ], +// [Symbol(kActual)]: undefined, +// [Symbol(kGroupId)]: 0 +// } +// } +// +// const formatted = _formatZodError(error, input); +// ↳ { +// ..., +// a: undefined, +// ^1 Required *or* +// b: undefined, +// ^1 Required +// } +// ``` +// +// In this case, fixing either of the reported issues would solve the problem. +// To indicate this, we "group" issues contained within `unionErrors`. See how +// annotations have a `[Symbol(kGroupId)]: 0`. Group IDs are 0-indexed +// internally but 1-indexed when displayed (see "^1"). Each group's messages +// are displayed in a different colour to visually group them. Note the +// unhelpful "Invalid input" message has been hidden. Required options that +// are not present in the `input` are added to the end of the annotated input. +// + +const kMessages = Symbol("kMessages"); +const kActual = Symbol("kActual"); +const kGroupId = Symbol("kGroupId"); + +// Set of Zod error messages attached to a specific path in the input +interface Annotation { + // Error messages at this specific path in the annotated tree. A path may have + // multiple messages associated with it if the schema is a union/intersection. + [kMessages]: string[]; + // The input value at this specific path + [kActual]: unknown; + // Optional 0-indexed group for this annotation. Fixing a single issue in a + // group will usually fix all other issues in the group. Grouped annotations + // are displayed in a different colour with their 1-indexed group ID. + [kGroupId]?: number; +} + +// Object with the same shape as the input, but with undefined at the leaves, +// or an annotation iff there's an issue at that specific path. +type Annotated = + | undefined + | Annotation + | { [key: string]: Annotated } + | Annotated[]; + +// `green` is a success colour, so only use it for groups if really needed +const groupColours = [yellow, /* (green) */ cyan, blue, magenta, green]; + +// Maps group IDs to the number of annotations in that group. Used to determine +// whether this isn't the last annotation in a group, and "or" should be printed +// with the message. +const GroupCountsMap = Map; +type GroupCountsMap = InstanceType; + +function isAnnotation(value: unknown): value is Annotation { + return ( + typeof value === "object" && + value !== null && + kMessages in value && + kActual in value + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function arrayShallowEqual(a: T[], b: T[]) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +function issueEqual(a: z.ZodIssue, b: z.ZodIssue) { + // We consider issues to be equal if their messages and paths are + return a.message === b.message && arrayShallowEqual(a.path, b.path); +} + +function hasMultipleDistinctMessages(issues: z.ZodIssue[], atDepth: number) { + // Returns true iff `issues` has issues that aren't "the same" at the + // specified depth or below + let firstIssue: z.ZodIssue | undefined; + for (const issue of issues) { + if (issue.path.length < atDepth) continue; + if (firstIssue === undefined) firstIssue = issue; + else if (!issueEqual(firstIssue, issue)) return true; + } + return false; +} + +function annotate( + groupCounts: GroupCountsMap, + annotated: Annotated, + input: unknown, + issue: z.ZodIssue, + path: (string | number)[], + groupId?: number +): Annotated { + if (path.length === 0) { + // Empty path, `input` has incorrect shape + + // If this is an `invalid_union` error, make sure we include all sub-issues + if (issue.code === "invalid_union") { + const unionIssues = issue.unionErrors.flatMap(({ issues }) => issues); + + // If the `input` is an object/array with multiple distinct messages, + // annotate it as a group + let newGroupId: number | undefined; + const multipleDistinct = hasMultipleDistinctMessages( + unionIssues, + // For this check, we only include messages that are deeper than our + // current level, so we don't include messages we'd ignore if we grouped + issue.path.length + 1 + ); + if (isRecord(input) && multipleDistinct) { + newGroupId = groupCounts.size; + groupCounts.set(newGroupId, 0); + } + + for (const unionIssue of unionIssues) { + const unionPath = unionIssue.path.slice(issue.path.length); + // If we have multiple distinct messages at deeper levels, and this + // issue is for the current path, skip it, so we don't end up annotating + // the current path and sub-paths + if (multipleDistinct && unionPath.length === 0) continue; + annotated = annotate( + groupCounts, + annotated, + input, + unionIssue, + unionPath, + newGroupId + ); + } + return annotated; + } + + const message = issue.message; + // If we've already annotated this path (existing annotation or union)... + if (annotated !== undefined) { + // ...and if this is a new message for an existing annotation... + if (isAnnotation(annotated) && !annotated[kMessages].includes(message)) { + // ...add it + annotated[kMessages].push(message); + } + return annotated; + } + + // Creating a new annotation + + // If this new annotation is part of a group... + if (groupId !== undefined) { + // ...increment that group's count + const current = groupCounts.get(groupId); + assert(current !== undefined); + groupCounts.set(groupId, current + 1); + } + + return { + [kMessages]: [message], + [kActual]: input, + [kGroupId]: groupId, + }; + } + + // Non-empty path, `input` should be an object or array + const [head, ...tail] = path; + assert(isRecord(input), "Expected object/array input for nested issue"); + if (annotated === undefined) { + // Initialise `annotated` to look like `input`, with empty slots for keys + if (Array.isArray(input)) { + annotated = new Array(input.length); + } else { + const entries = Object.keys(input).map((key) => [key, undefined]); + annotated = Object.fromEntries(entries); + } + } + assert(isRecord(annotated), "Expected object/array for nested issue"); + // Recursively annotate + annotated[head] = annotate( + groupCounts, + annotated[head], + input[head], + issue, + tail, + groupId + ); + return annotated; +} + +interface PrintExtras { + prefix?: string; + suffix?: string; +} +function print( + inspectOptions: util.InspectOptions, + groupCounts: GroupCountsMap, + annotated: Annotated, + indent = "", + extras?: PrintExtras +): string { + const prefix = extras?.prefix ?? ""; + const suffix = extras?.suffix ?? ""; + + if (isAnnotation(annotated)) { + const prefixIndent = indent + " ".repeat(prefix.length); + + // Print actual value + const actual = util.inspect(annotated[kActual], inspectOptions); + const actualIndented = actual + .split("\n") + .map((line, i) => (i > 0 ? prefixIndent + line : line)) + .join("\n"); + + // Print message + let messageColour = red; + let messagePrefix = prefixIndent + "^"; + let groupOr = ""; + if (annotated[kGroupId] !== undefined) { + // If this annotation was part of a group, set the message colour based + // on the group, and include the group ID in the prefix + messageColour = groupColours[annotated[kGroupId] % groupColours.length]; + messagePrefix += annotated[kGroupId] + 1; + const remaining = groupCounts.get(annotated[kGroupId]); + assert(remaining !== undefined); + if (remaining > 1) groupOr = " *or*"; + groupCounts.set(annotated[kGroupId], remaining - 1); + } + messagePrefix += " "; + + const messageIndent = " ".repeat(messagePrefix.length); + const messageIndented = annotated[kMessages] + .flatMap((m) => m.split("\n")) + .map((line, i) => (i > 0 ? messageIndent + line : line)) + .join("\n"); + + // Print final annotation + const error = messageColour(`${messagePrefix}${messageIndented}${groupOr}`); + return `${indent}${dim(prefix)}${actualIndented}${dim(suffix)}\n${error}`; + } else if (Array.isArray(annotated)) { + // Print array recursively + let result = `${indent}${dim(`${prefix}[`)}\n`; + const arrayIndent = indent + " "; + for (let i = 0; i < annotated.length; i++) { + const value = annotated[i]; + // Add `...` if the last item wasn't an `...` + if (value === undefined && (i === 0 || annotated[i - 1] !== undefined)) { + result += `${arrayIndent}${dim("...,")}\n`; + } + if (value !== undefined) { + result += print(inspectOptions, groupCounts, value, arrayIndent, { + prefix: `/* [${i}] */ `, + suffix: ",", + }); + result += "\n"; + } + } + result += `${indent}${dim(`]${suffix}`)}`; + return result; + } else if (isRecord(annotated)) { + // Print object recursively + let result = `${indent}${dim(`${prefix}{`)}\n`; + const objectIndent = indent + " "; + const entries = Object.entries(annotated); + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i]; + // Add `...` if the last item wasn't an `...` + if (value === undefined && (i === 0 || entries[i - 1][1] !== undefined)) { + result += `${objectIndent}${dim("...,")}\n`; + } + if (value !== undefined) { + result += print(inspectOptions, groupCounts, value, objectIndent, { + prefix: `${key}: `, + suffix: ",", + }); + result += "\n"; + } + } + result += `${indent}${dim(`}${suffix}`)}`; + return result; + } + + return ""; +} + +/** @internal */ +export function _formatZodError(error: z.ZodError, input: unknown): string { + // Shallow copy and sort array, with `invalid_union` errors first, so we don't + // annotate the input with an `invalid_type` error instead + const sortedIssues = Array.from(error.issues).sort((a, b) => { + if (a.code !== b.code) { + if (a.code === "invalid_union") return -1; + if (b.code === "invalid_union") return 1; + } + return 0; + }); + + // Build annotated input + let annotated: Annotated; + const groupCounts = new GroupCountsMap(); + for (const issue of sortedIssues) { + annotated = annotate(groupCounts, annotated, input, issue, issue.path); + } + + // Print to pretty string + // Build inspect options on each call to `formatZodError()` so we can toggle + // colours per-call + const inspectOptions: util.InspectOptions = { + depth: 0, + colors: $colors.enabled, + }; + return print(inspectOptions, groupCounts, annotated); +} diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 04ccfcbff..114b0d9da 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -16,6 +16,7 @@ import { R2Bucket, } from "@cloudflare/workers-types/experimental"; import test, { ThrowsExpectation } from "ava"; +import { $ as $colors } from "kleur/colors"; import { DeferredPromise, MessageEvent, @@ -39,7 +40,7 @@ import { TestLog, useServer, useTmp, utf8Encode } from "./test-shared"; const queuesTest = _QUEUES_COMPATIBLE_V8_VERSION ? test : test.skip; -test("Miniflare: validates options", async (t) => { +test.serial("Miniflare: validates options", async (t) => { // Check empty workers array rejected t.throws(() => new Miniflare({ workers: [] }), { instanceOf: MiniflareCoreError, @@ -75,6 +76,34 @@ test("Miniflare: validates options", async (t) => { message: 'Multiple workers defined with the same `name`: "a"', } ); + + // Disable colours for easier to read expectations + const originalColours = $colors.enabled; + $colors.enabled = false; + t.teardown(() => ($colors.enabled = originalColours)); + + // Check throws validation error with incorrect options + // @ts-expect-error intentionally testing incorrect types + t.throws(() => new Miniflare({ name: 42, script: "" }), { + instanceOf: MiniflareCoreError, + code: "ERR_VALIDATION", + message: `Unexpected options passed to \`new Miniflare()\` constructor: +{ + name: 42, + ^ Expected string, received number + ..., +}`, + }); + + // Check throws validation error with primitive option + // @ts-expect-error intentionally testing incorrect types + t.throws(() => new Miniflare("addEventListener(...)"), { + instanceOf: MiniflareCoreError, + code: "ERR_VALIDATION", + message: `Unexpected options passed to \`new Miniflare()\` constructor: +'addEventListener(...)' +^ Expected object, received string`, + }); }); test("Miniflare: ready returns copy of entry URL", async (t) => { diff --git a/packages/miniflare/test/plugins/core/modules.spec.ts b/packages/miniflare/test/plugins/core/modules.spec.ts index 220a4d7f7..36a469df0 100644 --- a/packages/miniflare/test/plugins/core/modules.spec.ts +++ b/packages/miniflare/test/plugins/core/modules.spec.ts @@ -2,7 +2,6 @@ import assert from "assert"; import path from "path"; import test from "ava"; import { Miniflare, MiniflareCoreError } from "miniflare"; -import { ZodError } from "zod"; import { stripAnsi, utf8Encode } from "../../test-shared"; const ROOT = path.resolve( @@ -138,7 +137,7 @@ test("Miniflare: automatically collects modules", async (t) => { modulesRules: [{ type: "PNG", include: ["**/*.png"] }], script: "", }), - { instanceOf: ZodError } + { instanceOf: MiniflareCoreError, code: "ERR_VALIDATION" } ); }); test("Miniflare: automatically collects modules with cycles", async (t) => { diff --git a/packages/miniflare/test/snapshots/zod-format.spec.ts.md b/packages/miniflare/test/snapshots/zod-format.spec.ts.md new file mode 100644 index 000000000..82b0d483d --- /dev/null +++ b/packages/miniflare/test/snapshots/zod-format.spec.ts.md @@ -0,0 +1,307 @@ +# Snapshot report for `packages/miniflare/test/zod-format.spec.ts` + +The actual snapshot is saved in `zod-format.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## _formatZodError: formats primitive schema with primitive input + +> Snapshot 1 + + `false␊ + ^ Expected number, received boolean` + +## _formatZodError: formats primitive schema with object input + +> Snapshot 1 + + `{ a: 1, b: [Object] }␊ + ^ Expected string, received object` + +## _formatZodError: formats object schema with primitive input + +> Snapshot 1 + + `true␊ + ^ Expected object, received boolean` + +## _formatZodError: formats object schema with object input + +> Snapshot 1 + + `{␊ + ...,␊ + b: '2',␊ + ^ Expected number, received string␊ + ...,␊ + g: '7',␊ + ^ Expected boolean, received string␊ + f: undefined,␊ + ^ Required␊ + }` + +## _formatZodError: formats object schema with additional options + +> Snapshot 1 + + `{ a: 1, b: 2 }␊ + ^ Unrecognized key(s) in object: 'b'` + +## _formatZodError: formats array schema with primitive input + +> Snapshot 1 + + `1␊ + ^ Expected array, received number` + +## _formatZodError: formats array schema with array input + +> Snapshot 1 + + `[␊ + ...,␊ + /* [2] */ '3',␊ + ^ Expected number, received string␊ + ...,␊ + /* [5] */ false,␊ + ^ Expected number, received boolean␊ + ]` + +## _formatZodError: formats array schema with additional options + +> Snapshot 1 + + `[ 1, 2, 3, 4, 5 ]␊ + ^ Array must contain at most 3 element(s)` + +## _formatZodError: formats deeply nested schema + +> Snapshot 1 + + `{␊ + a: '1',␊ + ^ Expected number, received string␊ + b: {␊ + c: 2,␊ + ^ Expected string, received number␊ + d: [␊ + ...,␊ + /* [1] */ {␊ + e: 42,␊ + ^ Expected boolean, received number␊ + },␊ + /* [2] */ false,␊ + ^ Expected object, received boolean␊ + /* [3] */ {␊ + e: undefined,␊ + ^ Required␊ + },␊ + ],␊ + f: [Function: f],␊ + ^ Expected array, received function␊ + },␊ + g: undefined,␊ + ^ Required␊ + }` + +## _formatZodError: formats large actual values + +> Snapshot 1 + + `{␊ + a: {␊ + b: [␊ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,␊ + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,␊ + 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,␊ + 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43,␊ + 44, 45, 46, 47, 48, 49␊ + ],␊ + ^ Expected string, received array␊ + },␊ + }` + +## _formatZodError: formats union schema + +> Snapshot 1 + + `'a'␊ + ^ Expected boolean, received string␊ + Invalid literal value, expected 1` + +## _formatZodError: formats discriminated union schema + +> Snapshot 1 + + `{␊ + ...,␊ + a: false,␊ + ^ Expected number, received boolean␊ + }` + +## _formatZodError: formats discriminated union schema with invalid discriminator + +> Snapshot 1 + + `{␊ + type: 'c',␊ + ^ Invalid discriminator value. Expected 'a' | 'b'␊ + }` + +## _formatZodError: formats intersection schema + +> Snapshot 1 + + `false␊ + ^ Expected number, received boolean␊ + Invalid literal value, expected 2` + +## _formatZodError: formats object union schema + +> Snapshot 1 + + `{␊ + key: false,␊ + ^ Expected string, received boolean␊ + objects: [␊ + /* [0] */ false,␊ + ^ Expected object, received boolean␊ + ...,␊ + /* [2] */ {␊ + a: undefined,␊ + ^1 Required *or*␊ + b: undefined,␊ + ^1 Required *or*␊ + c: undefined,␊ + ^1 Required␊ + },␊ + /* [3] */ [],␊ + ^ Expected object, received array␊ + /* [4] */ {␊ + ...,␊ + a: undefined,␊ + ^2 Required *or*␊ + b: undefined,␊ + ^2 Required *or*␊ + c: undefined,␊ + ^2 Required␊ + },␊ + ],␊ + }` + +## _formatZodError: formats object union schema in colour + +> Snapshot 1 + + `{␊ + key: false,␊ +  ^ Expected string, received boolean␊ + objects: [␊ + /* [0] */ false,␊ +  ^ Expected object, received boolean␊ + /* [1] */ {␊ + a: undefined,␊ +  ^1 Required *or*␊ + b: undefined,␊ +  ^1 Required *or*␊ + c: undefined,␊ +  ^1 Required␊ + },␊ + /* [2] */ {␊ + a: undefined,␊ +  ^2 Required *or*␊ + b: undefined,␊ +  ^2 Required *or*␊ + c: undefined,␊ +  ^2 Required␊ + },␊ + /* [3] */ {␊ + a: undefined,␊ +  ^3 Required *or*␊ + b: undefined,␊ +  ^3 Required *or*␊ + c: undefined,␊ +  ^3 Required␊ + },␊ + /* [4] */ {␊ + a: undefined,␊ +  ^4 Required *or*␊ + b: undefined,␊ +  ^4 Required *or*␊ + c: undefined,␊ +  ^4 Required␊ + },␊ + /* [5] */ {␊ + a: undefined,␊ +  ^5 Required *or*␊ + b: undefined,␊ +  ^5 Required *or*␊ + c: undefined,␊ +  ^5 Required␊ + },␊ + /* [6] */ {␊ + a: undefined,␊ +  ^6 Required *or*␊ + b: undefined,␊ +  ^6 Required *or*␊ + c: undefined,␊ +  ^6 Required␊ + },␊ + /* [7] */ {␊ + a: undefined,␊ +  ^7 Required *or*␊ + b: undefined,␊ +  ^7 Required *or*␊ + c: undefined,␊ +  ^7 Required␊ + },␊ + ],␊ + }` + +## _formatZodError: formats tuple union schema + +> Snapshot 1 + + `{␊ + tuples: [␊ + /* [0] */ false,␊ + ^ Expected array, received boolean␊ + /* [1] */ { a: 1 },␊ + ^ Expected array, received object␊ + /* [2] */ [],␊ + ^ Array must contain at least 2 element(s)␊ + Array must contain at least 3 element(s)␊ + /* [3] */ [␊ + ...,␊ + /* [1] */ '3',␊ + ^ Expected number, received string␊ + ],␊ + /* [4] */ [␊ + /* [0] */ 4,␊ + ^1 Expected string, received number␊ + Expected boolean, received number *or*␊ + /* [1] */ 5,␊ + ^1 Expected boolean, received number *or*␊ + /* [2] */ 6,␊ + ^1 Expected boolean, received number␊ + ],␊ + /* [5] */ [␊ + /* [0] */ true,␊ + ^2 Expected string, received boolean *or*␊ + /* [1] */ 7,␊ + ^2 Expected boolean, received number␊ + ...,␊ + ],␊ + ],␊ + }` + +## _formatZodError: formats custom message schema + +> Snapshot 1 + + `{␊ + a: Symbol(kOoh),␊ + ^ Custom message␊ + with multiple␊ + lines␊ + }` diff --git a/packages/miniflare/test/snapshots/zod-format.spec.ts.snap b/packages/miniflare/test/snapshots/zod-format.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..4429411428f9bd64d74eade4f11d1093933d599f GIT binary patch literal 1519 zcmVqM=2DQbLW{*^f2}(iV33M zMcgtu&L4{i00000000BES<7x5MHKZzh-h}KvneS;V>^xGuIibwCxR%75-FfK5(vp= z7^9l*nsMWP4E;!Aj2CS90EE~F#9y&x#g-4?RzItH`e~0_8hiS2PMx~<)a~o?`}xdr z^$Y*kKUiP}7JK*EcXQXXY4D9}Joh};8>9H#M@!GN&A@!akguOJo1(X7a4uXmon;u9 zzC)M(xf}RDzc&mT(38>j#)ljH*!*DQqs`qpwS3k)L(ktWnI13$IiWpco`O84GoUfh z%ylhBom9}{j)L6T_dr~w=7pr@@Cwl}QWZ2Cqsi+CI7Qc@IzRACXCbJI>6^tRGCGB7 zD2dw%yikf%Oj#YN!!Ba#!}OP^Wb5~hs~$r8`}+#~f>8BwpMQg+E{}2Oa^M1hqY_{; zi!$VVj6%m?bJJl)2K5LN{!=LtL~sd??D4e& zTHS?X{s?L>*yWDD3z1AlgK5tCskCvWH0pWuvYS6tN!9~ZG)F`y#4t`rDr49|Mtr40 z!k>~ce%qMj`F*s9CU}bW9-%%-)0`Y#Y{F><5lvJo+7l%{>P^#0f7)fzEnM+Z3jgB? zw**!YQIMvf0h&TOo<_uW=m$u5oq&QVXn<@N&Iw``vzZfsSyF|cR4OzWTUwXMVSZGF zBBd*ePB;IR(((5uU#H;JzS?Plg0HWl$8|7x7Vx?%7;{BW8c;eDNRmN*@GPoP7O%1d zvN6)Il+W5KAdG>~wMdMs>`~yXDwqNlS=CsqI4NybcO*sA_!m^F$&1j@xpg3m)3PWQ zm*_lszL$_K+EqKH(li$J77S562*Jj0s1>qR7oTn(OP+a>dM1v+EYBuC4iv;)&TY

qOG`s&#wZ!T`8gV$vVgX8v>w zOBLh677rqHJ>xUc;$tuwp`A5_=#w$(g)_2ln|w5Zeal76OUwH58T-d=KN z*6C@z@#&uMuE<|FlWCWUrsXDP(4^T3@7$`1;!YDSf4XX-xMZSJp(<84o+Z}fJ+$sx zZn(ORPw)LFned-`_b1rCf*;t1A^Az<$mqiT2_bf5C-`<`^P5WiB%G^ud%H&Pgg*Ce zI%s@7H*kls+q*lJkPnu$Rk@1z7R@GqbYN$>DiJ|)5k*($gT-dy&HpgeU#`-+GgZkXSd87I~>=2*P8te1u4(f9Q=Z)&~ zs4+hLY?^`LPV_ZxqaIeaQ9-!0SVIH9{fpE)z1@!f3gW;UwBf~F46zFr+LLI@G~c#q zK@@jDg(kkNQ7+$P0}*fm3%hJ9TSIj~QUnUkdfoQc%3fz-mesp%*=9rj(wPYKaW=FP za<$#YT;XWkX%pN1e13y^0d2|RYz}WmRt(mlB3S$%CM6@_=0?AK?xT9LhAqp?F4N*4 zMf;VjSqE>rHnN%T(*>*CH27B9G&oLn@V>gVXRft#@!CD#&GurRNsQ@yq8$`Fv;q@~ VKzh-FLgx3b{|DuwRyz|M000pc+;IQ^ literal 0 HcmV?d00001 diff --git a/packages/miniflare/test/zod-format.spec.ts b/packages/miniflare/test/zod-format.spec.ts new file mode 100644 index 000000000..aab8cf32b --- /dev/null +++ b/packages/miniflare/test/zod-format.spec.ts @@ -0,0 +1,221 @@ +import assert from "assert"; +import test from "ava"; +import { $ as $colors } from "kleur/colors"; +import { _formatZodError } from "miniflare"; +import { z } from "zod"; + +const formatZodErrorMacro = test.macro({ + title(providedTitle) { + return `_formatZodError: formats ${providedTitle}`; + }, + exec(t, schema: z.ZodTypeAny, input: unknown, colour?: boolean) { + const result = schema.safeParse(input); + assert(!result.success); + // Disable colours by default for easier-to-read snapshots + $colors.enabled = colour ?? false; + const formatted = _formatZodError(result.error, input); + t.snapshot(formatted); + }, +}); + +test( + "primitive schema with primitive input", + formatZodErrorMacro, + z.number(), + false +); +test("primitive schema with object input", formatZodErrorMacro, z.string(), { + a: 1, + b: { c: 1 }, +}); + +test( + "object schema with primitive input", + formatZodErrorMacro, + z.object({ a: z.number() }), + true +); +test( + "object schema with object input", + formatZodErrorMacro, + z.object({ + a: z.string(), + b: z.number(), + c: z.boolean(), + d: z.number(), + e: z.number(), + f: z.boolean(), + g: z.boolean(), + }), + { + a: "", // Check skips valid + b: "2", + c: true, // Check skips valid + d: 4, // Check doesn't duplicate `...` when skipping valid + e: 5, + /*f*/ // Check required options + g: "7", + } +); +test( + "object schema with additional options", + formatZodErrorMacro, + z.object({ a: z.number() }).strict(), + { a: 1, b: 2 } +); + +test( + "array schema with primitive input", + formatZodErrorMacro, + z.array(z.boolean()), + 1 +); +test( + "array schema with array input", + formatZodErrorMacro, + z.array(z.number()), + [ + 1, // Check skips valid + 2, // Check doesn't duplicate `...` when skipping valid + "3", + 4, + 5, + false, + ] +); +test( + "array schema with additional options", + formatZodErrorMacro, + z.array(z.number()).max(3), + [1, 2, 3, 4, 5] +); + +test( + "deeply nested schema", + formatZodErrorMacro, + z.object({ + a: z.number(), + b: z.object({ + c: z.string(), + d: z.array(z.object({ e: z.boolean() })), + f: z.array(z.number()), + }), + g: z.string(), + }), + { + a: "1", + b: { + c: 2, + d: [{ e: true }, { e: 42 }, false, {}], + f: () => {}, + }, + } +); + +test( + "large actual values", + formatZodErrorMacro, + z.object({ + a: z.object({ + b: z.string(), + }), + }), + { + a: { + // Check indents inspected value at correct depth + b: Array.from({ length: 50 }).map((_, i) => i), + }, + } +); + +test( + "union schema", + formatZodErrorMacro, + z.union([z.boolean(), z.literal(1)]), + "a" +); + +const discriminatedUnionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("a"), + a: z.number(), + }), + z.object({ + type: z.literal("b"), + b: z.boolean(), + }), +]); +test( + "discriminated union schema", + formatZodErrorMacro, + discriminatedUnionSchema, + { + type: "a", + a: false, + } +); +test( + "discriminated union schema with invalid discriminator", + formatZodErrorMacro, + discriminatedUnionSchema, + { type: "c" } +); + +test( + "intersection schema", + formatZodErrorMacro, + z.intersection(z.number(), z.literal(2)), + false +); + +const objectUnionSchema = z.object({ + key: z.string(), + objects: z.array( + z.union([ + z.object({ a: z.number() }), + z.object({ b: z.boolean() }), + z.object({ c: z.string() }), + ]) + ), +}); +test("object union schema", formatZodErrorMacro, objectUnionSchema, { + key: false, + objects: [false, { a: 1 }, {}, [], { d: "" }], +}); +test( + "object union schema in colour", + formatZodErrorMacro, + objectUnionSchema, + { + key: false, + objects: [false, {}, {}, {}, {}, {}, /* cycle */ {}, {}], + }, + /* colour */ true +); + +test( + "tuple union schema", + formatZodErrorMacro, + z.object({ + tuples: z.array( + z.union([ + z.tuple([z.string(), z.number()]), + z.tuple([z.boolean(), z.boolean(), z.boolean()]), + ]) + ), + }), + { + tuples: [false, { a: 1 }, [], ["2", "3"], [4, 5, 6], [true, 7, false]], + } +); + +test( + "custom message schema", + formatZodErrorMacro, + z.object({ + a: z.custom(() => false, { + message: "Custom message\nwith multiple\nlines", + }), + }), + { a: Symbol("kOoh") } +);