Skip to content

Commit 9b40d90

Browse files
committed
test(notifications): add e2e tests
1 parent 87ea4d9 commit 9b40d90

File tree

11 files changed

+241
-17
lines changed

11 files changed

+241
-17
lines changed

packages/amazonq/src/extensionNode.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import { Auth, AuthUtils, getTelemetryMetadataForConn, isAnySsoConnection } from
1818
import api from './api'
1919
import { activate as activateCWChat } from './app/chat/activation'
2020
import { beta } from 'aws-core-vscode/dev'
21-
import { activate as activateNotifications } from 'aws-core-vscode/notifications'
21+
import {
22+
RemoteFetcher,
23+
activate as activateNotifications,
24+
deactivate as deactivateNotifications,
25+
} from 'aws-core-vscode/notifications'
2226
import { AuthState, AuthUtil } from 'aws-core-vscode/codewhisperer'
2327
import { telemetry, AuthUserState } from 'aws-core-vscode/telemetry'
2428

@@ -75,7 +79,10 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) {
7579
...authState,
7680
})
7781

78-
await activateNotifications(context, authState, getAuthState)
82+
await activateNotifications(context, authState, getAuthState, {
83+
fetcher: new RemoteFetcher(),
84+
storageKey: 'aws.notifications',
85+
})
7986
}
8087

