diff --git a/lib/logger/utils.spec.ts b/lib/logger/utils.spec.ts index 376584700b8f40..ac944c5ad2ca9c 100644 --- a/lib/logger/utils.spec.ts +++ b/lib/logger/utils.spec.ts @@ -1,4 +1,9 @@ -import { sanitizeValue, validateLogLevel } from './utils'; +import { z } from 'zod'; +import prepareError, { + prepareZodIssues, + sanitizeValue, + validateLogLevel, +} from './utils'; describe('logger/utils', () => { afterEach(() => { @@ -46,4 +51,138 @@ describe('logger/utils', () => { `('sanitizeValue("$input") == "$output"', ({ input, output }) => { expect(sanitizeValue(input)).toBe(output); }); + + describe('prepareError', () => { + function getError( + schema: T, + input: unknown + ): z.ZodError | null { + try { + schema.parse(input); + } catch (error) { + if (error instanceof z.ZodError) { + return error; + } + } + throw new Error('Expected error'); + } + + function prepareIssues( + schema: T, + input: unknown + ): unknown | null { + const error = getError(schema, input); + return error ? prepareZodIssues(error.format()) : null; + } + + it('prepareZodIssues', () => { + expect(prepareIssues(z.string(), 42)).toBe( + 'Expected string, received number' + ); + + expect(prepareIssues(z.string().array(), 42)).toBe( + 'Expected array, received number' + ); + + expect( + prepareIssues(z.string().array(), ['foo', 'bar', 42, 42, 42, 42, 42]) + ).toEqual({ + '2': 'Expected string, received number', + '3': 'Expected string, received number', + '4': 'Expected string, received number', + ___: '... 2 more', + }); + + expect( + prepareIssues(z.record(z.string()), { + foo: 'foo', + bar: 'bar', + key1: 42, + key2: 42, + key3: 42, + key4: 42, + key5: 42, + }) + ).toEqual({ + key1: 'Expected string, received number', + key2: 'Expected string, received number', + key3: 'Expected string, received number', + ___: '... 2 more', + }); + + expect( + prepareIssues( + z.object({ + foo: z.object({ + bar: z.string(), + }), + }), + { foo: { bar: [], baz: 42 } } + ) + ).toEqual({ + foo: { + bar: 'Expected string, received array', + }, + }); + + expect( + prepareIssues( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('foo') }), + z.object({ type: z.literal('bar') }), + ]), + { type: 'baz' } + ) + ).toEqual({ + type: "Invalid discriminator value. Expected 'foo' | 'bar'", + }); + + expect( + prepareIssues( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('foo') }), + z.object({ type: z.literal('bar') }), + ]), + {} + ) + ).toEqual({ + type: "Invalid discriminator value. Expected 'foo' | 'bar'", + }); + + expect( + prepareIssues( + z.discriminatedUnion('type', [ + z.object({ type: z.literal('foo') }), + z.object({ type: z.literal('bar') }), + ]), + 42 + ) + ).toBe('Expected object, received number'); + }); + + it('prepareError', () => { + const err = getError( + z.object({ + foo: z.object({ + bar: z.object({ + baz: z.string(), + }), + }), + }), + { foo: { bar: { baz: 42 } } } + ); + + expect(prepareError(err!)).toEqual({ + issues: { + foo: { + bar: { + baz: 'Expected string, received number', + }, + }, + }, + message: 'Schema error', + stack: expect.stringMatching(/^ZodError: Schema error/), + }); + }); + }); }); diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts index 4d0297cedb823b..84144229650e37 100644 --- a/lib/logger/utils.ts +++ b/lib/logger/utils.ts @@ -3,6 +3,7 @@ import is from '@sindresorhus/is'; import bunyan from 'bunyan'; import fs from 'fs-extra'; import { RequestError as HttpError } from 'got'; +import { ZodError } from 'zod'; import { redactedFields, sanitize } from '../util/sanitize'; import type { BunyanRecord, BunyanStream } from './types'; @@ -46,7 +47,74 @@ const contentFields = [ 'yarnLockParsed', ]; +type ZodShortenedIssue = + | null + | string + | string[] + | { + [key: string]: ZodShortenedIssue; + }; + +export function prepareZodIssues(input: unknown): ZodShortenedIssue { + // istanbul ignore if + if (!is.plainObject(input)) { + return null; + } + + let err: null | string | string[] = null; + if (is.array(input._errors, is.string)) { + // istanbul ignore else + if (input._errors.length === 1) { + err = input._errors[0]; + } else if (input._errors.length > 1) { + err = input._errors; + } else { + err = null; + } + } + delete input._errors; + + if (is.emptyObject(input)) { + return err; + } + + const output: Record = {}; + const entries = Object.entries(input); + for (const [key, value] of entries.slice(0, 3)) { + const child = prepareZodIssues(value); + if (child !== null) { + output[key] = child; + } + } + + if (entries.length > 3) { + output['___'] = `... ${entries.length - 3} more`; + } + + return output; +} + +export function prepareZodError(err: ZodError): Record { + // istanbul ignore next + Object.defineProperty(err, 'message', { + get: () => 'Schema error', + set: (_) => { + _; + }, + }); + + return { + message: err.message, + stack: err.stack, + issues: prepareZodIssues(err.format()), + }; +} + export default function prepareError(err: Error): Record { + if (err instanceof ZodError) { + return prepareZodError(err); + } + const response: Record = { ...err, };