diff --git a/packages/apps/src/app.plugin.spec.ts b/packages/apps/src/app.plugin.spec.ts index bc85963a9..bc468725e 100644 --- a/packages/apps/src/app.plugin.spec.ts +++ b/packages/apps/src/app.plugin.spec.ts @@ -1,181 +1,243 @@ +import { MessageActivity } from '@microsoft/teams.api'; import { ConsoleLogger } from '@microsoft/teams.common/logging'; import { App } from './app'; import { IErrorEvent } from './events'; import { HttpPlugin } from './plugins'; -import { EmitPluginEvent, IPlugin, IPluginStartEvent } from './types'; +import { EmitPluginEvent, IPlugin, IPluginActivityEvent, IPluginStartEvent } from './types'; import { Event, Plugin } from './types/plugin/decorators'; interface ITestEvents { - test: { - message: string; - bar: number; - } + test: { + message: string; + bar: number; + } } class TestHttpPlugin extends HttpPlugin { - async onStart(_event: IPluginStartEvent) { - // No-op for tests - } + async onStart(_event: IPluginStartEvent) { + // No-op for tests + } - async onStop() { - // No-op for tests - } + async onStop() { + // No-op for tests + } } @Plugin({ - name: 'testPlugin', - version: '0.0.1', - description: 'test-plugin', + name: 'testPlugin', + version: '0.0.1', + description: 'test-plugin', }) class TestPlugin implements IPlugin { - @Event('custom') - private emit!: EmitPluginEvent; + @Event('custom') + private emit!: EmitPluginEvent; - __eventType!: ITestEvents; + __eventType!: ITestEvents; - testEmit() { - this.emit('test', { message: 'hello', bar: 1 }); - } + testEmit() { + this.emit('test', { message: 'hello', bar: 1 }); + } - onStart(_event: IPluginStartEvent): void | Promise { - // Do nothing - } + onStart(_event: IPluginStartEvent): void | Promise { + // Do nothing + } } describe('app.plugin', () => { - it('plugins should be able to emit events that reach the app', async () => { - // Create an App with our test plugin - const testPlugin = new TestPlugin(); - const app = new App({ - logger: new ConsoleLogger('test', { level: 'debug' }), - plugins: [testPlugin, new TestHttpPlugin()] - }); - - let receivedEventMessage: string = ''; - app.event('test', event => { - // Make sure message is typed correctly - receivedEventMessage = event.message; - // @ts-expect-error - event should be correctly typed to ITestEvents - event.nonExistentProperty = 'bar'; - }); - - await app.start(); - - testPlugin.testEmit(); - expect(receivedEventMessage).toEqual('hello'); + it('plugins should be able to emit events that reach the app', async () => { + // Create an App with our test plugin + const testPlugin = new TestPlugin(); + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [testPlugin, new TestHttpPlugin()] + }); + + let receivedEventMessage: string = ''; + app.event('test', event => { + // Make sure message is typed correctly + receivedEventMessage = event.message; + // @ts-expect-error - event should be correctly typed to ITestEvents + event.nonExistentProperty = 'bar'; + }); + + await app.start(); + + testPlugin.testEmit(); + expect(receivedEventMessage).toEqual('hello'); + }); + + it('should throw error when registering duplicate plugin names', () => { + const plugin1 = new TestPlugin(); + const plugin2 = new TestPlugin(); + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [plugin1] }); - it('should throw error when registering duplicate plugin names', () => { - const plugin1 = new TestPlugin(); - const plugin2 = new TestPlugin(); - const app = new App({ - logger: new ConsoleLogger('test', { level: 'debug' }), - plugins: [plugin1] - }); - - expect(() => { - app.plugin(plugin2); - }).toThrow('duplicate plugin "testPlugin" found'); + expect(() => { + app.plugin(plugin2); + }).toThrow('duplicate plugin "testPlugin" found'); + }); + + it('should prevent plugins from using reserved event names', async () => { + @Plugin({ + name: 'reservedPlugin', + version: '0.0.1', + description: 'test-plugin', + }) + class ReservedEventPlugin implements IPlugin<{ 'activity': { foo: string } }> { + + @Event('custom') + emit!: (name: Name, arg: { foo: string }) => void; + + __eventType!: { 'activity': { foo: string } }; + + onStart(_event: IPluginStartEvent): void | Promise { + // No-op for tests + } + + testEmit() { + this.emit('activity', { foo: 'bar' }); + } + } + + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [new ReservedEventPlugin()] }); - it('should prevent plugins from using reserved event names', async () => { - @Plugin({ - name: 'reservedPlugin', - version: '0.0.1', - description: 'test-plugin', - }) - class ReservedEventPlugin implements IPlugin<{ 'activity': { foo: string } }> { + const eventFn = jest.fn(); + app.event('activity', eventFn); - @Event('custom') - emit!: (name: Name, arg: { foo: string }) => void; + await app.start(); + const plugin = app.getPlugin('reservedPlugin') as ReservedEventPlugin; - __eventType!: { 'activity': { foo: string } }; + plugin.testEmit(); + expect(eventFn).not.toHaveBeenCalled(); + }); - onStart(_event: IPluginStartEvent): void | Promise { - // No-op for tests - } + it('should call plugin lifecycle methods in correct order', async () => { + const lifecycleOrder: string[] = []; - testEmit() { - this.emit('activity', { foo: 'bar' }); - } - } + @Plugin({ + name: 'lifecyclePlugin', + version: '0.0.1', + description: 'test-plugin', + }) + class LifecyclePlugin implements IPlugin { - const app = new App({ - logger: new ConsoleLogger('test', { level: 'debug' }), - plugins: [new ReservedEventPlugin()] - }); + onInit(): void { + lifecycleOrder.push('onInit'); + } - const eventFn = jest.fn(); - app.event('activity', eventFn); + onStart(_event: IPluginStartEvent): void { + lifecycleOrder.push('onStart'); + } - await app.start(); - const plugin = app.getPlugin('reservedPlugin') as ReservedEventPlugin; + onStop(): void { + lifecycleOrder.push('onStop'); + } + } - plugin.testEmit(); - expect(eventFn).not.toHaveBeenCalled(); + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [new LifecyclePlugin(), new TestHttpPlugin()] }); - it('should call plugin lifecycle methods in correct order', async () => { - const lifecycleOrder: string[] = []; + await app.start(); + await app.stop(); + + expect(lifecycleOrder).toEqual(['onInit', 'onStart', 'onStop']); + }); + + it('should propagate plugin errors to app error handler', async () => { + @Plugin({ + name: 'errorPlugin', + version: '0.0.1', + description: 'test-plugin', + }) + class ErrorPlugin implements IPlugin { + onStart(_event: IPluginStartEvent): void { + throw new Error('test error'); + } + } + + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [new ErrorPlugin(), new TestHttpPlugin()] + }); - @Plugin({ - name: 'lifecyclePlugin', - version: '0.0.1', - description: 'test-plugin', - }) - class LifecyclePlugin implements IPlugin { + let errorReceived = null as Error | null; + app.event('error', (event: IErrorEvent) => { + errorReceived = event.error; + }); - onInit(): void { - lifecycleOrder.push('onInit'); - } + await app.start(); - onStart(_event: IPluginStartEvent): void { - lifecycleOrder.push('onStart'); - } + expect(errorReceived).toBeDefined(); + expect(errorReceived?.message).toBe('test error'); + }); + it('should be able to include additional context', async () => { + interface IMyContext { + foo: number; + bar: string; + } - onStop(): void { - lifecycleOrder.push('onStop'); - } - } + @Plugin({ + name: 'myPlugin', + version: '0.0.1', + description: 'test-plugin', + }) + class MyPlugin implements IPlugin { + onActivity(_event: IPluginActivityEvent): IMyContext { + return { + foo: 4, + bar: 'str', + }; + } + + onStart(_event: IPluginStartEvent): void | Promise { + // No-op for tests + } + } - const app = new App({ - logger: new ConsoleLogger('test', { level: 'debug' }), - plugins: [new LifecyclePlugin(), new TestHttpPlugin()] - }); + const app = new App({ + logger: new ConsoleLogger('test', { level: 'debug' }), + plugins: [new MyPlugin(), new TestHttpPlugin()] + }); - await app.start(); - await app.stop(); - expect(lifecycleOrder).toEqual(['onInit', 'onStart', 'onStop']); + let receivedFoo: number = -1; + let receivedBar: string = ''; + app.on('message', (context) => { + receivedFoo = context.foo; + receivedBar = context.bar; }); - it('should propagate plugin errors to app error handler', async () => { - @Plugin({ - name: 'errorPlugin', - version: '0.0.1', - description: 'test-plugin', - }) - class ErrorPlugin implements IPlugin { - onStart(_event: IPluginStartEvent): void { - throw new Error('test error'); - } - } - - const app = new App({ - logger: new ConsoleLogger('test', { level: 'debug' }), - plugins: [new ErrorPlugin(), new TestHttpPlugin()] - }); - - let errorReceived = null as Error | null; - app.event('error', (event: IErrorEvent) => { - errorReceived = event.error; - }); - - await app.start(); - - expect(errorReceived).toBeDefined(); - expect(errorReceived?.message).toBe('test error'); + await app.start(); + + // Trigger a message activity by directly calling onActivity (internal API for testing) + const httpPlugin = app.getPlugin('http') as TestHttpPlugin; + const activity = new MessageActivity('test message'); + + // @ts-expect-error - accessing internal method for testing + await app.onActivity(httpPlugin, { + activity: activity.toInterface(), + token: { + appId: 'test-app-id', + serviceUrl: 'https://test.botframework.com', + from: 'bot' as const, + fromId: 'test-from-id', + toString: () => 'test-token', + isExpired: () => false, + } }); + + expect(receivedFoo).toEqual(4); + expect(receivedBar).toEqual('str'); + + await app.stop(); + }); }); diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 0c92a605d..17706953f 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -57,33 +57,30 @@ export async function $process( const routes = this.router.select(activity); + // Collect plugin contexts BEFORE creating the activity context let pluginContexts: {} = {}; for (let i = this.plugins.length - 1; i > -1; i--) { const plugin = this.plugins[i]; if (plugin.onActivity) { - routes.unshift(async ({ next }) => { - const additionalPluginContext = await plugin.onActivity!({ - ...ref, - sender: sender, - activity, - token, - }); - - if (additionalPluginContext) { - for (const key in additionalPluginContext) { - if (key in pluginContexts) { - this.log.warn(`Plugin context key "${key}" already exists. Overriding.`); - } + const additionalPluginContext = await plugin.onActivity({ + ...ref, + sender: sender, + activity, + token, + }); + + if (additionalPluginContext) { + for (const key in additionalPluginContext) { + if (key in pluginContexts) { + this.log.warn(`Plugin context key "${key}" already exists. Overriding.`); } - pluginContexts = { - ...pluginContexts, - ...additionalPluginContext, - }; } - - return next(); - }); + pluginContexts = { + ...pluginContexts, + ...additionalPluginContext, + }; + } } } @@ -94,7 +91,11 @@ export async function $process( if (i === routes.length - 1) return data; i++; - const res = await routes[i](ctx || context.toInterface()); + const mergedContext = ctx || { + ...context.toInterface(), + ...pluginContexts, + }; + const res = await routes[i](mergedContext); if (res) { data = res; @@ -116,7 +117,6 @@ export async function $process( storage: this.storage, isSignedIn: !!userToken, connectionName: this.oauth.defaultConnectionName, - ...pluginContexts }); if (routes.length === 0) {