From b627d4a812eaa049e8f64bd5dcac1462548e3661 Mon Sep 17 00:00:00 2001 From: Maxim Hayes Date: Mon, 18 Nov 2024 20:07:32 -0500 Subject: [PATCH] test(notifications): add e2e tests --- packages/amazonq/src/extensionNode.ts | 14 +- .../e2e/notifications/notifications.test.ts | 9 + packages/core/src/extensionNode.ts | 11 +- packages/core/src/notifications/activation.ts | 19 ++- packages/core/src/notifications/controller.ts | 18 +- packages/core/src/notifications/index.ts | 3 +- packages/core/src/notifications/rules.ts | 12 +- packages/core/src/test/index.ts | 1 + .../src/test/notifications/controller.test.ts | 9 +- .../notifications/notifications.test.ts | 159 ++++++++++++++++++ .../notifications/notifications.test.ts | 7 +- .../e2e/notifications/notifications.test.ts | 9 + 12 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 packages/amazonq/test/e2e/notifications/notifications.test.ts create mode 100644 packages/core/src/testE2E/notifications/notifications.test.ts create mode 100644 packages/toolkit/test/e2e/notifications/notifications.test.ts diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts index efb118e570c..ff124053fff 100644 --- a/packages/amazonq/src/extensionNode.ts +++ b/packages/amazonq/src/extensionNode.ts @@ -18,7 +18,11 @@ import { Auth, AuthUtils, getTelemetryMetadataForConn, isAnySsoConnection } from import api from './api' import { activate as activateCWChat } from './app/chat/activation' import { beta } from 'aws-core-vscode/dev' -import { activate as activateNotifications } from 'aws-core-vscode/notifications' +import { + RemoteFetcher, + activate as activateNotifications, + deactivate as deactivateNotifications, +} from 'aws-core-vscode/notifications' import { AuthState, AuthUtil } from 'aws-core-vscode/codewhisperer' import { telemetry, AuthUserState } from 'aws-core-vscode/telemetry' @@ -75,10 +79,13 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) { ...authState, }) - await activateNotifications(context, authState, getAuthState) + await activateNotifications(context, authState, getAuthState, { + fetcher: new RemoteFetcher(), + storageKey: 'aws.notifications', + }) } -async function getAuthState(): Promise> { +export async function getAuthState(): Promise> { let authState: AuthState = 'disconnected' try { // May call connection validate functions that try to refresh the token. @@ -147,4 +154,5 @@ async function setupDevMode(context: vscode.ExtensionContext) { export async function deactivate() { // Run concurrently to speed up execution. stop() does not throw so it is safe await Promise.all([(await CrashMonitoring.instance())?.shutdown(), deactivateCommon()]) + deactivateNotifications() } diff --git a/packages/amazonq/test/e2e/notifications/notifications.test.ts b/packages/amazonq/test/e2e/notifications/notifications.test.ts new file mode 100644 index 00000000000..a7e81f1b141 --- /dev/null +++ b/packages/amazonq/test/e2e/notifications/notifications.test.ts @@ -0,0 +1,9 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 fort + */ + +import { getNotificationsE2ESuite } from 'aws-core-vscode/test' +import { getAuthState } from '../../../src/extensionNode' + +getNotificationsE2ESuite(getAuthState) diff --git a/packages/core/src/extensionNode.ts b/packages/core/src/extensionNode.ts index cef34e522df..a74f097f153 100644 --- a/packages/core/src/extensionNode.ts +++ b/packages/core/src/extensionNode.ts @@ -39,7 +39,7 @@ import * as beta from './dev/beta' import { activate as activateApplicationComposer } from './applicationcomposer/activation' import { activate as activateRedshift } from './awsService/redshift/activation' import { activate as activateIamPolicyChecks } from './awsService/accessanalyzer/activation' -import { activate as activateNotifications } from './notifications/activation' +import { activate as activateNotifications, deactivate as deactivateNotifications } from './notifications/activation' import { SchemaService } from './shared/schemas' import { AwsResourceManager } from './dynamicResources/awsResourceManager' import globals from './shared/extensionGlobals' @@ -60,6 +60,7 @@ import { activate as activateThreatComposerEditor } from './threatComposer/activ import { isSsoConnection, hasScopes } from './auth/connection' import { CrashMonitoring, setContext } from './shared' import { AuthFormId } from './login/webview/vue/types' +import { RemoteFetcher } from './notifications/controller' let localize: nls.LocalizeFunc @@ -246,7 +247,10 @@ export async function activate(context: vscode.ExtensionContext) { ...authState, }) - await activateNotifications(context, authState, getAuthState) + await activateNotifications(context, authState, getAuthState, { + fetcher: new RemoteFetcher(), + storageKey: 'aws.notifications', + }) } catch (error) { const stacktrace = (error as Error).stack?.split('\n') // truncate if the stacktrace is unusually long @@ -270,6 +274,7 @@ export async function deactivate() { // Run concurrently to speed up execution. stop() does not throw so it is safe await Promise.all([await (await CrashMonitoring.instance())?.shutdown(), deactivateCommon(), deactivateEc2()]) await globals.resourceManager.dispose() + deactivateNotifications() } async function handleAmazonQInstall() { @@ -338,7 +343,7 @@ function recordToolkitInitialization(activationStartedOn: number, settingsValid: } } -async function getAuthState(): Promise> { +export async function getAuthState(): Promise> { let authStatus: AuthStatus = 'notConnected' const enabledConnections: Set = new Set() const enabledScopes: Set = new Set() diff --git a/packages/core/src/notifications/activation.ts b/packages/core/src/notifications/activation.ts index cd732909d16..f95e32ae3c2 100644 --- a/packages/core/src/notifications/activation.ts +++ b/packages/core/src/notifications/activation.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode' import { DevSettings } from '../shared/settings' -import { NotificationsController } from './controller' +import { NotificationsController, ControllerOptions } from './controller' import { NotificationsNode } from './panelNode' import { RuleEngine, getRuleContext } from './rules' import globals from '../shared/extensionGlobals' @@ -15,6 +15,7 @@ import { oneMinute } from '../shared/datetime' /** Time in MS to poll for emergency notifications */ const emergencyPollTime = oneMinute * 10 +let interval: NodeJS.Timer /** * Activate the in-IDE notifications module and begin receiving notifications. @@ -26,7 +27,8 @@ const emergencyPollTime = oneMinute * 10 export async function activate( context: vscode.ExtensionContext, initialState: AuthState, - authStateFn: () => Promise + authStateFn: () => Promise, + options: Omit ) { // TODO: Currently gated behind feature-flag. if (!DevSettings.instance.get('notifications', false)) { @@ -36,16 +38,25 @@ export async function activate( const panelNode = NotificationsNode.instance panelNode.registerView(context) - const controller = new NotificationsController(panelNode) + const controller = new NotificationsController({ node: panelNode, ...options }) const engine = new RuleEngine(await getRuleContext(context, initialState)) await controller.pollForStartUp(engine) await controller.pollForEmergencies(engine) - globals.clock.setInterval(async () => { + if (interval !== undefined) { + globals.clock.clearInterval(interval) + } + + interval = globals.clock.setInterval(async () => { const ruleContext = await getRuleContext(context, await authStateFn()) await controller.pollForEmergencies(new RuleEngine(ruleContext)) }, emergencyPollTime) getLogger('notifications').debug('Activated in-IDE notifications polling module') } + +export function deactivate() { + globals.clock.clearInterval(interval) + getLogger('notifications').debug('Deactivated in-IDE notifications polling module') +} diff --git a/packages/core/src/notifications/controller.ts b/packages/core/src/notifications/controller.ts index 30aa8ef3570..569ba21bb93 100644 --- a/packages/core/src/notifications/controller.ts +++ b/packages/core/src/notifications/controller.ts @@ -26,6 +26,12 @@ import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetch import { isAmazonQ } from '../shared/extensionUtilities' import { telemetry } from '../shared/telemetry/telemetry' +export type ControllerOptions = { + node: NotificationsNode + storageKey: globalKey + fetcher: NotificationFetcher +} + /** * Handles fetching and maintaining the state of in-IDE notifications. * Notifications are constantly polled from a known endpoint and then stored in global state. @@ -40,23 +46,25 @@ import { telemetry } from '../shared/telemetry/telemetry' */ export class NotificationsController { public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes + public readonly storageKey: globalKey /** Internal memory state that is written to global state upon modification. */ private readonly state: NotificationsState + private readonly notificationsNode: NotificationsNode + private readonly fetcher: NotificationFetcher static #instance: NotificationsController | undefined - constructor( - private readonly notificationsNode: NotificationsNode, - private readonly fetcher: NotificationFetcher = new RemoteFetcher(), - public readonly storageKey: globalKey = 'aws.notifications' - ) { + constructor(options: ControllerOptions) { if (!NotificationsController.#instance) { // Register on first creation only. registerDismissCommand() } NotificationsController.#instance = this + this.notificationsNode = options.node + this.storageKey = options.storageKey + this.fetcher = options.fetcher this.state = this.getDefaultState() } diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts index c61d2937d9b..aa6ca131def 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -3,4 +3,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { activate } from './activation' +export { activate, deactivate } from './activation' +export { RemoteFetcher } from './controller' diff --git a/packages/core/src/notifications/rules.ts b/packages/core/src/notifications/rules.ts index f2d41d7bcbc..fe77aa21c63 100644 --- a/packages/core/src/notifications/rules.ts +++ b/packages/core/src/notifications/rules.ts @@ -117,7 +117,13 @@ export class RuleEngine { if (condition.additionalCriteria) { for (const criteria of condition.additionalCriteria) { if (!this.evaluateRule(criteria)) { - logger.verbose('notification id: (%s) did NOT pass criteria check: %O', id, criteria) + // We want to see nested objects. It is not deep. + // eslint-disable-next-line aws-toolkits/no-json-stringify-in-log + logger.verbose( + 'notification id: (%s) did NOT pass criteria check: %s', + id, + JSON.stringify(criteria) + ) return false } logger.debug('notification id: (%s) passed criteria check: %O', id, criteria) @@ -201,7 +207,9 @@ export async function getRuleContext(context: vscode.ExtensionContext, authState } const { activeExtensions, ...loggableRuleContext } = ruleContext - logger.debug('getRuleContext() determined rule context: %O', loggableRuleContext) + // We want to see the nested objects. It is not deep. + // eslint-disable-next-line aws-toolkits/no-json-stringify-in-log + logger.debug('getRuleContext() determined rule context: %s', JSON.stringify(loggableRuleContext)) return ruleContext } diff --git a/packages/core/src/test/index.ts b/packages/core/src/test/index.ts index 282fdac2bfc..a620f31a53c 100644 --- a/packages/core/src/test/index.ts +++ b/packages/core/src/test/index.ts @@ -19,6 +19,7 @@ export { getTestLogger } from './globalSetup.test' export { testCommand } from './shared/vscode/testUtils' export { FakeAwsContext } from './utilities/fakeAwsContext' export { getTestWorkspaceFolder } from '../testInteg/integrationTestsUtilities' +export { getNotificationsE2ESuite } from '../testE2E/notifications/notifications.test' export * from './codewhisperer/testUtil' export * from './credentials/testUtil' export * from './testUtil' diff --git a/packages/core/src/test/notifications/controller.test.ts b/packages/core/src/test/notifications/controller.test.ts index 25b013db71b..161071eb8d0 100644 --- a/packages/core/src/test/notifications/controller.test.ts +++ b/packages/core/src/test/notifications/controller.test.ts @@ -26,10 +26,13 @@ import { import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher' import { NotificationsNode } from '../../notifications/panelNode' import { RuleEngine } from '../../notifications/rules' +import { globalKey } from '../../shared/globalState' // one test node to use across different tests export const panelNode: NotificationsNode = NotificationsNode.instance +const storageKey = 'aws.notifications.test' as globalKey + describe('Notifications Controller', function () { const ruleEngine: RuleEngine = new RuleEngine({ ideVersion: '1.83.0', @@ -90,7 +93,7 @@ describe('Notifications Controller', function () { beforeEach(async function () { await panelNode.setNotifications([], []) fetcher = new TestFetcher() - controller = new NotificationsController(panelNode, fetcher, '_aws.test.notification' as any) + controller = new NotificationsController({ node: panelNode, fetcher, storageKey }) ruleEngineSpy = sinon.spy(ruleEngine, 'shouldDisplayNotification') focusPanelSpy = sinon.spy(panelNode, 'focusPanel') @@ -475,7 +478,9 @@ describe('Notifications Controller', function () { throw new Error('test error') } })() - assert.doesNotThrow(() => new NotificationsController(panelNode, fetcher).pollForStartUp(ruleEngine)) + assert.doesNotThrow(() => + new NotificationsController({ node: panelNode, fetcher, storageKey }).pollForStartUp(ruleEngine) + ) assert.ok(wasCalled) }) diff --git a/packages/core/src/testE2E/notifications/notifications.test.ts b/packages/core/src/testE2E/notifications/notifications.test.ts new file mode 100644 index 00000000000..e4973541bfc --- /dev/null +++ b/packages/core/src/testE2E/notifications/notifications.test.ts @@ -0,0 +1,159 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 fort + */ + +import { RemoteFetcher } from '../../notifications/controller' +import { NotificationsNode } from '../../notifications/panelNode' +import { activate, deactivate } from '../../notifications/activation' +import globals from '../../shared/extensionGlobals' +import assert from 'assert' +import sinon from 'sinon' +import { createTestAuth } from '../../test/credentials/testUtil' +import { AuthUtil } from '../../codewhisperer/util/authUtil' +import { Auth } from '../../auth/auth' +import { ShownMessage } from '../../test/shared/vscode/message' +import { getTestWindow } from '../../test/shared/vscode/window' +import { AuthUserState } from '../../shared/telemetry/telemetry.gen' +import { globalKey } from '../../shared/globalState' +import { waitUntil } from '../../shared/utilities/timeoutUtils' +import { assertTelemetry, assertTextEditorContains } from '../../test/testUtil' + +/** + * Tests that connect to our hosted files server and download the integ notifications. + * + * IMPORTANT: + * These tests are dependent on what is hosted on the server (message contents, criteria, etc). + */ +export function getNotificationsE2ESuite(getAuthStateFn: () => Promise>) { + return describe('Notifications Integration Test', function () { + const storageKey = 'aws.notifications.test' as globalKey + const fetcher = new RemoteFetcher( + 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/startup/1.x.json', + 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/emergency/1.x.json' + ) + // const panelNode = NotificationsNode.instance + const sandbox = sinon.createSandbox() + let auth: ReturnType + let authUtil: AuthUtil + + beforeEach(async function () { + await globals.globalState.update(storageKey, undefined) + auth = createTestAuth(globals.globalState) + authUtil = new AuthUtil(auth) + sandbox.stub(Auth, 'instance').value(auth) + sandbox.stub(AuthUtil, 'instance').value(authUtil) + }) + + afterEach(async function () { + await globals.globalState.update(storageKey, undefined) + sandbox.restore() + }) + + /** + * A way to track notifications displayed in the IDE. + * See {@link setupTestWindow} for usage. + */ + function msgHandler(text: string, fn: (m: ShownMessage) => Promise) { + return { + seen: false, // have we seen and processed this message + text, // title of the notification to match the message on + fn, // what to do with the message + } + } + + function setupTestWindow(toHandle: ReturnType[]) { + const testWindow = getTestWindow() + testWindow.onDidShowMessage(async (message) => { + const handler = toHandle.find((h) => message.message.includes(h.text)) + if (handler) { + await handler.fn(message) + handler.seen = true + } + }) + + return testWindow + } + + /** + * Set up the test window, activate the notifications module, and wait for + * messages to resolve in the UI. + */ + async function runTest(toHandle: ReturnType[]) { + setupTestWindow(toHandle) + + const initialState = await getAuthStateFn() + await activate(globals.context, initialState, getAuthStateFn, { + fetcher, + storageKey, + }) + + await waitUntil(async () => toHandle.every((h) => h.seen), { timeout: 12000 }) + } + + it('can fetch unauthenticated notifications', async function () { + await runTest([ + msgHandler('New Amazon Q features are available!', async (m: ShownMessage) => { + assert.ok(!m.modal) + assert.ok(m.items.find((i) => i.title.includes('Learn more'))) + m.close() + }), + msgHandler( + 'Signing into Amazon Q is broken, please try this workaround while we work on releasing a fix.', + async (m: ShownMessage) => { + assert.ok(!m.modal) + m.selectItem('Learn more') + await assertTextEditorContains( + 'There is currently a bug that is preventing users from signing into Amazon Q.', + false + ) + } + ), + ]) + + assert.equal(NotificationsNode.instance.getChildren().length, 2) + assertTelemetry('toolkit_showNotification', [ + { id: 'TARGETED_NOTIFICATION:startup2' }, + { id: 'TARGETED_NOTIFICATION:emergency1' }, + ]) + assertTelemetry('toolkit_invokeAction', [ + { action: 'OK', source: 'TARGETED_NOTIFICATION:startup2' }, + { action: 'Learn more', source: 'TARGETED_NOTIFICATION:emergency1' }, + ]) + + deactivate() + }) + + it('can fetch authenticated notifications', async function () { + await auth.useConnection(await authUtil.connectToAwsBuilderId()) + await runTest([ + msgHandler('New Amazon Q features available: inline chat', async (m: ShownMessage) => { + assert.ok(!m.modal) + m.selectItem('Learn more') + await assertTextEditorContains( + 'You can now use Amazon Q inline in your IDE, without ever touching the mouse or using copy and paste.', + false + ) + }), + msgHandler('Amazon Q may delete user data', async (m: ShownMessage) => { + assert.ok(m.modal) + assert.ok(m.items.find((i) => i.title.includes('Update and Reload'))) + m.close() + }), + ]) + + assert.equal(NotificationsNode.instance.getChildren().length, 3) // includes one startup notification that wasn't checked here. (checked in another test) + assertTelemetry('toolkit_showNotification', [ + { id: 'TARGETED_NOTIFICATION:startup1' }, + { id: 'TARGETED_NOTIFICATION:startup2' }, + { id: 'TARGETED_NOTIFICATION:emergency2' }, + ]) + assertTelemetry('toolkit_invokeAction', [ + { action: 'Learn more', source: 'TARGETED_NOTIFICATION:startup1' }, + { action: 'OK', source: 'TARGETED_NOTIFICATION:emergency2' }, + ]) + + deactivate() + }) + }) +} diff --git a/packages/core/src/testInteg/notifications/notifications.test.ts b/packages/core/src/testInteg/notifications/notifications.test.ts index 5b753f783ff..8775f2fa28f 100644 --- a/packages/core/src/testInteg/notifications/notifications.test.ts +++ b/packages/core/src/testInteg/notifications/notifications.test.ts @@ -12,6 +12,7 @@ import globals from '../../shared/extensionGlobals' import assert from 'assert' import { VSCODE_EXTENSION_ID } from '../../shared/extensions' import sinon from 'sinon' +import { globalKey } from '../../shared/globalState' describe('Notifications Integration Test', function () { let fetcher: RemoteFetcher @@ -43,7 +44,11 @@ describe('Notifications Integration Test', function () { 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/startup/1.x.json', 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/emergency/1.x.json' ) - controller = new NotificationsController(panelNode, fetcher) + controller = new NotificationsController({ + node: panelNode, + fetcher, + storageKey: 'aws.notifications.test' as globalKey, + }) }) // Clear all global states after each test diff --git a/packages/toolkit/test/e2e/notifications/notifications.test.ts b/packages/toolkit/test/e2e/notifications/notifications.test.ts new file mode 100644 index 00000000000..2be7785ad44 --- /dev/null +++ b/packages/toolkit/test/e2e/notifications/notifications.test.ts @@ -0,0 +1,9 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 fort + */ + +import { getAuthState } from 'aws-core-vscode/node' +import { getNotificationsE2ESuite } from 'aws-core-vscode/test' + +getNotificationsE2ESuite(getAuthState)