diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 3d4301b305db..645c965ec138 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,4 +1,5 @@ import { inspectValue } from '@crawlee/utils'; +import type { z } from 'zod'; /** * Errors of `NonRetryableError` type will never be retried by the crawler. @@ -90,3 +91,73 @@ export class ServiceConflictError extends Error { * Subclasses can catch this error to skip their own navigation-dependent logic. */ export class NavigationSkippedError extends NonRetryableError {} + +/** Formats a zod issue path like `groups[0]` or `countryCode`. */ +function formatIssuePath(path: readonly PropertyKey[]): string { + let out = ''; + for (const key of path) { + if (typeof key === 'number') out += `[${key}]`; + else out += out ? `.${String(key)}` : String(key); + } + return out; +} + +/** Reads the value at `path` from the validated input, to include in the error. */ +function valueAtPath(root: unknown, path: readonly PropertyKey[]): unknown { + let current = root; + for (const key of path) { + if (current === null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +/** Renders a primitive received value for an error; skips objects/Dates (noisy). */ +function describeReceived(value: unknown): string | undefined { + switch (typeof value) { + case 'string': + return value; + case 'number': + case 'boolean': + case 'bigint': + return String(value); + default: + return undefined; + } +} + +/** + * Formats a `ZodError` as a plain, human-readable message that names the + * offending field *and* the value it received (e.g. ``must match pattern + * /^[A-Z]{2}$/ at `countryCode`, got `CZE` ``) — closer to the old `ow` errors + * than zod's default, which omits the received value. + */ +function formatZodError(error: z.ZodError, root: unknown): string { + return error.issues + .map((issue) => { + const location = issue.path.length ? ` at \`${formatIssuePath(issue.path)}\`` : ''; + const received = describeReceived(valueAtPath(root, issue.path)); + const got = received === undefined ? '' : `, got \`${received}\``; + return `${issue.message}${location}${got}`; + }) + .join('\n'); +} + +/** + * Thrown when an argument fails schema validation. + * + * Its `message` is a human-readable sentence naming the offending field and the + * value it received (rather than a raw JSON dump). The structured + * {@link https://zod.dev | zod} issues are available on `issues`, and the + * original `ZodError` on `cause`, for programmatic inspection. + */ +export class ArgumentValidationError extends Error { + /** Structured issues from the underlying schema check. */ + readonly issues: z.ZodError['issues']; + + constructor(error: z.ZodError, value: unknown) { + super(formatZodError(error, value), { cause: error }); + this.name = 'ArgumentValidationError'; + this.issues = error.issues; + } +} diff --git a/test/core/errors.test.ts b/test/core/errors.test.ts new file mode 100644 index 000000000000..d459b61316a6 --- /dev/null +++ b/test/core/errors.test.ts @@ -0,0 +1,32 @@ +import { ArgumentValidationError } from '@crawlee/core'; +import { describe, expect, test } from 'vitest'; +import { z } from 'zod'; + +describe('ArgumentValidationError', () => { + const schema = z + .object({ + countryCode: z.string().regex(/^[A-Z]{2}$/), + retries: z.number().optional(), + }) + .strict(); + + test('message names the offending field and the value it received', () => { + const error = new ArgumentValidationError(schema.safeParse({ countryCode: 'CZE' }).error!, { + countryCode: 'CZE', + }); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ArgumentValidationError'); + expect(error.message).toBe('Invalid string: must match pattern /^[A-Z]{2}$/ at `countryCode`, got `CZE`'); + }); + + test('exposes structured issues and keeps the ZodError as cause', () => { + const zodError = schema.safeParse({ retries: 'lots' }).error!; + const error = new ArgumentValidationError(zodError, { retries: 'lots' }); + + // `issues` is reachable directly, without digging into `cause`. + expect(error.issues).toBe(zodError.issues); + expect(error.issues.map((issue) => issue.path)).toContainEqual(['retries']); + expect(error.cause).toBe(zodError); + }); +});