From 41f64ae4ee5270896544becd207719ffaf2c2b4a Mon Sep 17 00:00:00 2001
From: Maxim Hayes <149123719+hayemaxi@users.noreply.github.com>
Date: Fri, 25 Oct 2024 11:14:25 -0400
Subject: [PATCH] feat(notifications): notifications controller and view panel
(#5828)
- NotificationsController
- Fetches notifications and determines which ones to display.
- NotificationsNode
- Side panel that displays a given list (from controller) of
notifications.
The controller will fetch notifications from an endpoint (or local files
during development). These notifications will be stored in memory and
global state. They will then be evaluated for dismissal/criteria and if
they pass they will be sent to the NotificationsNode for display.
Extensions will call `NotificationController.pollForStartUp()` and
`NotificationController.pollForEmergencies()` to get notifications.
---
License: I confirm that my contribution is made under the terms of the
Apache 2.0 license.
---
packages/amazonq/package.json | 19 +
packages/core/package.json | 3 +-
packages/core/package.nls.json | 2 +
.../core/src/codewhisperer/util/zipUtil.ts | 2 +-
packages/core/src/notifications/controller.ts | 260 +++++++++
packages/core/src/notifications/index.ts | 9 +
packages/core/src/notifications/panelNode.ts | 163 ++++++
packages/core/src/notifications/rules.ts | 4 +-
packages/core/src/notifications/types.ts | 34 ++
packages/core/src/shared/globalState.ts | 4 +-
.../core/src/shared/logger/toolkitLogger.ts | 2 +-
packages/core/src/shared/vscode/setContext.ts | 7 +-
.../src/test/notifications/controller.test.ts | 525 ++++++++++++++++++
.../resources/emergency/1.x.json | 33 ++
.../notifications/resources/startup/1.x.json | 33 ++
.../core/src/test/notifications/rules.test.ts | 1 -
.../core/src/test/notifications/types.test.ts | 45 ++
packages/toolkit/package.json | 17 +
18 files changed, 1156 insertions(+), 7 deletions(-)
create mode 100644 packages/core/src/notifications/controller.ts
create mode 100644 packages/core/src/notifications/index.ts
create mode 100644 packages/core/src/notifications/panelNode.ts
create mode 100644 packages/core/src/test/notifications/controller.test.ts
create mode 100644 packages/core/src/test/notifications/resources/emergency/1.x.json
create mode 100644 packages/core/src/test/notifications/resources/startup/1.x.json
create mode 100644 packages/core/src/test/notifications/types.test.ts
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%",