diff --git a/packages/core/src/notifications/activation.ts b/packages/core/src/notifications/activation.ts index a563880d46f..2ac36bcd5bd 100644 --- a/packages/core/src/notifications/activation.ts +++ b/packages/core/src/notifications/activation.ts @@ -38,8 +38,8 @@ export async function activate( const controller = new NotificationsController(panelNode) const engine = new RuleEngine(await getRuleContext(context, initialState)) - void controller.pollForStartUp(engine) - void controller.pollForEmergencies(engine) + await controller.pollForStartUp(engine) + await controller.pollForEmergencies(engine) globals.clock.setInterval(async () => { const ruleContext = await getRuleContext(context, await authStateFn()) diff --git a/packages/core/src/notifications/controller.ts b/packages/core/src/notifications/controller.ts index 9d1636c7a24..a93835d89b1 100644 --- a/packages/core/src/notifications/controller.ts +++ b/packages/core/src/notifications/controller.ts @@ -40,8 +40,6 @@ 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 @@ -49,7 +47,8 @@ export class NotificationsController { constructor( private readonly notificationsNode: NotificationsNode, - private readonly fetcher: NotificationFetcher = new RemoteFetcher() + private readonly fetcher: NotificationFetcher = new RemoteFetcher(), + public readonly storageKey: globalKey = 'aws.notifications' ) { if (!NotificationsController.#instance) { // Register on first creation only. @@ -57,7 +56,6 @@ export class NotificationsController { } NotificationsController.#instance = this - this.storageKey = 'aws.notifications' this.state = globals.globalState.tryGet(this.storageKey, NotificationsStateConstructor, { startUp: {}, emergency: {}, @@ -94,7 +92,7 @@ export class NotificationsController { ruleEngine.shouldDisplayNotification(n) ) - NotificationsNode.instance.setNotifications(startUp, emergency) + await NotificationsNode.instance.setNotifications(startUp, emergency) // Emergency notifications can't be dismissed, but if the user minimizes the panel then // we don't want to focus it each time we set the notification nodes. @@ -128,7 +126,7 @@ export class NotificationsController { this.state.dismissed.push(notificationId) await this.writeState() - NotificationsNode.instance.dismissStartUpNotification(notificationId) + await NotificationsNode.instance.dismissStartUpNotification(notificationId) } /** diff --git a/packages/core/src/notifications/panelNode.ts b/packages/core/src/notifications/panelNode.ts index 87ef872c59a..94e4f2031f1 100644 --- a/packages/core/src/notifications/panelNode.ts +++ b/packages/core/src/notifications/panelNode.ts @@ -4,11 +4,12 @@ */ import * as vscode from 'vscode' +import * as nls from 'vscode-nls' import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { Command, Commands } from '../shared/vscode/commands2' -import { Icon, IconPath, getIcon } from '../shared/icons' +import { Icon, getIcon } from '../shared/icons' import { contextKey, setContext } from '../shared/vscode/setContext' -import { NotificationType, ToolkitNotification, getNotificationTelemetryId } from './types' +import { NotificationType, OnReceiveType, ToolkitNotification, getNotificationTelemetryId } from './types' import { ToolkitError } from '../shared/errors' import { isAmazonQ } from '../shared/extensionUtilities' import { getLogger } from '../shared/logger/logger' @@ -16,12 +17,18 @@ import { registerToolView } from '../awsexplorer/activationShared' import { readonlyDocument } from '../shared/utilities/textDocumentUtilities' import { openUrl } from '../shared/utilities/vsCodeUtils' import { telemetry } from '../shared/telemetry/telemetry' +import { globals } from '../shared' + +const localize = nls.loadMessageBundle() +const logger = getLogger('notifications') /** * Controls the "Notifications" side panel/tree in each extension. It takes purely UX actions * and does not determine what notifications to dispaly or how to fetch and store them. */ export class NotificationsNode implements TreeNode { + public static readonly title = localize('AWS.notifications.title', 'Notifications') + public readonly id = 'notifications' public readonly resource = this public provider?: ResourceTreeDataProvider @@ -34,6 +41,7 @@ export class NotificationsNode implements TreeNode { private readonly showContextStr: contextKey private readonly startUpNodeContext: string private readonly emergencyNodeContext: string + private view: vscode.TreeView | undefined static #instance: NotificationsNode @@ -62,31 +70,49 @@ export class NotificationsNode implements TreeNode { } public getTreeItem() { - const item = new vscode.TreeItem('Notifications') + const item = new vscode.TreeItem(NotificationsNode.title) item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed item.contextValue = 'notifications' return item } - public refresh(): void { - const hasNotifications = this.startUpNotifications.length > 0 || this.emergencyNotifications.length > 0 - void setContext(this.showContextStr, hasNotifications) + public refresh() { + const totalNotifications = this.notificationCount() + if (this.view) { + if (totalNotifications > 0) { + this.view.badge = { + tooltip: `${totalNotifications} notification${totalNotifications > 1 ? 's' : ''}`, + value: totalNotifications, + } + this.view.title = `${NotificationsNode.title} (${totalNotifications})` + } else { + this.view.badge = undefined + this.view.title = NotificationsNode.title + } + } else { + logger.warn('NotificationsNode was refreshed but the view was not initialized!') + } this.provider?.refresh() + return setContext(this.showContextStr, totalNotifications > 0) } public getChildren() { const buildNode = (n: ToolkitNotification, type: NotificationType) => { - const icon: Icon | IconPath = - type === 'startUp' - ? getIcon('vscode-question') - : { ...getIcon('vscode-alert'), color: new vscode.ThemeColor('errorForeground') } + const icon: Icon = + type === 'emergency' + ? Object.assign(getIcon('vscode-alert') as Icon, { + color: new vscode.ThemeColor('errorForeground'), + }) + : (getIcon('vscode-question') as Icon) + + const title = n.uiRenderInstructions.content['en-US'].title return this.openNotificationCmd.build(n).asTreeNode({ - label: n.uiRenderInstructions.content['en-US'].title, + label: title, + tooltip: title, iconPath: icon, contextValue: type === 'startUp' ? this.startUpNodeContext : this.emergencyNodeContext, - tooltip: 'Click to open', }) } @@ -100,10 +126,10 @@ export class NotificationsNode implements TreeNode { * Sets the current list of notifications. Nodes are generated for each notification. * No other processing is done, see NotificationController. */ - public setNotifications(startUp: ToolkitNotification[], emergency: ToolkitNotification[]) { + public async setNotifications(startUp: ToolkitNotification[], emergency: ToolkitNotification[]) { this.startUpNotifications = startUp this.emergencyNotifications = emergency - this.refresh() + await this.refresh() } /** @@ -112,9 +138,9 @@ export class NotificationsNode implements TreeNode { * * Only dismisses startup notifications. */ - public dismissStartUpNotification(id: string) { + public async dismissStartUpNotification(id: string) { this.startUpNotifications = this.startUpNotifications.filter((n) => n.id !== id) - this.refresh() + await this.refresh() } /** @@ -124,6 +150,10 @@ export class NotificationsNode implements TreeNode { return vscode.commands.executeCommand(this.focusCmdStr) } + private notificationCount() { + return this.startUpNotifications.length + this.emergencyNotifications.length + } + /** * Fired when a notification is clicked on in the panel. It will run any rendering * instructions included in the notification. See {@link ToolkitNotification.uiRenderInstructions}. @@ -132,7 +162,7 @@ export class NotificationsNode implements TreeNode { switch (notification.uiRenderInstructions.onClick.type) { case 'modal': // Render blocking modal - getLogger('notifications').verbose(`rendering modal for notificaiton: ${notification.id} ...`) + logger.verbose(`rendering modal for notificaiton: ${notification.id} ...`) await this.showInformationWindow(notification, 'modal', false) break case 'openUrl': @@ -140,7 +170,7 @@ export class NotificationsNode implements TreeNode { if (!notification.uiRenderInstructions.onClick.url) { throw new ToolkitError('No url provided for onclick open url') } - getLogger('notifications').verbose(`opening url for notification: ${notification.id} ...`) + logger.verbose(`opening url for notification: ${notification.id} ...`) await openUrl( vscode.Uri.parse(notification.uiRenderInstructions.onClick.url), getNotificationTelemetryId(notification) @@ -148,7 +178,7 @@ export class NotificationsNode implements TreeNode { break case 'openTextDocument': // Display read-only txt document - getLogger('notifications').verbose(`showing txt document for notification: ${notification.id} ...`) + logger.verbose(`showing txt document for notification: ${notification.id} ...`) await telemetry.toolkit_invokeAction.run(async () => { telemetry.record({ source: getNotificationTelemetryId(notification), action: 'openTxt' }) await readonlyDocument.show( @@ -165,7 +195,11 @@ export class NotificationsNode implements TreeNode { * Can be either a blocking modal or a bottom-right corner toast * Handles the button click actions based on the button type. */ - private showInformationWindow(notification: ToolkitNotification, type: string = 'toast', passive: boolean = false) { + private showInformationWindow( + notification: ToolkitNotification, + type: OnReceiveType = 'toast', + passive: boolean = false + ) { const isModal = type === 'modal' // modal has to have defined actions (buttons) @@ -203,7 +237,10 @@ export class NotificationsNode implements TreeNode { ) break case 'updateAndReload': - await this.updateAndReload(notification.displayIf.extensionId) + // Give things time to finish executing. + globals.clock.setTimeout(() => { + return this.updateAndReload(notification.displayIf.extensionId) + }, 1000) break case 'openUrl': if (selectedButton.url) { @@ -228,7 +265,11 @@ export class NotificationsNode implements TreeNode { } private async updateAndReload(id: string) { - getLogger('notifications').verbose('Updating and reloading the extension...') + logger.verbose('Updating and reloading the extension...') + + // Publish pending telemetry before it is lost to the window reload. + await globals.telemetry.flushRecords() + await vscode.commands.executeCommand('workbench.extensions.installExtension', id) await vscode.commands.executeCommand('workbench.action.reloadWindow') } @@ -258,7 +299,7 @@ export class NotificationsNode implements TreeNode { } registerView(context: vscode.ExtensionContext) { - const view = registerToolView( + this.view = registerToolView( { nodes: [this], view: isAmazonQ() ? 'aws.amazonq.notifications' : 'aws.toolkit.notifications', @@ -266,6 +307,5 @@ export class NotificationsNode implements TreeNode { }, context ) - view.message = `New feature announcements and emergency notifications for ${isAmazonQ() ? 'Amazon Q' : 'AWS Toolkit'} will appear here.` } } diff --git a/packages/core/src/notifications/rules.ts b/packages/core/src/notifications/rules.ts index 66b3a10d2b5..9e62adb7cc1 100644 --- a/packages/core/src/notifications/rules.ts +++ b/packages/core/src/notifications/rules.ts @@ -11,6 +11,7 @@ import { getComputeEnvType, getOperatingSystem } from '../shared/telemetry/util' import { AuthFormId } from '../login/webview/vue/types' import { getLogger } from '../shared/logger/logger' +const logger = getLogger('notifications') /** * Evaluates if a given version fits into the parameters specified by a notification, e.g: * @@ -72,22 +73,40 @@ export class RuleEngine { constructor(private readonly context: RuleContext) {} public shouldDisplayNotification(payload: ToolkitNotification) { - return this.evaluate(payload.displayIf) + return this.evaluate(payload.id, payload.displayIf) } - private evaluate(condition: DisplayIf): boolean { + private evaluate(id: string, condition: DisplayIf): boolean { const currentExt = globals.context.extension.id if (condition.extensionId !== currentExt) { + logger.verbose( + 'notification id: (%s) did NOT pass extension id check, actual ext id: (%s), expected ext id: (%s)', + id, + currentExt, + condition.extensionId + ) return false } if (condition.ideVersion) { if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) { + logger.verbose( + 'notification id: (%s) did NOT pass IDE version check, actual version: (%s), expected version: (%s)', + id, + this.context.ideVersion, + condition.ideVersion + ) return false } } if (condition.extensionVersion) { if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) { + logger.verbose( + 'notification id: (%s) did NOT pass extension version check, actual ext version: (%s), expected ext version: (%s)', + id, + this.context.extensionVersion, + condition.extensionVersion + ) return false } } @@ -95,8 +114,10 @@ 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) return false } + logger.debug('notification id: (%s) passed criteria check: %O', id, criteria) } } @@ -134,8 +155,6 @@ export class RuleEngine { return hasAnyOfExpected(this.context.authStates) case 'AuthScopes': return isEqualSetToExpected(this.context.authScopes) - case 'InstalledExtensions': - return isSuperSetOfExpected(this.context.installedExtensions) case 'ActiveExtensions': return isSuperSetOfExpected(this.context.activeExtensions) default: @@ -169,7 +188,6 @@ export async function getRuleContext(context: vscode.ExtensionContext, authState computeEnv: await getComputeEnvType(), authTypes: [...new Set(authTypes)], authScopes: authState.authScopes ? authState.authScopes?.split(',') : [], - installedExtensions: vscode.extensions.all.map((e) => e.id), activeExtensions: vscode.extensions.all.filter((e) => e.isActive).map((e) => e.id), // Toolkit (and eventually Q?) may have multiple connections with different regions and states. @@ -177,8 +195,9 @@ export async function getRuleContext(context: vscode.ExtensionContext, authState authRegions: authState.awsRegion ? [authState.awsRegion] : [], authStates: [authState.authStatus], } - const { activeExtensions, installedExtensions, ...loggableRuleContext } = ruleContext - getLogger('notifications').debug('getRuleContext() determined rule context: %O', loggableRuleContext) + + const { activeExtensions, ...loggableRuleContext } = ruleContext + logger.debug('getRuleContext() determined rule context: %O', loggableRuleContext) return ruleContext } diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index 596a5f2b6bd..59717cf9079 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -9,15 +9,7 @@ import { TypeConstructor } from '../shared/utilities/typeConstructors' import { AuthUserState, AuthStatus } from '../shared/telemetry/telemetry.gen' /** Types of information that we can use to determine whether to show a notification or not. */ -export type Criteria = - | 'OS' - | 'ComputeEnv' - | 'AuthType' - | 'AuthRegion' - | 'AuthState' - | 'AuthScopes' - | 'InstalledExtensions' - | 'ActiveExtensions' +export type Criteria = 'OS' | 'ComputeEnv' | 'AuthType' | 'AuthRegion' | 'AuthState' | 'AuthScopes' | 'ActiveExtensions' /** Generic condition where the type determines how the values are evaluated. */ export interface CriteriaCondition { @@ -46,6 +38,10 @@ export interface ExactMatch { export type ConditionalClause = Range | ExactMatch | OR +export type OnReceiveType = 'toast' | 'modal' +export type OnClickType = 'modal' | 'openTextDocument' | 'openUrl' +export type ActionType = 'openUrl' | 'updateAndReload' | 'openTxt' + /** How to display the notification. */ export interface UIRenderInstructions { content: { @@ -55,13 +51,13 @@ export interface UIRenderInstructions { toastPreview?: string // optional property for toast } } - onRecieve: string + onRecieve: OnReceiveType // TODO: typo onClick: { - type: string + type: OnClickType url?: string // optional property for 'openUrl' } actions?: Array<{ - type: string + type: ActionType displayText: { [`en-US`]: string } @@ -132,7 +128,6 @@ export interface RuleContext { readonly authRegions: string[] readonly authStates: AuthStatus[] readonly authScopes: string[] - readonly installedExtensions: string[] readonly activeExtensions: string[] } diff --git a/packages/core/src/shared/telemetry/telemetryService.ts b/packages/core/src/shared/telemetry/telemetryService.ts index c56ba3d2a24..61e9559db7c 100644 --- a/packages/core/src/shared/telemetry/telemetryService.ts +++ b/packages/core/src/shared/telemetry/telemetryService.ts @@ -195,9 +195,11 @@ export class DefaultTelemetryService { } /** - * Publish metrics to the Telemetry Service. + * Publish metrics to the Telemetry Service. Usually it will automatically flush recent events + * on a regular interval. This should not be used unless you are interrupting this interval, + * e.g. via a forced window reload. */ - private async flushRecords(): Promise { + public async flushRecords(): Promise { if (this.telemetryEnabled) { await this._flushRecords() } diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 57450126412..2e8d1a6e750 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -36,22 +36,34 @@ export type contextKey = | 'amazonq.inline.codelensShortcutEnabled' | 'aws.toolkit.lambda.walkthroughSelected' +const contextMap: Partial> = {} + /** * Calls the vscode "setContext" command. * * This wrapper adds structure and traceability to the vscode "setContext". It also opens the door - * for: - * - validation - * - getContext() (see also https://github.com/microsoft/vscode/issues/10471) + * for validation. * * Use "setContext" only as a last resort, to set flags that are detectable in package.json * declarations. Do not use it as a general way to store global state (which should be avoided * anyway). * * Warning: vscode context keys/values are NOT isolated to individual extensions. Other extensions - * can read and modify them. + * can read and modify them. See also https://github.com/microsoft/vscode/issues/10471 */ export async function setContext(key: contextKey, val: any): Promise { // eslint-disable-next-line aws-toolkits/no-banned-usages await vscode.commands.executeCommand('setContext', key, val) + contextMap[key] = val +} + +/** + * Returns the value of a context key set via {@link setContext} wrapper for this session. + * + * Warning: this does not guarantee the state of the context key in vscode because it may have + * been set via `vscode.commands.executeCommand('setContext')`. It has no connection the + * context keys stored in vscode itself because an API for this is not exposed. + */ +export function getContext(key: contextKey): any { + return contextMap[key] } diff --git a/packages/core/src/test/notifications/controller.test.ts b/packages/core/src/test/notifications/controller.test.ts index e1ac14094b8..c1e2d299463 100644 --- a/packages/core/src/test/notifications/controller.test.ts +++ b/packages/core/src/test/notifications/controller.test.ts @@ -8,7 +8,8 @@ import * as FakeTimers from '@sinonjs/fake-timers' import assert from 'assert' import sinon from 'sinon' import globals from '../../shared/extensionGlobals' -import { randomUUID } from '../../shared' +import { randomUUID } from '../../shared/crypto' +import { getContext } from '../../shared/vscode/setContext' import { assertTelemetry, installFakeClock } from '../testUtil' import { NotificationFetcher, @@ -39,7 +40,6 @@ describe('Notifications Controller', function () { authRegions: ['us-east-1'], authStates: ['connected'], authScopes: ['codewhisperer:completions', 'codewhisperer:analysis'], - installedExtensions: ['ext1', 'ext2', 'ext3'], activeExtensions: ['ext1', 'ext2'], }) @@ -88,9 +88,9 @@ describe('Notifications Controller', function () { } beforeEach(async function () { - panelNode.setNotifications([], []) + await panelNode.setNotifications([], []) fetcher = new TestFetcher() - controller = new NotificationsController(panelNode, fetcher) + controller = new NotificationsController(panelNode, fetcher, '_aws.test.notification' as any) ruleEngineSpy = sinon.spy(ruleEngine, 'shouldDisplayNotification') focusPanelSpy = sinon.spy(panelNode, 'focusPanel') @@ -136,6 +136,7 @@ describe('Notifications Controller', function () { assert.deepStrictEqual(panelNode.startUpNotifications, [content.notifications[0]]) assert.equal(panelNode.getChildren().length, 1) assert.equal(focusPanelSpy.callCount, 0) + assert.equal(getContext('aws.toolkit.notifications.show'), true) }) it('can fetch and store emergency notifications', async function () { @@ -167,6 +168,7 @@ describe('Notifications Controller', function () { assert.deepStrictEqual(panelNode.emergencyNotifications, [content.notifications[0]]) assert.equal(panelNode.getChildren().length, 1) assert.equal(focusPanelSpy.callCount, 1) + assert.equal(getContext('aws.toolkit.notifications.show'), true) }) it('can fetch and store both startup and emergency notifications', async function () { @@ -225,6 +227,7 @@ describe('Notifications Controller', function () { assert.deepStrictEqual(panelNode.emergencyNotifications, [emergencyContent.notifications[0]]) assert.equal(panelNode.getChildren().length, 2) assert.equal(focusPanelSpy.callCount, 1) + assert.equal(getContext('aws.toolkit.notifications.show'), true) }) it('dismisses a startup notification', async function () { @@ -242,6 +245,7 @@ describe('Notifications Controller', function () { assert.equal(panelNode.getChildren().length, 2) assert.equal(panelNode.startUpNotifications.length, 2) + assert.equal(getContext('aws.toolkit.notifications.show'), true) assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { startUp: { @@ -282,9 +286,11 @@ describe('Notifications Controller', function () { await controller.pollForStartUp(ruleEngine) assert.equal(panelNode.getChildren().length, 1) + assert.equal(getContext('aws.toolkit.notifications.show'), true) await dismissNotification(content.notifications[0]) assert.equal(panelNode.getChildren().length, 0) + assert.equal(getContext('aws.toolkit.notifications.show'), false) content.notifications.push(getValidTestNotification('id:startup2')) fetcher.setStartUpContent({ @@ -306,6 +312,7 @@ describe('Notifications Controller', function () { }) assert.equal(panelNode.getChildren().length, 1) + assert.equal(getContext('aws.toolkit.notifications.show'), true) }) it('does not refocus emergency notifications', async function () { @@ -435,6 +442,7 @@ describe('Notifications Controller', function () { newlyReceived: [], }) assert.equal(panelNode.getChildren().length, 1) + assert.equal(getContext('aws.toolkit.notifications.show'), true) fetcher.setEmergencyContent({ eTag: '1', @@ -456,6 +464,7 @@ describe('Notifications Controller', function () { }) assert.equal(panelNode.getChildren().length, 0) + assert.equal(getContext('aws.toolkit.notifications.show'), false) }) it('does not rethrow errors when fetching', async function () { @@ -535,7 +544,7 @@ describe('RemoteFetcher', function () { }) }) -function getValidTestNotification(id: string) { +function getValidTestNotification(id: string): ToolkitNotification { return { id, displayIf: { @@ -557,7 +566,7 @@ function getValidTestNotification(id: string) { } } -function getInvalidTestNotification(id: string) { +function getInvalidTestNotification(id: string): ToolkitNotification { return { id, displayIf: { @@ -571,6 +580,8 @@ function getInvalidTestNotification(id: string) { description: 'test', }, }, + onRecieve: 'toast', + onClick: { type: 'modal' }, }, } } diff --git a/packages/core/src/test/notifications/rendering.test.ts b/packages/core/src/test/notifications/rendering.test.ts index ada48c2c64b..e446e53fdba 100644 --- a/packages/core/src/test/notifications/rendering.test.ts +++ b/packages/core/src/test/notifications/rendering.test.ts @@ -5,12 +5,15 @@ import * as vscode from 'vscode' import * as sinon from 'sinon' +import * as FakeTimers from '@sinonjs/fake-timers' import assert from 'assert' import { ToolkitNotification } from '../../notifications/types' import { panelNode } from './controller.test' import { getTestWindow } from '../shared/vscode/window' import * as VsCodeUtils from '../../shared/utilities/vsCodeUtils' -import { assertTextEditorContains } from '../testUtil' +import { assertTextEditorContains, installFakeClock } from '../testUtil' +import { waitUntil } from '../../shared/utilities/timeoutUtils' +import globals from '../../shared/extensionGlobals' describe('Notifications Rendering', function () { let sandbox: sinon.SinonSandbox @@ -92,11 +95,21 @@ describe('Notifications Rendering', function () { message.selectItem('Update and Reload') }) const excuteCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() + const telemetrySpy = sandbox.spy(globals.telemetry, 'flushRecords') const notification = getModalNotification() + + // Update and Reload is put on a timer so that other methods (e.g. telemetry) can finish. + const clock: FakeTimers.InstalledClock = installFakeClock() await panelNode.openNotification(notification) + await clock.tickAsync(1000) + clock.uninstall() + + await waitUntil(async () => excuteCommandStub.called, { interval: 5, timeout: 5000 }) + assert.ok(excuteCommandStub.calledWith('workbench.extensions.installExtension', 'aws.toolkit.fake.extension')) assert.ok(excuteCommandStub.calledWith('workbench.action.reloadWindow')) + assert.ok(telemetrySpy.calledOnce) }) it('executes openURL type button', async function () { diff --git a/packages/core/src/test/notifications/rules.test.ts b/packages/core/src/test/notifications/rules.test.ts index 6b9c7454869..867b2119546 100644 --- a/packages/core/src/test/notifications/rules.test.ts +++ b/packages/core/src/test/notifications/rules.test.ts @@ -24,7 +24,6 @@ describe('Notifications Rule Engine', function () { authRegions: ['us-east-1'], authStates: ['connected'], authScopes: ['codewhisperer:completions', 'codewhisperer:analysis'], - installedExtensions: ['ext1', 'ext2', 'ext3'], activeExtensions: ['ext1', 'ext2'], } @@ -405,28 +404,6 @@ describe('Notifications Rule Engine', function () { ) }) - it('should display notification for InstalledExtensions criteria', function () { - assert.equal( - ruleEngine.shouldDisplayNotification( - buildNotification({ - additionalCriteria: [{ type: 'InstalledExtensions', values: ['ext1', 'ext2'] }], - }) - ), - true - ) - }) - - it('should NOT display notification for invalid InstalledExtensions criteria', function () { - assert.equal( - ruleEngine.shouldDisplayNotification( - buildNotification({ - additionalCriteria: [{ type: 'InstalledExtensions', values: ['ext1', 'ext2', 'unknownExtension'] }], - }) - ), - false - ) - }) - it('should display notification for ActiveExtensions criteria', function () { assert.equal( ruleEngine.shouldDisplayNotification( @@ -479,7 +456,6 @@ describe('Notifications Rule Engine', function () { { type: 'AuthRegion', values: ['us-east-1', 'us-west-2'] }, { type: 'AuthState', values: ['connected'] }, { type: 'AuthScopes', values: ['codewhisperer:completions', 'codewhisperer:analysis'] }, - { type: 'InstalledExtensions', values: ['ext1', 'ext2'] }, { type: 'ActiveExtensions', values: ['ext1', 'ext2'] }, ], }) @@ -517,7 +493,6 @@ describe('Notifications Rule Engine', function () { { type: 'AuthRegion', values: ['us-east-1', 'us-west-2'] }, { type: 'AuthState', values: ['connected'] }, { type: 'AuthScopes', values: ['codewhisperer:completions', 'codewhisperer:analysis'] }, - { type: 'InstalledExtensions', values: ['ex1', 'ext2'] }, { type: 'ActiveExtensions', values: ['ext1', 'ext2'] }, { type: 'ComputeEnv', values: ['ec2'] }, // no 'local' @@ -576,7 +551,6 @@ describe('Notifications getRuleContext()', function () { authRegions: ['us-east-1'], authStates: ['connected'], authScopes: amazonQScopes, - installedExtensions: vscode.extensions.all.map((e) => e.id), activeExtensions: vscode.extensions.all.filter((e) => e.isActive).map((e) => e.id), }) })