mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
onCalendarAlert now fires for a task's display alert too, as
{ id, taskId, taskTitle, triggerTime }, and closing that reminder's toast
fires onCalendarAlertAcknowledge with { alertId, taskId }. A task's
reminder reaches only plugins with tasks:read, an event's only plugins
with calendar:read; either permission registers the hooks, and the host
hands each call only to a plugin holding the one it needs.
onBeforeCalendarAlert, a new intercept, runs before Bulwark shows a
reminder of an event or a task, with the same payload. A plugin that
shows reminders itself returns false: Bulwark then plays no sound, sets
no title alert and shows no toast for that reminder, while
onCalendarAlert still fires. Like the reminder it runs in every open
tab, and each tab follows its own answer. It fails open within five
seconds, so a hung plugin delays a reminder but never drops it. Without
a handler the reminder shows at once, as before.
518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks, isExternalAttachmentResult, setHookDialogGate, renderHooks } from '../plugin-hooks';
|
|
import { awaitPrompt, hasDialogFor, head, resolveHead, whenNoDialogFor, resetDialogLimits } from '../plugin-sandbox/host-dialog';
|
|
import type { CalendarEventFormView } from '../plugin-types';
|
|
|
|
beforeEach(() => {
|
|
pluginErrorTracker.resetAll();
|
|
clearAllHooks();
|
|
});
|
|
|
|
describe('HookBus', () => {
|
|
describe('register / size / dispose', () => {
|
|
it('registers a handler', () => {
|
|
const bus = new HookBus();
|
|
bus.register('p1', vi.fn());
|
|
expect(bus.size).toBe(1);
|
|
});
|
|
|
|
it('dispose removes the handler', () => {
|
|
const bus = new HookBus();
|
|
const d = bus.register('p1', vi.fn());
|
|
d.dispose();
|
|
expect(bus.size).toBe(0);
|
|
});
|
|
|
|
it('registering multiple handlers', () => {
|
|
const bus = new HookBus();
|
|
bus.register('p1', vi.fn());
|
|
bus.register('p2', vi.fn());
|
|
expect(bus.size).toBe(2);
|
|
});
|
|
|
|
it('removePlugin removes all handlers for that plugin', () => {
|
|
const bus = new HookBus();
|
|
bus.register('p1', vi.fn());
|
|
bus.register('p1', vi.fn());
|
|
bus.register('p2', vi.fn());
|
|
bus.removePlugin('p1');
|
|
expect(bus.size).toBe(1);
|
|
});
|
|
|
|
it('clear removes all handlers', () => {
|
|
const bus = new HookBus();
|
|
bus.register('p1', vi.fn());
|
|
bus.register('p2', vi.fn());
|
|
bus.clear();
|
|
expect(bus.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('emit (observer)', () => {
|
|
it('calls all handlers with args', async () => {
|
|
const bus = new HookBus<(x: number) => void>();
|
|
const fn1 = vi.fn();
|
|
const fn2 = vi.fn();
|
|
bus.register('p1', fn1);
|
|
bus.register('p2', fn2);
|
|
await bus.emit(42);
|
|
expect(fn1).toHaveBeenCalledWith(42);
|
|
expect(fn2).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it('calls handlers in order', async () => {
|
|
const bus = new HookBus<() => void>();
|
|
const order: number[] = [];
|
|
bus.register('p1', () => order.push(200), 200);
|
|
bus.register('p2', () => order.push(50), 50);
|
|
bus.register('p3', () => order.push(100), 100);
|
|
await bus.emit();
|
|
expect(order).toEqual([50, 100, 200]);
|
|
});
|
|
|
|
it('catches handler errors and records them', async () => {
|
|
const bus = new HookBus<() => void>();
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
bus.register('p1', () => { throw new Error('fail'); });
|
|
await bus.emit();
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('skips disabled plugins', async () => {
|
|
const bus = new HookBus<() => void>();
|
|
const fn = vi.fn();
|
|
bus.register('p1', fn);
|
|
|
|
// Manually trigger circuit breaker
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
for (let i = 0; i < 3; i++) {
|
|
pluginErrorTracker.record('p1', new Error('test'));
|
|
}
|
|
consoleSpy.mockRestore();
|
|
|
|
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
|
|
await bus.emit();
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('emitSync', () => {
|
|
it('calls handlers synchronously', () => {
|
|
const bus = new HookBus<(x: string) => void>();
|
|
const fn = vi.fn();
|
|
bus.register('p1', fn);
|
|
bus.emitSync('hello');
|
|
expect(fn).toHaveBeenCalledWith('hello');
|
|
});
|
|
|
|
it('catches errors without throwing', () => {
|
|
const bus = new HookBus<() => void>();
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
bus.register('p1', () => { throw new Error('boom'); });
|
|
expect(() => bus.emitSync()).not.toThrow();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('intercept', () => {
|
|
it('returns true when all handlers pass', async () => {
|
|
const bus = new HookBus<() => boolean>();
|
|
bus.register('p1', () => true);
|
|
bus.register('p2', () => true);
|
|
const result = await bus.intercept();
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('returns false when any handler returns false', async () => {
|
|
const bus = new HookBus<() => boolean>();
|
|
bus.register('p1', () => true);
|
|
bus.register('p2', () => false);
|
|
const result = await bus.intercept();
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('stops early on false (short-circuits)', async () => {
|
|
const bus = new HookBus<() => boolean>();
|
|
const fn3 = vi.fn(() => true);
|
|
bus.register('p1', () => true, 10);
|
|
bus.register('p2', () => false, 20);
|
|
bus.register('p3', fn3, 30);
|
|
await bus.intercept();
|
|
expect(fn3).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns true when no handlers registered', async () => {
|
|
const bus = new HookBus<() => boolean>();
|
|
expect(await bus.intercept()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('transform', () => {
|
|
it('chains values through handlers', async () => {
|
|
const bus = new HookBus<(val: number) => number>();
|
|
bus.register('p1', (val: number) => val * 2);
|
|
bus.register('p2', (val: number) => val + 1);
|
|
const result = await bus.transform(5);
|
|
expect(result).toBe(11); // (5 * 2) + 1
|
|
});
|
|
|
|
it('returns initial value when no handlers', async () => {
|
|
const bus = new HookBus<(val: string) => string>();
|
|
const result = await bus.transform('hello');
|
|
expect(result).toBe('hello');
|
|
});
|
|
|
|
it('skips handler that returns undefined', async () => {
|
|
const bus = new HookBus<(val: number) => number | undefined>();
|
|
bus.register('p1', () => undefined);
|
|
bus.register('p2', (val: number) => val + 10);
|
|
const result = await bus.transform(5);
|
|
expect(result).toBe(15);
|
|
});
|
|
|
|
it('drops a handler that overruns the bus timeout', async () => {
|
|
vi.useFakeTimers();
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
const bus = new HookBus<(val: number) => Promise<number>>(50);
|
|
bus.register('slow', () => new Promise<number>(resolve => setTimeout(() => resolve(99), 500)));
|
|
const pending = bus.transform(1);
|
|
await vi.advanceTimersByTimeAsync(60);
|
|
expect(await pending).toBe(1); // initial value kept, handler timed out
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('honours a per-bus timeout longer than the 5s default', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const bus = new HookBus<(val: number) => Promise<number>>(120_000);
|
|
bus.register('slow', () => new Promise<number>(resolve => setTimeout(() => resolve(99), 30_000)));
|
|
const pending = bus.transform(1);
|
|
await vi.advanceTimersByTimeAsync(30_100);
|
|
expect(await pending).toBe(99);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// #1207: a send-takeover handler that failed or was cancelled let the host
|
|
// delete the draft, and one that threw let the host send the message unsigned.
|
|
describe('onComposeSend takeOver', () => {
|
|
it('reports a send the plugin made itself', async () => {
|
|
emailHooks.onComposeSend.register('smime', () => false);
|
|
expect(await emailHooks.onComposeSend.takeOver({})).toBe('handled');
|
|
});
|
|
|
|
it('reports an abort the plugin asked for', async () => {
|
|
emailHooks.onComposeSend.register('smime', () => ({ abort: true }));
|
|
expect(await emailHooks.onComposeSend.takeOver({})).toBe('aborted');
|
|
});
|
|
|
|
it('leaves the send to the host when no plugin takes it', async () => {
|
|
emailHooks.onComposeSend.register('other', () => undefined);
|
|
emailHooks.onComposeSend.register('guard', () => true);
|
|
expect(await emailHooks.onComposeSend.takeOver({})).toBe('default');
|
|
});
|
|
|
|
it('fails closed when a handler throws', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const later = vi.fn(() => false);
|
|
emailHooks.onComposeSend.register('smime', () => { throw new Error('no key'); }, 10);
|
|
emailHooks.onComposeSend.register('later', later, 20);
|
|
expect(await emailHooks.onComposeSend.takeOver({})).toBe('failed');
|
|
expect(later).not.toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('waits for a slow passphrase prompt and fails closed after the budget', async () => {
|
|
vi.useFakeTimers();
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
emailHooks.onComposeSend.register('smime', () => new Promise(resolve => setTimeout(() => resolve(false), 5 * 60_000)));
|
|
const slow = emailHooks.onComposeSend.takeOver({});
|
|
await vi.advanceTimersByTimeAsync(5 * 60_000 + 100);
|
|
expect(await slow).toBe('handled');
|
|
|
|
emailHooks.onComposeSend.clear();
|
|
emailHooks.onComposeSend.register('stuck', () => new Promise(() => {}));
|
|
const stuck = emailHooks.onComposeSend.takeOver({});
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000 + 100);
|
|
expect(await stuck).toBe('failed');
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('onBeforeBlobUpload offload budget', () => {
|
|
it('gives the hook room for a real upload, not the 5s default', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
emailHooks.onBeforeBlobUpload.register('offloader', () =>
|
|
new Promise(resolve => setTimeout(() => resolve({
|
|
externalAttachment: true, html: '<a href="https://drop.example/x">x</a>', text: 'https://drop.example/x',
|
|
}), 20_000)),
|
|
);
|
|
const pending = emailHooks.onBeforeBlobUpload.transform<unknown>('file-id');
|
|
await vi.advanceTimersByTimeAsync(20_100);
|
|
expect(isExternalAttachmentResult(await pending)).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('onBeforeCalendarAlert budget', () => {
|
|
it('shows the reminder after 5s when a plugin hangs, not after the dialog budget', async () => {
|
|
vi.useFakeTimers();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
calendarHooks.onBeforeCalendarAlert.register('hung', () => new Promise(() => {}));
|
|
let allowed: boolean | undefined;
|
|
void calendarHooks.onBeforeCalendarAlert.intercept({ id: 'a1', taskId: 't1' }).then((a) => { allowed = a; });
|
|
await vi.advanceTimersByTimeAsync(4_900);
|
|
expect(allowed).toBeUndefined();
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(allowed).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isExternalAttachmentResult', () => {
|
|
const valid = { externalAttachment: true, html: '<a href="https://x/y">y</a>', text: 'https://x/y' };
|
|
|
|
it('accepts a well-formed result', () => {
|
|
expect(isExternalAttachmentResult(valid)).toBe(true);
|
|
});
|
|
|
|
it('accepts an optional fileId', () => {
|
|
expect(isExternalAttachmentResult({ ...valid, fileId: 'abc' })).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['a plain file id', 'some-uuid'],
|
|
['null', null],
|
|
['undefined', undefined],
|
|
['missing marker', { html: 'x', text: 'x' }],
|
|
['falsy marker', { ...valid, externalAttachment: false }],
|
|
['missing text', { externalAttachment: true, html: 'x' }],
|
|
['non-string html', { externalAttachment: true, html: 1, text: 'x' }],
|
|
['non-string fileId', { ...valid, fileId: 42 }],
|
|
])('rejects %s', (_label, value) => {
|
|
expect(isExternalAttachmentResult(value)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PluginErrorTracker', () => {
|
|
it('is not disabled initially', () => {
|
|
expect(pluginErrorTracker.isDisabled('some-plugin')).toBe(false);
|
|
});
|
|
|
|
it('disables after threshold errors', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
pluginErrorTracker.record('p1', new Error('1'));
|
|
pluginErrorTracker.record('p1', new Error('2'));
|
|
expect(pluginErrorTracker.isDisabled('p1')).toBe(false);
|
|
pluginErrorTracker.record('p1', new Error('3'));
|
|
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls auto-disable callback', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const cb = vi.fn();
|
|
pluginErrorTracker.setAutoDisableCallback(cb);
|
|
for (let i = 0; i < 3; i++) {
|
|
pluginErrorTracker.record('p2', new Error(`err-${i}`));
|
|
}
|
|
expect(cb).toHaveBeenCalledWith('p2', expect.any(Error));
|
|
consoleSpy.mockRestore();
|
|
pluginErrorTracker.setAutoDisableCallback(() => {});
|
|
});
|
|
|
|
it('reset re-enables a plugin', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
for (let i = 0; i < 3; i++) {
|
|
pluginErrorTracker.record('p3', new Error(`err-${i}`));
|
|
}
|
|
expect(pluginErrorTracker.isDisabled('p3')).toBe(true);
|
|
pluginErrorTracker.reset('p3');
|
|
expect(pluginErrorTracker.isDisabled('p3')).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Hook domain instances', () => {
|
|
it('emailHooks has expected buses', () => {
|
|
expect(emailHooks.onEmailOpen).toBeInstanceOf(HookBus);
|
|
expect(emailHooks.onBeforeEmailSend).toBeInstanceOf(HookBus);
|
|
expect(emailHooks.onAfterEmailDelete).toBeInstanceOf(HookBus);
|
|
expect(emailHooks.onNewEmailReceived).toBeInstanceOf(HookBus);
|
|
});
|
|
|
|
it('calendarHooks has expected buses', () => {
|
|
expect(calendarHooks.onCalendarEventOpen).toBeInstanceOf(HookBus);
|
|
expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus);
|
|
expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus);
|
|
});
|
|
|
|
it('onCalendarEventFormSave pipes a returned patch through transform with the form view', async () => {
|
|
const { calendarFormHooks } = await import('../plugin-hooks');
|
|
const views: CalendarEventFormView[] = [];
|
|
const disposable = calendarFormHooks.onCalendarEventFormSave.register(
|
|
'test-p',
|
|
async (_patch, view) => {
|
|
views.push(view);
|
|
return { virtualLocation: 'https://meet.example.com/rooms/x?role=host' };
|
|
},
|
|
);
|
|
|
|
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
|
|
{},
|
|
{
|
|
title: 'Team Sync',
|
|
description: '',
|
|
start: '2026-10-09T10:00:00',
|
|
end: '2026-10-09T11:00:00',
|
|
isAllDay: false,
|
|
location: '',
|
|
virtualLocation: '',
|
|
calendarId: 'cal-1',
|
|
uid: 'uid-1',
|
|
attendees: ['a@sunbeam.pt'],
|
|
},
|
|
);
|
|
|
|
expect(patch).toEqual({ virtualLocation: 'https://meet.example.com/rooms/x?role=host' });
|
|
expect(views[0]).toMatchObject({ title: 'Team Sync', uid: 'uid-1', attendees: ['a@sunbeam.pt'] });
|
|
disposable.dispose();
|
|
});
|
|
|
|
it('onCalendarEventFormSave keeps the form unchanged when handlers return undefined', async () => {
|
|
const { calendarFormHooks } = await import('../plugin-hooks');
|
|
const disposable = calendarFormHooks.onCalendarEventFormSave.register('test-p', async () => undefined);
|
|
|
|
const patch = await calendarFormHooks.onCalendarEventFormSave.transform(
|
|
{ virtualLocation: 'keep-me' },
|
|
{
|
|
title: 'T', description: '', start: '', end: '', isAllDay: false,
|
|
location: '', virtualLocation: '', calendarId: 'cal-1', attendees: [],
|
|
},
|
|
);
|
|
|
|
expect(patch).toEqual({ virtualLocation: 'keep-me' });
|
|
disposable.dispose();
|
|
});
|
|
});
|
|
|
|
describe('removeAllPluginHooks', () => {
|
|
it('removes handlers from all buses for a plugin', () => {
|
|
emailHooks.onEmailOpen.register('test-p', vi.fn());
|
|
calendarHooks.onCalendarEventOpen.register('test-p', vi.fn());
|
|
emailHooks.onEmailOpen.register('other-p', vi.fn());
|
|
|
|
removeAllPluginHooks('test-p');
|
|
|
|
expect(emailHooks.onEmailOpen.size).toBe(1); // other-p remains
|
|
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('clearAllHooks', () => {
|
|
it('removes all handlers from all buses', () => {
|
|
emailHooks.onEmailOpen.register('p1', vi.fn());
|
|
calendarHooks.onCalendarEventOpen.register('p2', vi.fn());
|
|
clearAllHooks();
|
|
expect(emailHooks.onEmailOpen.size).toBe(0);
|
|
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('onBeforeEmailSend fails closed', () => {
|
|
it('does not send when the plugin never answers', async () => {
|
|
// A plugin that shows a confirm and waits for the user used to let the
|
|
// mail go out after 60 s, with the dialog still on screen.
|
|
vi.useFakeTimers();
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
emailHooks.onBeforeEmailSend.register('guard', () => new Promise(() => {}));
|
|
const result = emailHooks.onBeforeEmailSend.intercept({});
|
|
await vi.advanceTimersByTimeAsync(10 * 60_000 + 100);
|
|
expect(await result).toBe(false);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not send when the plugin throws', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); });
|
|
expect(await emailHooks.onBeforeEmailSend.intercept({})).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('hooks that wait on a plugin dialog (#1302)', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
setHookDialogGate({ hasOpenDialog: hasDialogFor, whenDialogsClosed: whenNoDialogFor });
|
|
});
|
|
afterEach(() => {
|
|
setHookDialogGate(null);
|
|
while (head()) resolveHead(null);
|
|
resetDialogLimits();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// What the PGP plugin does: ask for the passphrase inside its render hook,
|
|
// then return the decrypted body.
|
|
function registerPassphraseRenderer() {
|
|
renderHooks.onRenderEmailBody.register('pgp', async (body: object) => {
|
|
const answer = await awaitPrompt({
|
|
pluginId: 'pgp', kind: 'prompt', title: 'Unlock key', message: '',
|
|
fields: [{ name: 'passphrase', label: 'Passphrase', type: 'password' }],
|
|
});
|
|
return answer ? { ...body, html: 'decrypted', handledBy: 'pgp' } : body;
|
|
});
|
|
}
|
|
|
|
it('keeps the passphrase prompt open while the user types and uses the answer', async () => {
|
|
registerPassphraseRenderer();
|
|
const result = renderHooks.onRenderEmailBody.transform({ html: '' });
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(head()?.title).toBe('Unlock key');
|
|
|
|
resolveHead({ passphrase: 'secret' });
|
|
expect(await result).toEqual({ html: 'decrypted', handledBy: 'pgp' });
|
|
expect(console.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('times out again once the dialog is closed and the plugin still does not answer', async () => {
|
|
renderHooks.onRenderEmailBody.register('pgp', () => {
|
|
void awaitPrompt({ pluginId: 'pgp', kind: 'prompt', title: 'Unlock key', message: '', fields: [] });
|
|
return new Promise(() => {});
|
|
});
|
|
const result = renderHooks.onRenderEmailBody.transform({ html: 'raw' });
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
resolveHead(null);
|
|
await vi.advanceTimersByTimeAsync(4_900);
|
|
expect(console.error).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(await result).toEqual({ html: 'raw' });
|
|
expect(console.error).toHaveBeenCalledWith('[plugin:pgp] Hook error:', expect.any(Error));
|
|
});
|
|
});
|