From 75b4312e6ae0ead70b961b8571806c00b868a2f5 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sun, 6 Oct 2024 14:23:07 +0200 Subject: [PATCH] Set error cause property Since EcmaScript 2022, `Error` objects support a `cause` property, allowing for tracking the root cause of an error. This commit set this property where appropriate. Since vitest's `toThrowError()` does not support checking the `cause` of an error [1], we use utility functions to catch and extract errors thrown by a function. This allows checking for individual properties of an error. [1]: https://github.com/vitest-dev/vitest/issues/5697 --- src/font-loader.test.ts | 20 +++++++++----------- src/font-loader.ts | 4 +--- src/image-loader.test.ts | 26 +++++++++++++------------- src/image-loader.ts | 8 ++------ src/images.ts | 4 +--- src/test/test-utils.ts | 33 +++++++++++++++++++++++++++++++++ src/types.test.ts | 15 ++++++++------- src/types.ts | 9 +++------ 8 files changed, 70 insertions(+), 49 deletions(-) diff --git a/src/font-loader.test.ts b/src/font-loader.test.ts index 8171f75..b6467dc 100644 --- a/src/font-loader.test.ts +++ b/src/font-loader.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FontLoader, FontStore } from './font-loader.ts'; import type { Font, FontDef, FontSelector } from './fonts.ts'; -import { fakeFont, mkData } from './test/test-utils.ts'; +import { catchErrorAsync, fakeFont, mkData } from './test/test-utils.ts'; describe('font-loader', () => { let normalFont: FontDef; @@ -142,9 +142,10 @@ describe('font-loader', () => { it('rejects if font could not be loaded', async () => { const store = new FontStore(fontLoader); - await expect(store.selectFont({ fontFamily: 'foo' })).rejects.toThrowError( - "Could not load font for 'foo', style=normal, weight=normal: No such font defined", - ); + const error = await catchErrorAsync(() => store.selectFont({ fontFamily: 'foo' })); + + expect(error.message).toBe("Could not load font for 'foo', style=normal, weight=normal"); + expect(error.cause).toEqual(new Error('No such font defined')); }); it('creates fontkit font object', async () => { @@ -186,13 +187,10 @@ describe('font-loader', () => { it('caches errors from font loader', async () => { const store = new FontStore(fontLoader); - await expect(store.selectFont({ fontFamily: 'foo' })).rejects.toThrowError( - "Could not load font for 'foo', style=normal, weight=normal: No such font defined", - ); - await expect(store.selectFont({ fontFamily: 'foo' })).rejects.toThrowError( - "Could not load font for 'foo', style=normal, weight=normal: No such font defined", - ); - expect(fontLoader.loadFont).toHaveBeenCalledTimes(1); + const error = await catchErrorAsync(() => store.selectFont({ fontFamily: 'foo' })); + + expect(error.message).toBe("Could not load font for 'foo', style=normal, weight=normal"); + expect(error.cause).toEqual(new Error('No such font defined')); }); }); }); diff --git a/src/font-loader.ts b/src/font-loader.ts index 9c5318b..ca8cd6c 100644 --- a/src/font-loader.ts +++ b/src/font-loader.ts @@ -111,9 +111,7 @@ export class FontStore { } catch (error) { const { fontFamily: family, fontStyle: style, fontWeight: weight } = selector; const selectorStr = `'${family}', style=${style ?? 'normal'}, weight=${weight ?? 'normal'}`; - throw new Error( - `Could not load font for ${selectorStr}: ${(error as Error)?.message ?? error}`, - ); + throw new Error(`Could not load font for ${selectorStr}`, { cause: error }); } const fkFont = fontkit.create(loadedFont.data); return pickDefined({ diff --git a/src/image-loader.test.ts b/src/image-loader.test.ts index 6098520..6d2e878 100644 --- a/src/image-loader.test.ts +++ b/src/image-loader.test.ts @@ -5,6 +5,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageLoader, ImageStore } from './image-loader.ts'; import type { ImageSelector } from './images.ts'; +import { catchErrorAsync } from './test/test-utils.ts'; describe('image-loader', () => { let libertyJpg: Uint8Array; @@ -21,9 +22,10 @@ describe('image-loader', () => { it('rejects if image cannot be loaded', async () => { const loader = new ImageLoader([]); - await expect(loader.loadImage({ name: 'foo' })).rejects.toThrowError( - "Could not load image 'foo': ENOENT: no such file or directory, open 'foo'", - ); + const error = await catchErrorAsync(() => loader.loadImage({ name: 'foo' })); + + expect(error.message).toBe("Could not load image 'foo'"); + expect(error.cause).toEqual(new Error("ENOENT: no such file or directory, open 'foo'")); }); it('returns data and metadata for registered images', async () => { @@ -64,9 +66,10 @@ describe('image-loader', () => { it('rejects if image could not be loaded', async () => { const store = new ImageStore(imageLoader); - await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError( - "Could not load image 'foo': No such image", - ); + const error = await catchErrorAsync(() => store.selectImage({ name: 'foo' })); + + expect(error.message).toBe("Could not load image 'foo'"); + expect(error.cause).toEqual(new Error('No such image')); }); it('reads format, width and height from JPEG image', async () => { @@ -120,13 +123,10 @@ describe('image-loader', () => { it('caches errors from image loader', async () => { const store = new ImageStore(imageLoader); - await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError( - "Could not load image 'foo': No such image", - ); - await expect(store.selectImage({ name: 'foo' })).rejects.toThrowError( - "Could not load image 'foo': No such image", - ); - expect(imageLoader.loadImage).toHaveBeenCalledTimes(1); + const error = await catchErrorAsync(() => store.selectImage({ name: 'foo' })); + + expect(error.message).toBe("Could not load image 'foo'"); + expect(error.cause).toEqual(new Error('No such image')); }); }); }); diff --git a/src/image-loader.ts b/src/image-loader.ts index 5ce844a..78c641a 100644 --- a/src/image-loader.ts +++ b/src/image-loader.ts @@ -28,9 +28,7 @@ export class ImageLoader { data = await readFile(selector.name); return { data }; } catch (error) { - throw new Error( - `Could not load image '${selector.name}': ${(error as Error)?.message ?? error}`, - ); + throw new Error(`Could not load image '${selector.name}'`, { cause: error }); } } } @@ -53,9 +51,7 @@ export class ImageStore { try { loadedImage = await this.#imageLoader.loadImage(selector); } catch (error) { - throw new Error( - `Could not load image '${selector.name}': ${(error as Error)?.message ?? error}`, - ); + throw new Error(`Could not load image '${selector.name}'`, { cause: error }); } const { data } = loadedImage; const format = determineImageFormat(data); diff --git a/src/images.ts b/src/images.ts index 626d448..9dc3e4b 100644 --- a/src/images.ts +++ b/src/images.ts @@ -50,9 +50,7 @@ export function registerImage(image: Image, pdfDoc: PDFDocument) { : JpegEmbedder.for(image.data)); embedder.embedIntoContext(pdfDoc.context, ref); } catch (error) { - throw new Error( - `Could not embed image "${image.name}": ${(error as Error)?.message ?? error}`, - ); + throw new Error(`Could not embed image "${image.name}"`, { cause: error }); } }, }); diff --git a/src/test/test-utils.ts b/src/test/test-utils.ts index 456e540..46cfa4a 100644 --- a/src/test/test-utils.ts +++ b/src/test/test-utils.ts @@ -1,5 +1,6 @@ import type { PDFDocument, PDFFont, PDFPage } from 'pdf-lib'; import { PDFContext, PDFName, PDFRef } from 'pdf-lib'; +import { printValue } from 'src/print-value.ts'; import type { Font } from '../fonts.ts'; import { weightToNumber } from '../fonts.ts'; @@ -125,3 +126,35 @@ export function getContentStream(page: Page) { export function mkData(value: string) { return new Uint8Array(value.split('').map((c) => c.charCodeAt(0))); } + +export function catchError(fn: (...args: any[]) => any): Error { + const result = { value: undefined as unknown }; + try { + result.value = fn(); + throw result; + } catch (error) { + if (error instanceof Error) { + return error; + } + if (error === result) { + throw new Error(`Expected function to throw, but it returned ${printValue(result.value)}`); + } + throw new Error(`Expected function to throw Error, but it threw ${printValue(error)}`); + } +} + +export async function catchErrorAsync(fn: (...args: any[]) => any): Promise { + const result = { value: undefined as unknown }; + try { + result.value = await fn(); + throw result; + } catch (error) { + if (error instanceof Error) { + return error; + } + if (error === result) { + throw new Error(`Expected function to throw, but it returned ${printValue(result.value)}`); + } + throw new Error(`Expected function to throw Error, but it threw ${printValue(error)}`); + } +} diff --git a/src/types.test.ts b/src/types.test.ts index 8278672..e5eeddc 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { catchError } from './test/test-utils.ts'; import { dynamic, isObject, @@ -190,11 +191,10 @@ describe('types', () => { }); it('throws when function returns invalid value', () => { - const resolve = validate(() => 23); + const error = catchError(validate(() => 23)); - expect(() => resolve()).toThrowError( - 'Supplied function for "test" returned invalid value: Expected string, got: 23', - ); + expect(error.message).toBe('Supplied function for "test" returned invalid value'); + expect(error.cause).toEqual(new Error('Expected string, got: 23')); }); it('throws when function throws', () => { @@ -202,9 +202,10 @@ describe('types', () => { throw new Error('test error'); }); - expect(() => resolve()).toThrowError( - 'Supplied function for "test" threw: Error: test error', - ); + const error = catchError(resolve); + + expect(error.message).toBe('Supplied function for "test" threw error'); + expect(error.cause).toEqual(new Error('test error')); }); }); }); diff --git a/src/types.ts b/src/types.ts index 9fc10da..33b2b57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,8 +100,7 @@ export function dynamic( try { return asType(result, type); } catch (error) { - const errorStr = error instanceof Error ? (error.message ?? String(error)) : String(error); - throw new Error(`${subject} returned invalid value: ${errorStr}`); + throw new Error(`${subject} returned invalid value`, { cause: error }); } }; }; @@ -110,10 +109,8 @@ export function dynamic( function safeCall(fn: (...params: unknown[]) => unknown, args: unknown[], subject: string) { try { return fn(...args); - } catch (error: unknown) { - const errorStr = - error instanceof Error ? (error.stack ?? error.message ?? String(error)) : String(error); - throw new Error(`${subject} threw: ${errorStr}`); + } catch (error) { + throw new Error(`${subject} threw error`, { cause: error }); } }