diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index eb4034aa1f2..fbb68da3fde 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -188,6 +188,11 @@ }, "views": { "amazonq": [ + { + "id": "aws.amazonq.notifications", + "name": "%AWS.notifications.title%", + "when": "!isCloud9 && !aws.isSageMaker && aws.amazonq.notifications.show" + }, { "type": "webview", "id": "aws.amazonq.AmazonCommonAuth", @@ -370,6 +375,13 @@ "group": "cw_chat" } ], + "view/item/context": [ + { + "command": "_aws.amazonq.notifications.dismiss", + "when": "viewItem == amazonqNotificationStartUp", + "group": "inline@1" + } + ], "aws.amazonq.submenu.feedback": [ { "command": "aws.amazonq.submitFeedback", @@ -397,6 +409,13 @@ ] }, "commands": [ + { + "command": "_aws.amazonq.notifications.dismiss", + "title": "%AWS.generic.dismiss%", + "category": "%AWS.amazonq.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": "$(remove-close)" + }, { "command": "aws.amazonq.explainCode", "title": "%AWS.command.amazonq.explainCode%", diff --git a/packages/core/package.json b/packages/core/package.json index a63a5121290..4ca9b19f487 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,8 @@ "./utils": "./dist/src/shared/utilities/index.js", "./feedback": "./dist/src/feedback/index.js", "./telemetry": "./dist/src/shared/telemetry/index.js", - "./dev": "./dist/src/dev/index.js" + "./dev": "./dist/src/dev/index.js", + "./notifications": "./dist/src/notifications/index.js" }, "contributes": { "icons": { diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index d108766cd63..0f334478532 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -5,6 +5,7 @@ "AWS.productName.cn": "Amazon Toolkit", "AWS.amazonq.productName": "Amazon Q", "AWS.codecatalyst.submenu.title": "Manage CodeCatalyst", + "AWS.notifications.title": "Notifications", "AWS.configuration.profileDescription": "The name of the credential profile to obtain credentials from.", "AWS.configuration.description.lambda.recentlyUploaded": "Recently selected Lambda upload targets.", "AWS.configuration.description.ecs.openTerminalCommand": "The command to run when starting a new interactive terminal session.", @@ -245,6 +246,7 @@ "AWS.generic.promptUpdate": "Update...", "AWS.generic.preview": "Preview", "AWS.generic.viewDocs": "View Documentation", + "AWS.generic.dismiss": "Dismiss", "AWS.ssmDocument.ssm.maxItemsComputed.desc": "Controls the maximum number of problems produced by the SSM Document language server.", "AWS.walkthrough.gettingStarted.title": "Get started with AWS", "AWS.walkthrough.gettingStarted.description": "These walkthroughs help you set up the AWS Toolkit.", diff --git a/packages/core/src/codewhisperer/util/zipUtil.ts b/packages/core/src/codewhisperer/util/zipUtil.ts index f8656536ad8..b0c22eed885 100644 --- a/packages/core/src/codewhisperer/util/zipUtil.ts +++ b/packages/core/src/codewhisperer/util/zipUtil.ts @@ -9,7 +9,7 @@ import { tempDirPath } from '../../shared/filesystemUtilities' import { getLogger } from '../../shared/logger' import * as CodeWhispererConstants from '../models/constants' import { ToolkitError } from '../../shared/errors' -import { fs } from '../../shared' +import { fs } from '../../shared/fs/fs' import { getLoggerForScope } from '../service/securityScanHandler' import { runtimeLanguageContext } from './runtimeLanguageContext' import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen' diff --git a/packages/core/src/notifications/controller.ts b/packages/core/src/notifications/controller.ts new file mode 100644 index 00000000000..229b69ee9e4 --- /dev/null +++ b/packages/core/src/notifications/controller.ts @@ -0,0 +1,260 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { ToolkitError } from '../shared/errors' +import globals from '../shared/extensionGlobals' +import { globalKey } from '../shared/globalState' +import { NotificationsState, NotificationsStateConstructor, NotificationType, ToolkitNotification } from './types' +import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' +import { getLogger } from '../shared/logger/logger' +import { NotificationsNode } from './panelNode' +import { Commands } from '../shared/vscode/commands2' +import { RuleEngine } from './rules' +import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' +import { withRetries } from '../shared/utilities/functionUtils' +import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher' +import { isAmazonQ } from '../shared/extensionUtilities' + +/** + * Handles fetching and maintaining the state of in-IDE notifications. + * Notifications are constantly polled from a known endpoint and then stored in global state. + * The global state is used to compare if there are a change in notifications on the endpoint + * or if the endpoint is not reachable. + * + * This class will send any notifications to {@link NotificationsNode} for display. + * Notifications can be dismissed. + * + * Startup notifications - fetched each start up. + * 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 + + static #instance: NotificationsController | undefined + + constructor( + private readonly notificationsNode: NotificationsNode, + private readonly fetcher: NotificationFetcher = new RemoteFetcher() + ) { + if (!NotificationsController.#instance) { + // Register on first creation only. + registerDismissCommand() + } + NotificationsController.#instance = this + + this.storageKey = 'aws.notifications' + this.state = globals.globalState.tryGet(this.storageKey, NotificationsStateConstructor, { + startUp: {}, + emergency: {}, + dismissed: [], + }) + } + + public pollForStartUp(ruleEngine: RuleEngine) { + return this.poll(ruleEngine, 'startUp') + } + + public pollForEmergencies(ruleEngine: RuleEngine) { + return this.poll(ruleEngine, 'emergency') + } + + private async poll(ruleEngine: RuleEngine, category: NotificationType) { + try { + await this.fetchNotifications(category) + } catch (err: any) { + getLogger('notifications').error(`Unable to fetch %s notifications: %s`, category, err) + } + + await this.displayNotifications(ruleEngine) + } + + private async displayNotifications(ruleEngine: RuleEngine) { + const dismissed = new Set(this.state.dismissed) + const startUp = + this.state.startUp.payload?.notifications.filter( + (n) => !dismissed.has(n.id) && ruleEngine.shouldDisplayNotification(n) + ) ?? [] + const emergency = (this.state.emergency.payload?.notifications ?? []).filter((n) => + ruleEngine.shouldDisplayNotification(n) + ) + + 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. + // So we store it in dismissed once a focus has been fired for it. + const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id)) + if (newEmergencies.length > 0) { + this.state.dismissed = [...this.state.dismissed, ...newEmergencies] + await this.writeState() + void this.notificationsNode.focusPanel() + } + } + + /** + * Permanently hides a notification from view. Only 'startUp' notifications can be dismissed. + * Users are able to collapse or hide the notifications panel in native VSC if they want to + * hide all notifications. + */ + public async dismissNotification(notificationId: string) { + getLogger('notifications').debug('Dismissing notification: %s', notificationId) + this.state.dismissed.push(notificationId) + await this.writeState() + + NotificationsNode.instance.dismissStartUpNotification(notificationId) + } + + /** + * Fetch notifications from the endpoint and store them in the global state. + */ + private async fetchNotifications(category: NotificationType) { + const response = await this.fetcher.fetch(category, this.state[category].eTag) + if (!response.content) { + getLogger('notifications').verbose('No new notifications for category: %s', category) + return + } + + getLogger('notifications').verbose('ETAG has changed for notifications category: %s', category) + + this.state[category].payload = JSON.parse(response.content) + this.state[category].eTag = response.eTag + await this.writeState() + + getLogger('notifications').verbose( + "Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.", + category, + this.state[category].payload?.schemaVersion, + this.state[category].payload?.notifications?.length + ) + } + + /** + * Write the latest memory state to global state. + */ + private async writeState() { + getLogger('notifications').debug('NotificationsController: Updating notifications state at %s', this.storageKey) + + // Clean out anything in 'dismissed' that doesn't exist anymore. + const notifications = new Set( + [ + ...(this.state.startUp.payload?.notifications ?? []), + ...(this.state.emergency.payload?.notifications ?? []), + ].map((n) => n.id) + ) + this.state.dismissed = this.state.dismissed.filter((id) => notifications.has(id)) + + await globals.globalState.update(this.storageKey, this.state) + } + + static get instance() { + if (this.#instance === undefined) { + throw new ToolkitError('NotificationsController was accessed before it has been initialized.') + } + + return this.#instance + } +} + +function registerDismissCommand() { + const name = isAmazonQ() ? '_aws.amazonq.notifications.dismiss' : '_aws.toolkit.notifications.dismiss' + + globals.context.subscriptions.push( + Commands.register(name, async (node: TreeNode) => { + const item = node?.getTreeItem() + if (item instanceof vscode.TreeItem && item.command?.arguments) { + // The command used to build the TreeNode contains the notification as an argument. + /** See {@link NotificationsNode} for more info. */ + const notification = item.command?.arguments[0] as ToolkitNotification + + await NotificationsController.instance.dismissNotification(notification.id) + } else { + getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`) + } + }) + ) +} + +export type ResourceResponse = Awaited> + +export interface NotificationFetcher { + /** + * Fetch notifications from some source. If there is no (new) data to fetch, then the response's + * content value will be undefined. + * + * @param type typeof NotificationType + * @param versionTag last known version of the data aka ETAG. Can be used to determine if the data changed. + */ + fetch(type: NotificationType, versionTag?: string): Promise +} + +export class RemoteFetcher implements NotificationFetcher { + public static readonly retryNumber = 5 + public static readonly retryIntervalMs = 30000 + + private readonly startUpEndpoint: string = + 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json' + private readonly emergencyEndpoint: string = + 'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/emergency/1.x.json' + + constructor(startUpPath?: string, emergencyPath?: string) { + this.startUpEndpoint = startUpPath ?? this.startUpEndpoint + this.emergencyEndpoint = emergencyPath ?? this.emergencyEndpoint + } + + fetch(category: NotificationType, versionTag?: string): Promise { + const endpoint = category === 'startUp' ? this.startUpEndpoint : this.emergencyEndpoint + const fetcher = new HttpResourceFetcher(endpoint, { + showUrl: true, + }) + getLogger('notifications').verbose( + 'Attempting to fetch notifications for category: %s at endpoint: %s', + category, + endpoint + ) + + return withRetries(async () => await fetcher.getNewETagContent(versionTag), { + maxRetries: RemoteFetcher.retryNumber, + delay: RemoteFetcher.retryIntervalMs, + // No exponential backoff - necessary? + }) + } +} + +/** + * Can be used when developing locally. This may be expanded at some point to allow notifications + * to be published via github rather than internally. + * + * versionTag (ETAG) is ignored. + */ +export class LocalFetcher implements NotificationFetcher { + // Paths relative to running extension root folder (e.g. packages/amazonq/). + private readonly startUpLocalPath: string = '../core/src/test/notifications/resources/startup/1.x.json' + private readonly emergencyLocalPath: string = '../core/src/test/notifications/resources/emergency/1.x.json' + + constructor(startUpPath?: string, emergencyPath?: string) { + this.startUpLocalPath = startUpPath ?? this.startUpLocalPath + this.emergencyLocalPath = emergencyPath ?? this.emergencyLocalPath + } + + async fetch(category: NotificationType, versionTag?: string): Promise { + const uri = category === 'startUp' ? this.startUpLocalPath : this.emergencyLocalPath + getLogger('notifications').verbose( + 'Attempting to fetch notifications locally for category: %s at path: %s', + category, + uri + ) + + return { + content: await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get(), + eTag: 'LOCAL_PATH', + } + } +} diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts new file mode 100644 index 00000000000..3b9963f9985 --- /dev/null +++ b/packages/core/src/notifications/index.ts @@ -0,0 +1,9 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export { RuleContext } from './types' +export { NotificationsController } from './controller' +export { RuleEngine } from './rules' +export { registerProvider, NotificationsNode } from './panelNode' diff --git a/packages/core/src/notifications/panelNode.ts b/packages/core/src/notifications/panelNode.ts new file mode 100644 index 00000000000..0eadec7c459 --- /dev/null +++ b/packages/core/src/notifications/panelNode.ts @@ -0,0 +1,163 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider' +import { Command, Commands } from '../shared/vscode/commands2' +import { getIcon } from '../shared/icons' +import { contextKey, setContext } from '../shared/vscode/setContext' +import { NotificationType, ToolkitNotification } from './types' +import { ToolkitError } from '../shared/errors' +import { isAmazonQ } from '../shared/extensionUtilities' + +/** + * 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 readonly id = 'notifications' + public readonly resource = this + public provider?: ResourceTreeDataProvider + public startUpNotifications: ToolkitNotification[] = [] + public emergencyNotifications: ToolkitNotification[] = [] + + private readonly openNotificationCmd: Command + private readonly focusCmdStr: string + private readonly showContextStr: contextKey + private readonly startUpNodeContext: string + private readonly emergencyNodeContext: string + + private readonly onDidChangeTreeItemEmitter = new vscode.EventEmitter() + private readonly onDidChangeChildrenEmitter = new vscode.EventEmitter() + private readonly onDidChangeVisibilityEmitter = new vscode.EventEmitter() + public readonly onDidChangeTreeItem = this.onDidChangeTreeItemEmitter.event + public readonly onDidChangeChildren = this.onDidChangeChildrenEmitter.event + public readonly onDidChangeVisibility = this.onDidChangeVisibilityEmitter.event + + static #instance: NotificationsNode + + constructor() { + NotificationsNode.#instance = this + + this.openNotificationCmd = Commands.register( + isAmazonQ() ? '_aws.amazonq.notifications.open' : '_aws.toolkit.notifications.open', + async (n: ToolkitNotification) => this.openNotification(n) + ) + + if (isAmazonQ()) { + this.focusCmdStr = 'aws.amazonq.notifications.focus' + this.showContextStr = 'aws.amazonq.notifications.show' + this.startUpNodeContext = 'amazonqNotificationStartUp' + this.emergencyNodeContext = 'amazonqNotificationEmergency' + } else { + this.focusCmdStr = 'aws.toolkit.notifications.focus' + this.showContextStr = 'aws.toolkit.notifications.show' + this.startUpNodeContext = 'toolkitNotificationStartUp' + this.emergencyNodeContext = 'toolkitNotificationEmergency' + } + } + + public getTreeItem() { + const item = new vscode.TreeItem('Notifications') + 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) + + this.onDidChangeChildrenEmitter.fire() + this.provider?.refresh() + } + + public refreshRootNode() { + this.onDidChangeTreeItemEmitter.fire() + this.provider?.refresh() + } + + public getChildren() { + const buildNode = (n: ToolkitNotification, type: NotificationType) => { + return this.openNotificationCmd.build(n).asTreeNode({ + label: n.uiRenderInstructions.content['en-US'].title, + iconPath: type === 'startUp' ? getIcon('vscode-question') : getIcon('vscode-alert'), + contextValue: type === 'startUp' ? this.startUpNodeContext : this.emergencyNodeContext, + }) + } + + return [ + ...this.emergencyNotifications.map((n) => buildNode(n, 'emergency')), + ...this.startUpNotifications.map((n) => buildNode(n, 'startUp')), + ] + } + + /** + * 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[]) { + this.startUpNotifications = startUp + this.emergencyNotifications = emergency + this.refresh() + } + + /** + * Deletes a notification node from the panel. This is purely a UX action - nothing happens + * to the notification on the backend via this function. + * + * Only dismisses startup notifications. + */ + public dismissStartUpNotification(id: string) { + this.startUpNotifications = this.startUpNotifications.filter((n) => n.id !== id) + this.refresh() + } + + /** + * Will uncollapse/unhide the notifications panel from view and focus it. + */ + public focusPanel() { + return vscode.commands.executeCommand(this.focusCmdStr) + } + + /** + * Fired when a notification is clicked on in the panel. It will run any rendering + * instructions included in the notification. See {@link ToolkitNotification.uiRenderInstructions}. + * + * TODO: implement more rendering possibilites. + */ + private async openNotification(notification: ToolkitNotification) { + await vscode.window.showTextDocument( + await vscode.workspace.openTextDocument({ + content: notification.uiRenderInstructions.content['en-US'].description, + }) + ) + } + + /** + * HACK: Since this is assumed to be an immediate child of the + * root, we return undefined. + * + * TODO: Look to have a base root class to extend so we do not + * need to implement this here. + * @returns + */ + getParent(): TreeNode | undefined { + return undefined + } + + static get instance() { + if (this.#instance === undefined) { + throw new ToolkitError('NotificationsNode was accessed before it has been initialized.') + } + + return this.#instance + } +} + +export function registerProvider(provider: ResourceTreeDataProvider) { + NotificationsNode.instance.provider = provider +} diff --git a/packages/core/src/notifications/rules.ts b/packages/core/src/notifications/rules.ts index 6bd3d8f314c..75b607e4f99 100644 --- a/packages/core/src/notifications/rules.ts +++ b/packages/core/src/notifications/rules.ts @@ -66,7 +66,8 @@ export class RuleEngine { } private evaluate(condition: DisplayIf): boolean { - if (condition.extensionId !== globals.context.extension.id) { + const currentExt = globals.context.extension.id + if (condition.extensionId !== currentExt) { return false } @@ -113,6 +114,7 @@ export class RuleEngine { // So... YAGNI switch (criteria.type) { case 'OS': + // todo: allow lowercase? return isExpected(this.context.os) case 'ComputeEnv': return isExpected(this.context.computeEnv) diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index bab3170dd87..1ba9fd22e2b 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode' import { EnvType, OperatingSystem } from '../shared/telemetry/util' +import { TypeConstructor } from '../shared/utilities/typeConstructors' /** Types of information that we can use to determine whether to show a notification or not. */ export type Criteria = @@ -74,6 +75,39 @@ export interface Notifications { notifications: ToolkitNotification[] } +export type NotificationData = { + payload?: Notifications + eTag?: string +} + +export type NotificationsState = { + // Categories + startUp: NotificationData + emergency: NotificationData + + // Util + dismissed: string[] +} + +export const NotificationsStateConstructor: TypeConstructor = (v: unknown): NotificationsState => { + const isNotificationsState = (v: Partial): v is NotificationsState => { + const requiredKeys: (keyof NotificationsState)[] = ['startUp', 'emergency', 'dismissed'] + return ( + requiredKeys.every((key) => key in v) && + Array.isArray(v.dismissed) && + typeof v.startUp === 'object' && + typeof v.emergency === 'object' + ) + } + + if (v && typeof v === 'object' && isNotificationsState(v)) { + return v + } + throw new Error('Cannot cast to NotificationsState.') +} + +export type NotificationType = keyof Omit + export interface RuleContext { readonly ideVersion: typeof vscode.version readonly extensionVersion: string diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index e792ced4209..3db27c88cd0 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -22,7 +22,7 @@ type samInitStateKey = type stepFunctionsKey = 'SCRIPT_LAST_DOWNLOADED_URL' | 'CSS_LAST_DOWNLOADED_URL' -type globalKey = +export type globalKey = | samInitStateKey | stepFunctionsKey | ToolIdStateKey @@ -31,6 +31,8 @@ type globalKey = | 'aws.amazonq.codewhisperer.newCustomizations' | 'aws.amazonq.hasShownWalkthrough' | 'aws.amazonq.showTryChatCodeLens' + | 'aws.amazonq.notifications' + | 'aws.notifications' | 'aws.downloadPath' | 'aws.lastTouchedS3Folder' | 'aws.lastUploadedToS3Folder' diff --git a/packages/core/src/shared/logger/toolkitLogger.ts b/packages/core/src/shared/logger/toolkitLogger.ts index 901294d1a11..9a3153ae9a1 100644 --- a/packages/core/src/shared/logger/toolkitLogger.ts +++ b/packages/core/src/shared/logger/toolkitLogger.ts @@ -15,7 +15,7 @@ import { ConsoleLogTransport } from './consoleLogTransport' import { isWeb } from '../extensionGlobals' /* define log topics */ -export type LogTopic = 'unknown' | 'test' | 'crashReport' +export type LogTopic = 'unknown' | 'test' | 'crashReport' | 'notifications' class ErrorLog { constructor( diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 5068bc59e13..e253666b867 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -10,12 +10,13 @@ import * as vscode from 'vscode' * * New keys must start with "aws." or "amazonq.". */ -type contextKey = +export type contextKey = | 'aws.isDevMode' | 'aws.isSageMaker' | 'aws.isWebExtHost' | 'aws.isInternalUser' | 'aws.amazonq.showLoginView' + | 'aws.amazonq.notifications.show' | 'aws.codecatalyst.connected' | 'aws.codewhisperer.connected' | 'aws.codewhisperer.connectionExpired' @@ -23,6 +24,7 @@ type contextKey = | 'aws.explorer.showAuthView' | 'aws.toolkit.amazonq.dismissed' | 'aws.toolkit.amazonqInstall.dismissed' + | 'aws.toolkit.notifications.show' // Deprecated/legacy names. New keys should start with "aws.". | 'codewhisperer.activeLine' | 'gumby.isPlanAvailable' @@ -43,6 +45,9 @@ type contextKey = * 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. */ export async function setContext(key: contextKey, val: any): Promise { // eslint-disable-next-line aws-toolkits/no-banned-usages diff --git a/packages/core/src/test/notifications/controller.test.ts b/packages/core/src/test/notifications/controller.test.ts new file mode 100644 index 00000000000..413cda0e70b --- /dev/null +++ b/packages/core/src/test/notifications/controller.test.ts @@ -0,0 +1,525 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as FakeTimers from '@sinonjs/fake-timers' +import assert from 'assert' +import sinon from 'sinon' +import { NotificationsController, NotificationsNode, RuleEngine } from '../../notifications' +import globals from '../../shared/extensionGlobals' +import { NotificationData, NotificationType, ToolkitNotification } from '../../notifications/types' +import { randomUUID } from '../../shared' +import { installFakeClock } from '../testUtil' +import { NotificationFetcher, RemoteFetcher, ResourceResponse } from '../../notifications/controller' +import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher' + +describe('Notifications Controller', function () { + const panelNode: NotificationsNode = new NotificationsNode() + const ruleEngine: RuleEngine = new RuleEngine({ + ideVersion: '1.83.0', + extensionVersion: '1.20.0', + os: 'LINUX', + computeEnv: 'local', + authTypes: ['builderId'], + authRegions: ['us-east-1'], + authStates: ['connected'], + authScopes: ['codewhisperer:completions', 'codewhisperer:analysis'], + installedExtensions: ['ext1', 'ext2', 'ext3'], + activeExtensions: ['ext1', 'ext2'], + }) + + let controller: NotificationsController + let fetcher: TestFetcher + + let ruleEngineSpy: sinon.SinonSpy + let focusPanelSpy: sinon.SinonSpy + + function dismissNotification(notification: ToolkitNotification) { + // We could call `controller.dismissNotification()`, but this emulates a call + // from actually clicking it in the view panel. + return vscode.commands.executeCommand('_aws.toolkit.notifications.dismiss', { + getTreeItem: () => { + const item = new vscode.TreeItem('test') + Object.assign(item, { + command: { arguments: [notification] }, + }) + + return item + }, + }) + } + + class TestFetcher implements NotificationFetcher { + private startUpContent: ResourceResponse = { + eTag: 'unset', + content: undefined, + } + private emergencyContent: ResourceResponse = { + eTag: 'unset', + content: undefined, + } + + setStartUpContent(content: ResourceResponse) { + this.startUpContent = content + } + + setEmergencyContent(content: ResourceResponse) { + this.emergencyContent = content + } + + async fetch(category: NotificationType): Promise { + return category === 'startUp' ? this.startUpContent : this.emergencyContent + } + } + + beforeEach(async function () { + panelNode.setNotifications([], []) + fetcher = new TestFetcher() + controller = new NotificationsController(panelNode, fetcher) + + ruleEngineSpy = sinon.spy(ruleEngine, 'shouldDisplayNotification') + focusPanelSpy = sinon.spy(panelNode, 'focusPanel') + + await globals.globalState.update(controller.storageKey, { + startUp: {} as NotificationData, + emergency: {} as NotificationData, + dismissed: [], + }) + }) + + afterEach(function () { + ruleEngineSpy.restore() + focusPanelSpy.restore() + }) + + it('can fetch and store startup notifications', async function () { + const eTag = randomUUID() + const content = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1'), getInvalidTestNotification('id:startup2')], + } + fetcher.setStartUpContent({ + eTag, + content: JSON.stringify(content), + }) + + await controller.pollForStartUp(ruleEngine) + + assert.equal(ruleEngineSpy.callCount, 2) + assert.deepStrictEqual(ruleEngineSpy.args, [[content.notifications[0]], [content.notifications[1]]]) + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: content, + eTag, + }, + emergency: {}, + dismissed: [], + }) + assert.equal(panelNode.startUpNotifications.length, 1) + assert.equal(panelNode.emergencyNotifications.length, 0) + assert.deepStrictEqual(panelNode.startUpNotifications, [content.notifications[0]]) + assert.equal(panelNode.getChildren().length, 1) + assert.equal(focusPanelSpy.callCount, 0) + }) + + it('can fetch and store emergency notifications', async function () { + const eTag = randomUUID() + const content = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:emergency1'), getInvalidTestNotification('id:emergency2')], + } + fetcher.setEmergencyContent({ + eTag, + content: JSON.stringify(content), + }) + + await controller.pollForEmergencies(ruleEngine) + + assert.equal(ruleEngineSpy.callCount, 2) + assert.deepStrictEqual(ruleEngineSpy.args, [[content.notifications[0]], [content.notifications[1]]]) + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: {}, + emergency: { + payload: content, + eTag, + }, + dismissed: [content.notifications[0].id], + }) + assert.equal(panelNode.startUpNotifications.length, 0) + assert.equal(panelNode.emergencyNotifications.length, 1) + assert.deepStrictEqual(panelNode.emergencyNotifications, [content.notifications[0]]) + assert.equal(panelNode.getChildren().length, 1) + assert.equal(focusPanelSpy.callCount, 1) + }) + + it('can fetch and store both startup and emergency notifications', async function () { + const eTag1 = randomUUID() + const eTag2 = randomUUID() + const startUpContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1'), getInvalidTestNotification('id:startup2')], + } + const emergencyContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:emergency1'), getInvalidTestNotification('id:emergency2')], + } + fetcher.setStartUpContent({ + eTag: eTag1, + content: JSON.stringify(startUpContent), + }) + fetcher.setEmergencyContent({ + eTag: eTag2, + content: JSON.stringify(emergencyContent), + }) + + await controller.pollForStartUp(ruleEngine) + await controller.pollForEmergencies(ruleEngine) + + // There are only 4 notifications in this test. + // However, each time there is a poll, ALL notifications are evaluated for display. + // First poll = 2 startup notifications + // Second poll = 2 emergency notifications + 2 startup notifications from the first poll + // = 6 + assert.equal(ruleEngineSpy.callCount, 6) + + assert.deepStrictEqual(ruleEngineSpy.args, [ + [startUpContent.notifications[0]], + [startUpContent.notifications[1]], + [startUpContent.notifications[0]], + [startUpContent.notifications[1]], + [emergencyContent.notifications[0]], + [emergencyContent.notifications[1]], + ]) + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: startUpContent, + eTag: eTag1, + }, + emergency: { + payload: emergencyContent, + eTag: eTag2, + }, + dismissed: [emergencyContent.notifications[0].id], + }) + assert.equal(panelNode.startUpNotifications.length, 1) + assert.equal(panelNode.emergencyNotifications.length, 1) + assert.deepStrictEqual(panelNode.startUpNotifications, [startUpContent.notifications[0]]) + assert.deepStrictEqual(panelNode.emergencyNotifications, [emergencyContent.notifications[0]]) + assert.equal(panelNode.getChildren().length, 2) + assert.equal(focusPanelSpy.callCount, 1) + }) + + it('dismisses a startup notification', async function () { + const eTag = randomUUID() + const content = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1'), getValidTestNotification('id:startup2')], + } + fetcher.setStartUpContent({ + eTag, + content: JSON.stringify(content), + }) + + await controller.pollForStartUp(ruleEngine) + + assert.equal(panelNode.getChildren().length, 2) + assert.equal(panelNode.startUpNotifications.length, 2) + + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: content, + eTag, + }, + emergency: {}, + dismissed: [], + }) + + await dismissNotification(content.notifications[1]) + + const actualState = await globals.globalState.get(controller.storageKey) + assert.deepStrictEqual(actualState, { + startUp: { + payload: content, + eTag, + }, + emergency: {}, + dismissed: [content.notifications[1].id], + }) + + assert.equal(panelNode.getChildren().length, 1) + assert.equal(panelNode.startUpNotifications.length, 1) + }) + + it('does not redisplay dismissed notifications', async function () { + const content = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1')], + } + fetcher.setStartUpContent({ + eTag: '1', + content: JSON.stringify(content), + }) + + await controller.pollForStartUp(ruleEngine) + assert.equal(panelNode.getChildren().length, 1) + + await dismissNotification(content.notifications[0]) + assert.equal(panelNode.getChildren().length, 0) + + content.notifications.push(getValidTestNotification('id:startup2')) + fetcher.setStartUpContent({ + eTag: '1', + content: JSON.stringify(content), + }) + + await controller.pollForStartUp(ruleEngine) + + const actualState = await globals.globalState.get(controller.storageKey) + assert.deepStrictEqual(actualState, { + startUp: { + payload: content, + eTag: '1', + }, + emergency: {}, + dismissed: [content.notifications[0].id], + }) + + assert.equal(panelNode.getChildren().length, 1) + }) + + it('does not refocus emergency notifications', async function () { + const startUpContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1')], + } + const emergencyContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:emergency1')], + } + fetcher.setStartUpContent({ + eTag: '1', + content: JSON.stringify(startUpContent), + }) + fetcher.setEmergencyContent({ + eTag: '1', + content: JSON.stringify(emergencyContent), + }) + + await controller.pollForEmergencies(ruleEngine) + await controller.pollForEmergencies(ruleEngine) + await controller.pollForStartUp(ruleEngine) + + assert.equal(focusPanelSpy.callCount, 1) + assert.equal(panelNode.getChildren().length, 2) + }) + + it('does not update state if eTag is not changed', async function () { + const eTag = randomUUID() + const content = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1'), getInvalidTestNotification('id:startup2')], + } + fetcher.setStartUpContent({ + eTag, + content: JSON.stringify(content), + }) + + await controller.pollForStartUp(ruleEngine) + + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: content, + eTag, + }, + emergency: {}, + dismissed: [], + }) + assert.equal(panelNode.getChildren().length, 1) + + fetcher.setStartUpContent({ + eTag, + content: undefined, + }) + await controller.pollForStartUp(ruleEngine) + + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: content, + eTag, + }, + emergency: {}, + dismissed: [], + }) + assert.equal(panelNode.getChildren().length, 1) + }) + + it('cleans out dismissed state', async function () { + const startUpContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:startup1')], + } + const emergencyContent = { + schemaVersion: '1.x', + notifications: [getValidTestNotification('id:emergency1')], + } + fetcher.setStartUpContent({ + eTag: '1', + content: JSON.stringify(startUpContent), + }) + fetcher.setEmergencyContent({ + eTag: '1', + content: JSON.stringify(emergencyContent), + }) + + await controller.pollForStartUp(ruleEngine) + await controller.pollForEmergencies(ruleEngine) + + await dismissNotification(startUpContent.notifications[0]) + + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: startUpContent, + eTag: '1', + }, + emergency: { + payload: emergencyContent, + eTag: '1', + }, + dismissed: [emergencyContent.notifications[0].id, startUpContent.notifications[0].id], + }) + + const emptyContent = { + schemaVersion: '1.x', + notifications: [], + } + fetcher.setStartUpContent({ + eTag: '1', + content: JSON.stringify(emptyContent), + }) + + await controller.pollForStartUp(ruleEngine) + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: emptyContent, + eTag: '1', + }, + emergency: { + payload: emergencyContent, + eTag: '1', + }, + dismissed: [emergencyContent.notifications[0].id], + }) + assert.equal(panelNode.getChildren().length, 1) + + fetcher.setEmergencyContent({ + eTag: '1', + content: JSON.stringify(emptyContent), + }) + + await controller.pollForEmergencies(ruleEngine) + assert.deepStrictEqual(await globals.globalState.get(controller.storageKey), { + startUp: { + payload: emptyContent, + eTag: '1', + }, + emergency: { + payload: emptyContent, + eTag: '1', + }, + dismissed: [], + }) + + assert.equal(panelNode.getChildren().length, 0) + }) + + it('does not rethrow errors when fetching', async function () { + let wasCalled = false + const fetcher = new (class _ extends TestFetcher { + override async fetch(): Promise { + wasCalled = true + throw new Error('test error') + } + })() + assert.doesNotThrow(() => new NotificationsController(panelNode, fetcher).pollForStartUp(ruleEngine)) + assert.ok(wasCalled) + }) +}) + +describe('RemoteFetcher', function () { + let clock: FakeTimers.InstalledClock + + before(function () { + clock = installFakeClock() + }) + + afterEach(function () { + clock.reset() + }) + + after(function () { + clock.uninstall() + }) + + it('retries and throws error', async function () { + const httpStub = sinon.stub(HttpResourceFetcher.prototype, 'getNewETagContent') + httpStub.throws(new Error('network error')) + + const runClock = (async () => { + await clock.tickAsync(1) + for (let n = 1; n <= RemoteFetcher.retryNumber; n++) { + assert.equal(httpStub.callCount, n) + await clock.tickAsync(RemoteFetcher.retryIntervalMs) + } + + // Stop trying + await clock.tickAsync(RemoteFetcher.retryNumber) + assert.equal(httpStub.callCount, RemoteFetcher.retryNumber) + })() + + const fetcher = new RemoteFetcher() + await fetcher + .fetch('startUp', 'any') + .then(() => assert.ok(false, 'Did not throw exception.')) + .catch(() => assert.ok(true)) + await runClock + + httpStub.restore() + }) +}) + +function getValidTestNotification(id: string) { + return { + id, + displayIf: { + extensionId: 'aws.toolkit.fake.extension', + }, + uiRenderInstructions: { + content: { + [`en-US`]: { + title: 'test', + description: 'test', + }, + }, + }, + } +} + +function getInvalidTestNotification(id: string) { + return { + id, + displayIf: { + extensionId: 'aws.toolkit.fake.extension', + additionalCriteria: [{ type: 'OS', values: ['MAC'] }], + }, + uiRenderInstructions: { + content: { + [`en-US`]: { + title: 'test', + description: 'test', + }, + }, + }, + } +} diff --git a/packages/core/src/test/notifications/resources/emergency/1.x.json b/packages/core/src/test/notifications/resources/emergency/1.x.json new file mode 100644 index 00000000000..864c6fdc5e5 --- /dev/null +++ b/packages/core/src/test/notifications/resources/emergency/1.x.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "1.x", + "notifications": [ + { + "id": "amazonq_emergency_local_1", + "displayIf": { + "extensionId": "amazonwebservices.amazon-q-vscode" + }, + "uiRenderInstructions": { + "content": { + "en-US": { + "title": "[local] Emergency: Broken stuff", + "description": "Something crazy is happening! Please update your extension." + } + } + } + }, + { + "id": "toolkit_emergency_local_1", + "displayIf": { + "extensionId": "amazonwebservices.aws-toolkit-vscode" + }, + "uiRenderInstructions": { + "content": { + "en-US": { + "title": "[local] Emergency: Broken stuff", + "description": "Something crazy is happening! Please update your extension." + } + } + } + } + ] +} diff --git a/packages/core/src/test/notifications/resources/startup/1.x.json b/packages/core/src/test/notifications/resources/startup/1.x.json new file mode 100644 index 00000000000..b0e7bc2e72f --- /dev/null +++ b/packages/core/src/test/notifications/resources/startup/1.x.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "1.x", + "notifications": [ + { + "id": "amazonq_startUp_local_1", + "displayIf": { + "extensionId": "amazonwebservices.amazon-q-vscode" + }, + "uiRenderInstructions": { + "content": { + "en-US": { + "title": "[local] What's New", + "description": "Check out this new stuff!" + } + } + } + }, + { + "id": "toolkit_startUp_local_1", + "displayIf": { + "extensionId": "amazonwebservices.aws-toolkit-vscode" + }, + "uiRenderInstructions": { + "content": { + "en-US": { + "title": "[local] What's New", + "description": "Check out this new stuff!" + } + } + } + } + ] +} diff --git a/packages/core/src/test/notifications/rules.test.ts b/packages/core/src/test/notifications/rules.test.ts index f1772817024..07eead40c99 100644 --- a/packages/core/src/test/notifications/rules.test.ts +++ b/packages/core/src/test/notifications/rules.test.ts @@ -8,7 +8,6 @@ import { RuleEngine } from '../../notifications/rules' import { DisplayIf, ToolkitNotification, RuleContext } from '../../notifications/types' import { globals } from '../../shared' -// TODO: remove auth page and tests describe('Notifications Rule Engine', function () { const context: RuleContext = { ideVersion: '1.83.0', diff --git a/packages/core/src/test/notifications/types.test.ts b/packages/core/src/test/notifications/types.test.ts new file mode 100644 index 00000000000..926d5b84333 --- /dev/null +++ b/packages/core/src/test/notifications/types.test.ts @@ -0,0 +1,45 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { NotificationsState, NotificationsStateConstructor } from '../../notifications/types' + +describe('NotificationsState type validation', function () { + it('passes on valid input', async function () { + const state: NotificationsState = { + startUp: {}, + emergency: {}, + dismissed: [], + } + let ret + assert.doesNotThrow(() => { + ret = NotificationsStateConstructor(state) + }) + assert.deepStrictEqual(ret, state) + }) + + it('fails on invalid input', async function () { + assert.throws(() => { + NotificationsStateConstructor('' as unknown as NotificationsState) + }) + assert.throws(() => { + NotificationsStateConstructor({} as NotificationsState) + }) + assert.throws(() => { + NotificationsStateConstructor({ + startUp: {}, + emergency: {}, + dismissed: {}, // x + } as NotificationsState) + }) + assert.throws(() => { + NotificationsStateConstructor({ + startUp: {}, // x + emergency: '', // x + dismissed: [], + } as NotificationsState) + }) + }) +}) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 454f0a0897e..25bebbbaa97 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -701,6 +701,11 @@ }, "views": { "aws-explorer": [ + { + "id": "aws.toolkit.notifications", + "name": "%AWS.notifications.title%", + "when": "!isCloud9 && !aws.isSageMaker && aws.toolkit.notifications.show" + }, { "id": "aws.amazonq.codewhisperer", "name": "%AWS.amazonq.codewhisperer.title%", @@ -1359,6 +1364,11 @@ } ], "view/item/context": [ + { + "command": "_aws.toolkit.notifications.dismiss", + "when": "viewItem == toolkitNotificationStartUp", + "group": "inline@1" + }, { "command": "aws.apig.invokeRemoteRestApi", "when": "view == aws.explorer && viewItem =~ /^(awsApiGatewayNode)$/", @@ -2041,6 +2051,13 @@ ] }, "commands": [ + { + "command": "_aws.toolkit.notifications.dismiss", + "title": "%AWS.generic.dismiss%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": "$(remove-close)" + }, { "command": "aws.accessanalyzer.iamPolicyChecks", "title": "%AWS.command.accessanalyzer.iamPolicyChecks%",