Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions src/flare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export class Flare<E extends Record<string, any>> {
};
observer.fn(context, arg);
} catch (error) {
// The throwing observer should not break others
}
}
}
Expand All @@ -242,30 +243,16 @@ export class Flare<E extends Record<string, any>> {
if (!shouldRun) return;

try {
if (!timeout) return this.call(handlerOptions.handler, payload);
if (!timeout) return handlerOptions.handler(payload);

const timeoutPromise = new Promise<void>((_, reject) => setTimeout(() => reject(new Error('FlareHandler timeout')), timeout));
return Promise.race([this.call(handlerOptions.handler, payload), timeoutPromise]);
return Promise.race([handlerOptions.handler(payload), timeoutPromise]);
} finally {
if (handlerOptions.options.once) {
this.handlerOptionsStore[event]?.delete(handlerOptions);
}
}
};

private call<K extends keyof E>(
handler: FlareHandler<E[K]>,
payload: E[K],
) {
try {
return Promise.resolve(handler(payload)).catch((e) => {
// TODO: handle async exception
});
} catch (error) {
// TODO: handle sync exception
throw error;
}
}
}

interface HandlerOptionsPair<E, K extends keyof E> {
Expand Down
225 changes: 210 additions & 15 deletions test/observe.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,213 @@
import { flare } from '../src';

test('observe', () => {
const PAYLOAD = 'PAYLOAD';
const EVENT_NAME = 'EVENT_NAME';

flare.observe({
id: 'ID',
fn: (context, arg) => {
console.log('context:', context);
console.log('arg:', arg);
expect(context.event).toBe(EVENT_NAME);
expect(context.payload).toBe(PAYLOAD);
}
import Flare, {
FlareObservationSource,
FlareObservationType,
} from '../src';

type Events = {
save: { data: string };
delete: { id: number };
};

describe('Observation system', () => {
let flare: Flare<Events>;
let observerFn: jest.Mock;
const payload = { data: 'abc' };

beforeEach(() => {
flare = new Flare<Events>();
observerFn = jest.fn();
flare.observe({ fn: observerFn });
});

flare.fire(EVENT_NAME, PAYLOAD);
describe('basic observation behavior', () => {
it('should call observer on normal event fire with Info type', async () => {
const handler = jest.fn();
flare.catch('save', handler);

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'save',
payload,
type: FlareObservationType.Info,
source: FlareObservationSource.Flare,
}),
undefined
);
});

it('should include timestamp and source info', async () => {
flare.catch('save', jest.fn());
await flare.fire('save', payload);

const [ctx] = observerFn.mock.calls[0];
expect(typeof ctx.timestamp).toBe('number');
expect(ctx.source).toBe(FlareObservationSource.Flare);
});

it('should not block event flow if observer throws error', async () => {
const badObserver = jest.fn(() => { throw new Error('bad'); });
flare.observe({ fn: badObserver });

const handler = jest.fn();
flare.catch('save', handler);

await expect(flare.fire('save', payload)).resolves.toBeUndefined();
expect(handler).toHaveBeenCalled();
// The throwing observer should not break others
expect(observerFn).toHaveBeenCalled();
});
});

describe('observation from other sources', () => {
it('should record Warning observation when event stopped by middleware', async () => {
flare.use({
id: 'blocker',
fn: (ctx) => ctx.stop(),
});
flare.catch('save', jest.fn());

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Warning,
source: FlareObservationSource.Middleware,
sourceId: 'blocker',
}),
expect.stringContaining('stopped')
);
});

it('should record Warning observation when event cancelled by interceptor', async () => {
flare.in({
id: 'breaker',
before: () => false,
});
flare.catch('save', jest.fn());

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Warning,
source: FlareObservationSource.Interceptor,
sourceId: 'breaker',
}),
expect.stringContaining('cancelled')
);
});

it('should record Error observation when interceptor throws', async () => {
flare.in({
id: 'faulty',
before: () => { throw new Error('boom'); },
});
flare.catch('save', jest.fn());

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Error,
source: FlareObservationSource.Interceptor,
sourceId: 'faulty',
}),
expect.any(Error)
);
});

it('should record Error observation when middleware throws', async () => {
flare.use({
id: 'm1',
fn: async () => {
throw new Error('mw fail');
},
});
flare.catch('save', jest.fn());

await expect(flare.fire('save', payload)).rejects.toThrow('mw fail');

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Error,
source: FlareObservationSource.Middleware,
sourceId: 'm1',
}),
expect.any(Error)
);
});
});

describe('observation during handler execution', () => {
it('should record Error observation when handler throws synchronously', async () => {
const errorHandler = jest.fn(() => { throw new Error('handler fail'); });
flare.catch('save', errorHandler);

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Error,
source: FlareObservationSource.Handler,
}),
expect.any(Error)
);
});

it('should record Error observation when handler rejects asynchronously', async () => {
const asyncFailHandler = jest.fn(async () => {
return Promise.reject(new Error('async fail'));
});
flare.catch('save', asyncFailHandler);

await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalledWith(
expect.objectContaining({
type: FlareObservationType.Error,
source: FlareObservationSource.Handler,
}),
expect.any(Error)
);
});
});

describe('observer management', () => {
it('should allow multiple observers to receive the same events', async () => {
const obs2 = jest.fn();
flare.observe({ fn: obs2 });

flare.catch('save', jest.fn());
await flare.fire('save', payload);

expect(observerFn).toHaveBeenCalled();
expect(obs2).toHaveBeenCalled();
});

it('should allow dynamic observation of different event types', async () => {
const obsA = jest.fn();
const obsB = jest.fn();

flare.observe({ fn: obsA });
flare.observe({ fn: obsB });

flare.catch('save', jest.fn());
flare.catch('delete', jest.fn());

await flare.fire('save', { data: 'ok' });
await flare.fire('delete', { id: 7 });

expect(obsA).toHaveBeenCalledWith(
expect.objectContaining({ event: 'save' }),
undefined
);
expect(obsB).toHaveBeenCalledWith(
expect.objectContaining({ event: 'delete' }),
undefined
);
});
});
});