Skip to content

Commit

Permalink
Set error cause property
Browse files Browse the repository at this point in the history
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]: vitest-dev/vitest#5697
  • Loading branch information
ralfstx committed Oct 6, 2024
1 parent a87db52 commit 75b4312
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 49 deletions.
20 changes: 9 additions & 11 deletions src/font-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'));
});
});
});
Expand Down
4 changes: 1 addition & 3 deletions src/font-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
26 changes: 13 additions & 13 deletions src/image-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'));
});
});
});
8 changes: 2 additions & 6 deletions src/image-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
Expand All @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions src/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
},
});
Expand Down
33 changes: 33 additions & 0 deletions src/test/test-utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Error> {
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)}`);
}
}
15 changes: 8 additions & 7 deletions src/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';

import { catchError } from './test/test-utils.ts';
import {
dynamic,
isObject,
Expand Down Expand Up @@ -190,21 +191,21 @@ 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', () => {
const resolve = validate(() => {
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'));
});
});
});
Expand Down
9 changes: 3 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ export function dynamic<T = unknown>(
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 });
}
};
};
Expand All @@ -110,10 +109,8 @@ export function dynamic<T = unknown>(
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 });
}
}

Expand Down

0 comments on commit 75b4312

Please sign in to comment.