8188
async function getAuthState(): Promise<Omit<AuthUserState, 'source'>> {
@@ -147,4 +154,5 @@ async function setupDevMode(context: vscode.ExtensionContext) {
147154
export async function deactivate() {
148155
// Run concurrently to speed up execution. stop() does not throw so it is safe
149156
await Promise.all([(await CrashMonitoring.instance())?.shutdown(), deactivateCommon()])
157+
deactivateNotifications()
150158
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0 fort
4+
*/
5+
6+
import { getNotificationsE2ESuite } from 'aws-core-vscode/test'
7+
import { getAuthState } from '../../../src/extensionNode'
8+
9+
getNotificationsE2ESuite(getAuthState)

packages/core/src/extensionNode.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import * as beta from './dev/beta'
3939
import { activate as activateApplicationComposer } from './applicationcomposer/activation'
4040
import { activate as activateRedshift } from './awsService/redshift/activation'
4141
import { activate as activateIamPolicyChecks } from './awsService/accessanalyzer/activation'
42-
import { activate as activateNotifications } from './notifications/activation'
42+
import { activate as activateNotifications, deactivate as deactivateNotifications } from './notifications/activation'
4343
import { SchemaService } from './shared/schemas'
4444
import { AwsResourceManager } from './dynamicResources/awsResourceManager'
4545
import globals from './shared/extensionGlobals'
@@ -60,6 +60,7 @@ import { activate as activateThreatComposerEditor } from './threatComposer/activ
6060
import { isSsoConnection, hasScopes } from './auth/connection'
6161
import { CrashMonitoring, setContext } from './shared'
6262
import { AuthFormId } from './login/webview/vue/types'
63+
import { RemoteFetcher } from './notifications/controller'
6364

6465
let localize: nls.LocalizeFunc
6566

@@ -246,7 +247,10 @@ export async function activate(context: vscode.ExtensionContext) {
246247
...authState,
247248
})
248249

249-
await activateNotifications(context, authState, getAuthState)
250+
await activateNotifications(context, authState, getAuthState, {
251+
fetcher: new RemoteFetcher(),
252+
storageKey: 'aws.notifications',
253+
})
250254
} catch (error) {
251255
const stacktrace = (error as Error).stack?.split('\n')
252256
// truncate if the stacktrace is unusually long
@@ -270,6 +274,7 @@ export async function deactivate() {
270274
// Run concurrently to speed up execution. stop() does not throw so it is safe
271275
await Promise.all([await (await CrashMonitoring.instance())?.shutdown(), deactivateCommon(), deactivateEc2()])
272276
await globals.resourceManager.dispose()
277+
deactivateNotifications()
273278
}
274279

275280
async function handleAmazonQInstall() {

packages/core/src/notifications/activation.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as vscode from 'vscode'
77
import { DevSettings } from '../shared/settings'
8-
import { NotificationsController } from './controller'
8+
import { NotificationsController, ControllerOptions } from './controller'
99
import { NotificationsNode } from './panelNode'
1010
import { RuleEngine, getRuleContext } from './rules'
1111
import globals from '../shared/extensionGlobals'
@@ -15,6 +15,7 @@ import { oneMinute } from '../shared/datetime'
1515

1616
/** Time in MS to poll for emergency notifications */
1717
const emergencyPollTime = oneMinute * 10
18+
let interval: NodeJS.Timer
1819

1920
/**
2021
* Activate the in-IDE notifications module and begin receiving notifications.
@@ -26,7 +27,8 @@ const emergencyPollTime = oneMinute * 10
2627
export async function activate(
2728
context: vscode.ExtensionContext,
2829
initialState: AuthState,
29-
authStateFn: () => Promise<AuthState>
30+
authStateFn: () => Promise<AuthState>,
31+
options: Omit<ControllerOptions, 'node'>
3032
) {
3133
// TODO: Currently gated behind feature-flag.
3234
if (!DevSettings.instance.get('notifications', false)) {
@@ -36,16 +38,25 @@ export async function activate(
3638
const panelNode = NotificationsNode.instance
3739
panelNode.registerView(context)
3840

39-
const controller = new NotificationsController(panelNode)
41+
const controller = new NotificationsController({ node: panelNode, ...options })
4042
const engine = new RuleEngine(await getRuleContext(context, initialState))
4143

4244
await controller.pollForStartUp(engine)
4345
await controller.pollForEmergencies(engine)
4446

45-
globals.clock.setInterval(async () => {
47+
if (interval !== undefined) {
48+
globals.clock.clearInterval(interval)
49+
}
50+
51+
interval = globals.clock.setInterval(async () => {
4652
const ruleContext = await getRuleContext(context, await authStateFn())
4753
await controller.pollForEmergencies(new RuleEngine(ruleContext))
4854
}, emergencyPollTime)
4955

5056
getLogger('notifications').debug('Activated in-IDE notifications polling module')
5157
}
58+
59+
export function deactivate() {
60+
globals.clock.clearInterval(interval)
61+
getLogger('notifications').debug('Deactivated in-IDE notifications polling module')
62+
}

packages/core/src/notifications/controller.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetch
2626
import { isAmazonQ } from '../shared/extensionUtilities'
2727
import { telemetry } from '../shared/telemetry/telemetry'
2828

29+
export type ControllerOptions = {
30+
node: NotificationsNode
31+
storageKey: globalKey
32+
fetcher: NotificationFetcher
33+
}
34+
2935
/**
3036
* Handles fetching and maintaining the state of in-IDE notifications.
3137
* Notifications are constantly polled from a known endpoint and then stored in global state.
@@ -40,23 +46,25 @@ import { telemetry } from '../shared/telemetry/telemetry'
4046
*/
4147
export class NotificationsController {
4248
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes
49+
public readonly storageKey: globalKey
4350

4451
/** Internal memory state that is written to global state upon modification. */
4552
private readonly state: NotificationsState
53+
private readonly notificationsNode: NotificationsNode
54+
private readonly fetcher: NotificationFetcher
4655

4756
static #instance: NotificationsController | undefined
4857

49-
constructor(
50-
private readonly notificationsNode: NotificationsNode,
51-
private readonly fetcher: NotificationFetcher = new RemoteFetcher(),
52-
public readonly storageKey: globalKey = 'aws.notifications'
53-
) {
58+
constructor(options: ControllerOptions) {
5459
if (!NotificationsController.#instance) {
5560
// Register on first creation only.
5661
registerDismissCommand()
5762
}
5863
NotificationsController.#instance = this
5964

65+
this.notificationsNode = options.node
66+
this.storageKey = options.storageKey
67+
this.fetcher = options.fetcher
6068
this.state = this.getDefaultState()
6169
}
6270

packages/core/src/notifications/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
export { activate } from './activation'
6+
export { activate, deactivate } from './activation'
7+
export { RemoteFetcher } from './controller'

packages/core/src/notifications/rules.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,13 @@ export class RuleEngine {
117117
if (condition.additionalCriteria) {
118118
for (const criteria of condition.additionalCriteria) {
119119
if (!this.evaluateRule(criteria)) {
120-
logger.verbose('notification id: (%s) did NOT pass criteria check: %O', id, criteria)
120+
// We want to see nested objects. It is not deep.
121+
// eslint-disable-next-line aws-toolkits/no-json-stringify-in-log
122+
logger.verbose(
123+
'notification id: (%s) did NOT pass criteria check: %s',
124+
id,
125+
JSON.stringify(criteria)
126+
)
121127
return false
122128
}
123129
logger.debug('notification id: (%s) passed criteria check: %O', id, criteria)
@@ -201,7 +207,9 @@ export async function getRuleContext(context: vscode.ExtensionContext, authState
201207
}
202208

203209
const { activeExtensions, ...loggableRuleContext } = ruleContext
204-
logger.debug('getRuleContext() determined rule context: %O', loggableRuleContext)
210+
// We want to see the nested objects. It is not deep.
211+
// eslint-disable-next-line aws-toolkits/no-json-stringify-in-log
212+
logger.debug('getRuleContext() determined rule context: %s', JSON.stringify(loggableRuleContext))
205213

206214
return ruleContext
207215
}

packages/core/src/test/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { getTestLogger } from './globalSetup.test'
1919
export { testCommand } from './shared/vscode/testUtils'
2020
export { FakeAwsContext } from './utilities/fakeAwsContext'
2121
export { getTestWorkspaceFolder } from '../testInteg/integrationTestsUtilities'
22+
export { getNotificationsE2ESuite } from '../testE2E/notifications/notifications.test'
2223
export * from './codewhisperer/testUtil'
2324
export * from './credentials/testUtil'
2425
export * from './testUtil'
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0 fort
4+
*/
5+
6+
import { RemoteFetcher } from '../../notifications/controller'
7+
import { NotificationsNode } from '../../notifications/panelNode'
8+
import { activate, deactivate } from '../../notifications/activation'
9+
import globals from '../../shared/extensionGlobals'
10+
import assert from 'assert'
11+
import sinon from 'sinon'
12+
import { createTestAuth } from '../../test/credentials/testUtil'
13+
import { AuthUtil } from '../../codewhisperer/util/authUtil'
14+
import { Auth } from '../../auth/auth'
15+
import { ShownMessage } from '../../test/shared/vscode/message'
16+
import { getTestWindow } from '../../test/shared/vscode/window'
17+
import { AuthUserState } from '../../shared/telemetry/telemetry.gen'
18+
import { globalKey } from '../../shared/globalState'
19+
import { waitUntil } from '../../shared/utilities/timeoutUtils'
20+
import { assertTelemetry, assertTextEditorContains } from '../../test/testUtil'
21+
22+
/**
23+
* Tests that connect to our hosted files server and download the integ notifications.
24+
*
25+
* IMPORTANT:
26+
* These tests are dependent on what is hosted on the server (message contents, criteria, etc).
27+
*/
28+
export function getNotificationsE2ESuite(getAuthStateFn: () => Promise<Omit<AuthUserState, 'source'>>) {
29+
return describe('Notifications Integration Test', function () {
30+
const storageKey = 'aws.notifications.test' as globalKey
31+
const fetcher = new RemoteFetcher(
32+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/startup/1.x.json',
33+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/emergency/1.x.json'
34+
)
35+
// const panelNode = NotificationsNode.instance
36+
const sandbox = sinon.createSandbox()
37+
let auth: ReturnType<typeof createTestAuth>
38+
let authUtil: AuthUtil
39+
40+
beforeEach(async function () {
41+
await globals.globalState.update(storageKey, undefined)
42+
auth = createTestAuth(globals.globalState)
43+
authUtil = new AuthUtil(auth)
44+
sandbox.stub(Auth, 'instance').value(auth)
45+
sandbox.stub(AuthUtil, 'instance').value(authUtil)
46+
})
47+
48+
afterEach(async function () {
49+
await globals.globalState.update(storageKey, undefined)
50+
sandbox.restore()
51+
})
52+
53+
/**
54+
* A way to track notifications displayed in the IDE.
55+
* See {@link setupTestWindow} for usage.
56+
*/
57+
function msgHandler(text: string, fn: (m: ShownMessage) => Promise<void>) {
58+
return {
59+
seen: false, // have we seen and processed this message
60+
text, // title of the notification to match the message on
61+
fn, // what to do with the message
62+
}
63+
}
64+
65+
function setupTestWindow(toHandle: ReturnType<typeof msgHandler>[]) {
66+
const testWindow = getTestWindow()
67+
testWindow.onDidShowMessage(async (message) => {
68+
const handler = toHandle.find((h) => message.message.includes(h.text))
69+
if (handler) {
70+
await handler.fn(message)
71+
handler.seen = true
72+
}
73+
})
74+
75+
return testWindow
76+
}
77+
78+
/**
79+
* Set up the test window, activate the notifications module, and wait for
80+
* messages to resolve in the UI.
81+
*/
82+
async function runTest(toHandle: ReturnType<typeof msgHandler>[]) {
83+
setupTestWindow(toHandle)
84+
85+
const initialState = await getAuthStateFn()
86+
await activate(globals.context, initialState, getAuthStateFn, {
87+
fetcher,
88+
storageKey,
89+
})
90+
91+
await waitUntil(async () => toHandle.every((h) => h.seen), { timeout: 12000 })
92+
}
93+
94+
it('can fetch unauthenticated notifications', async function () {
95+
await runTest([
96+
msgHandler('New Amazon Q features are available!', async (m: ShownMessage) => {
97+
assert.ok(!m.modal)
98+
assert.ok(m.items.find((i) => i.title.includes('Learn more')))
99+
m.close()
100+
}),
101+
msgHandler(
102+
'Signing into Amazon Q is broken, please try this workaround while we work on releasing a fix.',
103+
async (m: ShownMessage) => {
104+
assert.ok(!m.modal)
105+
m.selectItem('Learn more')
106+
await assertTextEditorContains(
107+
'There is currently a bug that is preventing users from signing into Amazon Q.',
108+
false
109+
)
110+
}
111+
),
112+
])
113+
114+
assert.equal(NotificationsNode.instance.getChildren().length, 2)
115+
assertTelemetry('toolkit_showNotification', [
116+
{ id: 'TARGETED_NOTIFICATION:startup2' },
117+
{ id: 'TARGETED_NOTIFICATION:emergency1' },
118+
])
119+
assertTelemetry('toolkit_invokeAction', [
120+
{ action: 'OK', source: 'TARGETED_NOTIFICATION:startup2' },
121+
{ action: 'Learn more', source: 'TARGETED_NOTIFICATION:emergency1' },
122+
])
123+
124+
deactivate()
125+
})
126+
127+
it('can fetch authenticated notifications', async function () {
128+
await auth.useConnection(await authUtil.connectToAwsBuilderId())
129+
await runTest([
130+
msgHandler('New Amazon Q features available: inline chat', async (m: ShownMessage) => {
131+
assert.ok(!m.modal)
132+
m.selectItem('Learn more')
133+
await assertTextEditorContains(
134+
'You can now use Amazon Q inline in your IDE, without ever touching the mouse or using copy and paste.',
135+
false
136+
)
137+
}),
138+
msgHandler('Amazon Q may delete user data', async (m: ShownMessage) => {
139+
assert.ok(m.modal)
140+
assert.ok(m.items.find((i) => i.title.includes('Update and Reload')))
141+
m.close()
142+
}),
143+
])
144+
145+
assert.equal(NotificationsNode.instance.getChildren().length, 3) // includes one startup notification that wasn't checked here. (checked in another test)
146+
assertTelemetry('toolkit_showNotification', [
147+
{ id: 'TARGETED_NOTIFICATION:startup1' },
148+
{ id: 'TARGETED_NOTIFICATION:startup2' },
149+
{ id: 'TARGETED_NOTIFICATION:emergency2' },
150+
])
151+
assertTelemetry('toolkit_invokeAction', [
152+
{ action: 'Learn more', source: 'TARGETED_NOTIFICATION:startup1' },
153+
{ action: 'OK', source: 'TARGETED_NOTIFICATION:emergency2' },
154+
])
155+
156+
deactivate()
157+
})
158+
})
159+
}

packages/core/src/testInteg/notifications/notifications.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import globals from '../../shared/extensionGlobals'
1212
import assert from 'assert'
1313
import { VSCODE_EXTENSION_ID } from '../../shared/extensions'
1414
import sinon from 'sinon'
15+
import { globalKey } from '../../shared'
1516

1617
describe('Notifications Integration Test', function () {
1718
let fetcher: RemoteFetcher
@@ -43,7 +44,11 @@ describe('Notifications Integration Test', function () {
4344
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/startup/1.x.json',
4445
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/integ/VSCode/emergency/1.x.json'
4546
)
46-
controller = new NotificationsController(panelNode, fetcher)
47+
controller = new NotificationsController({
48+
node: panelNode,
49+
fetcher,
50+
storageKey: 'aws.notifications.test' as globalKey,
51+
})
4752
})
4853

4954
// Clear all global states after each test

0 commit comments

Comments
 (0)