diff --git a/packages/controller-utils/src/EventAnalyzer.test.ts b/packages/controller-utils/src/EventAnalyzer.test.ts new file mode 100644 index 00000000000..f5bfb30a81c --- /dev/null +++ b/packages/controller-utils/src/EventAnalyzer.test.ts @@ -0,0 +1,198 @@ +import type { EventName, Handlers } from './EventAnalyzer'; +import { EventAnalyzer, EventStats } from './EventAnalyzer'; + +type EventHandler = (...payload: unknown[]) => void; + +class MockMessenger { + readonly #handlers: Map; + + constructor() { + this.#handlers = new Map(); + } + + subscribe(event: Event, handler: EventHandler) { + if (!this.#handlers.has(event)) { + this.#handlers.set(event, handler); + } + } + + unsubscribe(event: Event, _handler: EventHandler) { + const registered = this.#handlers.get(event); + + if (!registered) { + throw new Error('Not registered'); + } + this.#handlers.delete(event); + } + + publish(event: Event, ...payload: unknown[]) { + const handler = this.#handlers.get(event); + + if (!handler) { + throw new Error(`No event handlers for: "${event}"`); + } + handler(...payload); + } +} + +function setup(opts: { + events: readonly Event[]; + handlers?: Handlers; +}) { + const messenger = new MockMessenger(); + const handlers = opts.handlers ?? { + onSameEventValues: jest.fn(), + }; + const analyzer = EventAnalyzer.from({ + messenger, + handlers, + events: opts.events, + }); + + return { messenger, handlers, analyzer }; +} + +describe('EventChangeDetector', () => { + const events = ['0:event', '1:event'] as const; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('does not call the handler if events values are different', () => { + const { messenger, handlers } = setup({ events }); + + messenger.publish(events[0], { foobar: true }); + messenger.publish(events[0], { foobar: false }); + + expect(handlers.onSameEventValues).not.toHaveBeenCalled(); + }); + + it('does not call the handler if events values are being re-used', () => { + const { messenger, handlers } = setup({ events }); + + messenger.publish(events[0], { foobar: true }); + messenger.publish(events[0], { foobar: false }); + messenger.publish(events[0], { foobar: true }); + + expect(handlers.onSameEventValues).not.toHaveBeenCalled(); + }); + + it('detects event sent with the same values', () => { + const { messenger, analyzer, handlers } = setup({ events }); + + const payload = { foobar: true }; + messenger.publish(events[0], payload); + messenger.publish(events[0], payload); + + expect(handlers.onSameEventValues).toHaveBeenCalledWith( + analyzer, + events[0], + payload, + ); + }); + + it('detects event sent with the same values (default handler)', () => { + const messenger = new MockMessenger<(typeof events)[number]>(); + const analyzer = EventAnalyzer.from({ + messenger, + events, + }); + analyzer.subscribe(events[0]); + + const consoleSpy = jest.spyOn(global.console, 'log'); + + const payload = { foobar: true }; + messenger.publish(events[0], payload); + messenger.publish(events[0], payload); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`! ${events[0]} (no-diff)`), + ); + }); + + it('subscribe and unsubscribe events', () => { + const { messenger, analyzer } = setup<(typeof events)[0]>({ + events: [], + }); + + analyzer.subscribe(events[0]); + analyzer.unsubscribe(events[0]); + + expect(() => messenger.publish(events[0], { foobar: true })).toThrow( + `No event handlers for: "${events[0]}"`, + ); + }); + + it('tracks stats', () => { + const { messenger, analyzer } = setup({ + events, + }); + + const event = events[0]; + messenger.publish(event, { foobar: 1 }); + messenger.publish(event, { foobar: 2 }); + messenger.publish(event, { foobar: 3 }); + messenger.publish(event, { foobar: 3 }); // Same. + + const stats = analyzer.getStats(events[0]); + expect(stats).toBeDefined(); + expect(stats?.total).toBe(4); + expect(stats?.same).toBe(1); + expect(stats?.rate).toBe(0.25); + }); + + it('cannot get stats for an unknown event', () => { + const { analyzer } = setup({ + events: [events[0]], + }); + + const badEvent = events[1]; + // @ts-expect-error - Testing error case that's not type-safe. + expect(() => analyzer.getStats(badEvent)).toThrow( + `Unknown event: "${badEvent}"`, + ); + }); + + it('gets registered events', () => { + const { analyzer } = setup({ + events, + }); + + expect(analyzer.getEvents()).toStrictEqual(events); + }); + + it('gets registered events and use it to get event stats', () => { + const { analyzer } = setup({ + events, + }); + + for (const event of analyzer.getEvents()) { + const stats = analyzer.getStats(event); + + expect(stats).toBeDefined(); + } + }); +}); + +describe('EventStats', () => { + it('updates the total', () => { + const stats = new EventStats(); + + stats.update(); + stats.update(); + + expect(stats.same).toBe(0); + expect(stats.total).toBe(2); + }); + + it('updates the total and same', () => { + const stats = new EventStats(); + + stats.update(); + stats.update({ isSame: true }); + + expect(stats.same).toBe(1); + expect(stats.total).toBe(2); + }); +}); diff --git a/packages/controller-utils/src/EventAnalyzer.ts b/packages/controller-utils/src/EventAnalyzer.ts new file mode 100644 index 00000000000..653ced21675 --- /dev/null +++ b/packages/controller-utils/src/EventAnalyzer.ts @@ -0,0 +1,188 @@ +import deepEqual from 'fast-deep-equal'; + +export type EventName = `${string}:${string}`; + +export type EventHandler = (...payload: unknown[]) => void; + +export type EventMessenger = { + subscribe: (event: Event, callback: EventHandler) => void; + unsubscribe: (event: Event, callback: EventHandler) => void; +}; + +export type Handlers = { + onSameEventValues: ( + data: EventAnalyzedData, + event: EventName, + ...values: unknown[] + ) => void; +}; + +/** + * Pretty-print values as JSON. + * + * @param values - Values to be pretty-printed. + * @returns A pretty-printed JSON string. + */ +function pprint(values: unknown) { + return JSON.stringify(values, null, 2); +} + +export class EventStats { + #total: number = 0; + + #same: number = 0; + + get total(): number { + return this.#total; + } + + get same(): number { + return this.#same; + } + + get rate(): number { + return this.#same / this.#total; + } + + update({ isSame }: { isSame: boolean } = { isSame: false }) { + if (isSame) { + this.#same += 1; + } + this.#total += 1; + } + + pprint() { + return `[${this.#same}/${this.#total} (${(this.rate * 100).toFixed(2)}%)]`; + } +} + +export type EventInfo = { + handler: EventHandler; + value?: unknown[]; + stats: EventStats; +}; + +export type EventAnalyzedData = { + getStats(event: Event): EventStats; + getEvents(): Event[]; +}; + +/** + * Default handlers for `onSameEventValues`. + * + * @param data - Analyzed data. + * @param event - Event name. + * @param values - Event values (or payload). + */ +export const onSameEventValuesLogHandler: Handlers['onSameEventValues'] = < + Event extends EventName, +>( + data: EventAnalyzedData, + event: Event, + ...values: unknown[] +) => { + const stats = data.getStats(event); + + console.log(`! ${event} (no-diff) ${stats.pprint()}:\n${pprint(values)}`); +}; + +// TODO: Change name. +export const DEFAULT_HANDLERS: Handlers = { + onSameEventValues: onSameEventValuesLogHandler, +}; + +export class EventAnalyzer + implements EventAnalyzedData +{ + readonly #events: Map; + + readonly #handlers: Handlers; + + readonly #messenger: EventMessenger; + + constructor({ + messenger, + handlers = DEFAULT_HANDLERS, + }: { + messenger: EventMessenger; + handlers?: Handlers; + }) { + this.#messenger = messenger; + this.#handlers = handlers; + this.#events = new Map(); + } + + static from({ + events, + messenger, + handlers = DEFAULT_HANDLERS, + }: { + events: readonly Event[]; + messenger: EventMessenger; + handlers?: Handlers; + }) { + const analyzer = new EventAnalyzer({ messenger, handlers }); + + for (const event of events) { + analyzer.subscribe(event); + } + return analyzer; + } + + subscribe(event: Event) { + if (!this.#events.has(event)) { + const handler: EventHandler = (...payload) => { + this.#handleEvent(event, ...payload); + }; + + this.#messenger.subscribe(event, handler); + this.#events.set(event, { + handler, + stats: new EventStats(), + }); + } + } + + unsubscribe(event: Event) { + const info = this.#events.get(event); + + if (info) { + const { handler } = info; + + this.#messenger.unsubscribe(event, handler); + this.#events.delete(event); + } + } + + #get(event: Event): EventInfo { + const info = this.#events.get(event); + + if (!info) { + throw new Error(`Unknown event: "${event}"`); + } + return info; + } + + getEvents(): Event[] { + return Array.from(this.#events.keys()); + } + + getStats(event: Event): EventStats { + return this.#get(event).stats; + } + + #handleEvent(event: Event, ...newValues: unknown[]) { + const info = this.#get(event); + const { stats, value } = info; + + const isSame = value !== undefined && deepEqual(value, newValues); + + stats.update({ isSame }); + if (isSame) { + this.#handlers.onSameEventValues(this, event, ...newValues); + } + + // Keep track of the new event values. + info.value = newValues; + } +} diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index dbcad3f253a..01e828ce2d3 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -81,6 +81,10 @@ describe('@metamask/controller-utils', () => { "parseDomainParts", "isValidSIWEOrigin", "detectSIWE", + "EventStats", + "onSameEventValuesLogHandler", + "DEFAULT_HANDLERS", + "EventAnalyzer", ] `); }); diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index a3f5c992283..daa600e7d00 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -49,3 +49,4 @@ export { } from './util'; export * from './types'; export * from './siwe'; +export * from './EventAnalyzer';