diff --git a/packages/amazonq/.vscode/launch.json b/packages/amazonq/.vscode/launch.json index 120eacdb44d..2debfa50c8b 100644 --- a/packages/amazonq/.vscode/launch.json +++ b/packages/amazonq/.vscode/launch.json @@ -118,6 +118,51 @@ "order": 3 } }, + { + "name": "Integration Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--disable-extension=amazonwebservices.aws-toolkit-vscode", + "${workspaceFolder}/../core/dist/src/testFixtures/workspaceFolder", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/dist/test/integ/index.js" + ], + "env": { + "DEVELOPMENT_PATH": "${workspaceFolder}", + "AWS_TOOLKIT_AUTOMATION": "local" + }, + "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"], + "preLaunchTask": "watch", + "presentation": { + "group": "6_IntegrationTests", + "order": 1 + } + }, + { + "name": "Integration Tests (current file)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--disable-extension=amazonwebservices.aws-toolkit-vscode", + "${workspaceFolder}/../core/dist/src/testFixtures/workspaceFolder", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/dist/test/integ/index.js" + ], + "env": { + "TEST_FILE": "${relativeFile}", + "DEVELOPMENT_PATH": "${workspaceFolder}", + "AWS_TOOLKIT_AUTOMATION": "local" + }, + "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"], + "preLaunchTask": "watch", + "presentation": { + "group": "5_IntegrationTestsCurrentFile", + "order": 1 + } + }, { "name": "E2E Test (current file)", "type": "extensionHost", diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index e63c0ac02b0..d95a980f8eb 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -58,6 +58,7 @@ "watch": "npm run clean && npm run buildScripts && tsc -watch -p ./", "testCompile": "npm run clean && npm run buildScripts && npm run compileOnly", "test": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts unit dist/test/unit/index.js ../core/dist/src/testFixtures/workspaceFolder", + "testInteg": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts integration dist/test/integ/index.js ../core/dist/src/testFixtures/workspaceFolder", "testE2E": "npm run testCompile && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts e2e dist/test/e2e/index.js ../core/dist/src/testFixtures/workspaceFolder", "testWeb": "npm run compileDev && c8 --allowExternal ts-node ../core/scripts/test/launchTest.ts web dist/test/web/testRunnerWebCore.js", "webRun": "npx @vscode/test-web --open-devtools --browserOption=--disable-web-security --waitForDebugger=9222 --extensionDevelopmentPath=. .", diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts index efb118e570c..c09bbfbcc43 100644 --- a/packages/amazonq/src/extensionNode.ts +++ b/packages/amazonq/src/extensionNode.ts @@ -18,7 +18,7 @@ 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 { 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' @@ -78,7 +78,7 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) { await activateNotifications(context, authState, getAuthState) } -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 +147,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/integ/index.ts b/packages/amazonq/test/integ/index.ts new file mode 100644 index 00000000000..b5189b2e322 --- /dev/null +++ b/packages/amazonq/test/integ/index.ts @@ -0,0 +1,13 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { runTests } from 'aws-core-vscode/test' +import { VSCODE_EXTENSION_ID } from 'aws-core-vscode/utils' + +export function run(): Promise { + return runTests(process.env.TEST_DIR ?? 'test/integ', VSCODE_EXTENSION_ID.amazonq, [ + '../../core/dist/src/testInteg/globalSetup.test.ts', + ]) +} diff --git a/packages/amazonq/test/integ/notifications/notifications.test.ts b/packages/amazonq/test/integ/notifications/notifications.test.ts new file mode 100644 index 00000000000..d6e9bfece91 --- /dev/null +++ b/packages/amazonq/test/integ/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 { getNotificationsSuite } from 'aws-core-vscode/test' +import { getAuthState } from '../../../src/extensionNode' + +getNotificationsSuite(getAuthState) diff --git a/packages/core/src/extensionNode.ts b/packages/core/src/extensionNode.ts index cef34e522df..c8c8129a959 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' @@ -270,6 +270,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 +339,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..99fe3cfdca4 100644 --- a/packages/core/src/notifications/activation.ts +++ b/packages/core/src/notifications/activation.ts @@ -5,17 +5,23 @@ import * as vscode from 'vscode' import { DevSettings } from '../shared/settings' -import { NotificationsController } from './controller' +import { NotificationsController, ControllerOptions, RemoteFetcher } from './controller' import { NotificationsNode } from './panelNode' import { RuleEngine, getRuleContext } from './rules' import globals from '../shared/extensionGlobals' import { AuthState } from './types' import { getLogger } from '../shared/logger/logger' import { oneMinute } from '../shared/datetime' +import { globalKey } from '../shared/globalState' /** Time in MS to poll for emergency notifications */ const emergencyPollTime = oneMinute * 10 +/** Key in global state to store notification data */ +const storageKey: globalKey = 'aws.notifications' + +let interval: NodeJS.Timer + /** * Activate the in-IDE notifications module and begin receiving notifications. * @@ -26,7 +32,8 @@ const emergencyPollTime = oneMinute * 10 export async function activate( context: vscode.ExtensionContext, initialState: AuthState, - authStateFn: () => Promise + authStateFn: () => Promise, + options?: Partial> ) { // TODO: Currently gated behind feature-flag. if (!DevSettings.instance.get('notifications', false)) { @@ -36,16 +43,29 @@ export async function activate( const panelNode = NotificationsNode.instance panelNode.registerView(context) - const controller = new NotificationsController(panelNode) + const controller = new NotificationsController({ + node: panelNode, + fetcher: options?.fetcher ?? new RemoteFetcher(), + storageKey: options?.storageKey ?? storageKey, + }) 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..4010205e21b 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. @@ -39,24 +45,25 @@ import { telemetry } from '../shared/telemetry/telemetry' * Emergency notifications - fetched at a regular interval. */ 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..33a1fa1d4de 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { activate } from './activation' +export { activate, deactivate } from './activation' diff --git a/packages/core/src/test/index.ts b/packages/core/src/test/index.ts index 282fdac2bfc..f399c29cbde 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 { getNotificationsSuite } from '../testInteg/notifications/suite' 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/testInteg/notifications/notifications.test.ts b/packages/core/src/testInteg/notifications/notifications.test.ts index 5b753f783ff..8602bac2395 100644 --- a/packages/core/src/testInteg/notifications/notifications.test.ts +++ b/packages/core/src/testInteg/notifications/notifications.test.ts @@ -12,7 +12,11 @@ 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' +/** + * Simple integ test to verify we can fetch and display from hosted files. + */ describe('Notifications Integration Test', function () { let fetcher: RemoteFetcher let panelNode: NotificationsNode @@ -43,7 +47,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/core/src/testInteg/notifications/suite.ts b/packages/core/src/testInteg/notifications/suite.ts new file mode 100644 index 00000000000..67d0a7b4c49 --- /dev/null +++ b/packages/core/src/testInteg/notifications/suite.ts @@ -0,0 +1,160 @@ +/*! + * 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' + +/** + * Return an extension-specific suite that will take parameters from that running extension and + * perform a thorough integ test. + * + * IMPORTANT: + * These tests are dependent on what is hosted on the server (message contents, criteria, etc). + */ +export function getNotificationsSuite(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/toolkit/test/integ/notifications/notifications.test.ts b/packages/toolkit/test/integ/notifications/notifications.test.ts new file mode 100644 index 00000000000..4bc609f4ea1 --- /dev/null +++ b/packages/toolkit/test/integ/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 { getNotificationsSuite } from 'aws-core-vscode/test' + +getNotificationsSuite(getAuthState)