diff --git a/packages/core/src/notifications/rules.ts b/packages/core/src/notifications/rules.ts new file mode 100644 index 00000000000..6bd3d8f314c --- /dev/null +++ b/packages/core/src/notifications/rules.ts @@ -0,0 +1,135 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as semver from 'semver' +import globals from '../shared/extensionGlobals' +import { ConditionalClause, RuleContext, DisplayIf, CriteriaCondition, ToolkitNotification } from './types' + +/** + * Evaluates if a given version fits into the parameters specified by a notification, e.g: + * + * extensionVersion: { + * type: 'range', + * lowerInclusive: '1.21.0' + * } + * + * will match all versions 1.21.0 and up. + * + * @param version the version to check + * @param condition the condition to check against + * @returns true if the version satisfies the condition + */ +function isValidVersion(version: string, condition: ConditionalClause): boolean { + switch (condition.type) { + case 'range': { + const lowerConstraint = !condition.lowerInclusive || semver.gte(version, condition.lowerInclusive) + const upperConstraint = !condition.upperExclusive || semver.lt(version, condition.upperExclusive) + return lowerConstraint && upperConstraint + } + case 'exactMatch': + return condition.values.some((v) => semver.eq(v, version)) + case 'or': + /** Check case where any of the subconditions are true, i.e. one of multiple range or exactMatch conditions */ + return condition.clauses.some((clause) => isValidVersion(version, clause)) + default: + throw new Error(`Unknown clause type: ${(condition as any).type}`) + } +} + +/** + * Determine whether or not to display a given notification based on whether the + * notification requirements fit the extension context provided on initialization. + * + * Usage: + * const myContext = { + * extensionVersion: '4.5.6', + * ... + * } + * + * const ruleEngine = new RuleEngine(myContext) + * + * notifications.forEach(n => { + * if (ruleEngine.shouldDisplayNotification(n)) { + * // process notification + * ... + * } + * }) + * + */ +export class RuleEngine { + constructor(private readonly context: RuleContext) {} + + public shouldDisplayNotification(payload: ToolkitNotification) { + return this.evaluate(payload.displayIf) + } + + private evaluate(condition: DisplayIf): boolean { + if (condition.extensionId !== globals.context.extension.id) { + return false + } + + if (condition.ideVersion) { + if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) { + return false + } + } + if (condition.extensionVersion) { + if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) { + return false + } + } + + if (condition.additionalCriteria) { + for (const criteria of condition.additionalCriteria) { + if (!this.evaluateRule(criteria)) { + return false + } + } + } + + return true + } + + private evaluateRule(criteria: CriteriaCondition) { + const expected = criteria.values + const expectedSet = new Set(expected) + + const isExpected = (i: string) => expectedSet.has(i) + const hasAnyOfExpected = (i: string[]) => i.some((v) => expectedSet.has(v)) + const isSuperSetOfExpected = (i: string[]) => { + const s = new Set(i) + return expected.every((v) => s.has(v)) + } + const isEqualSetToExpected = (i: string[]) => { + const s = new Set(i) + return expected.every((v) => s.has(v)) && i.every((v) => expectedSet.has(v)) + } + + // Maybe we could abstract these out into some strategy pattern with classes. + // But this list is short and its unclear if we need to expand it further. + // Also, we might replace this with a common implementation amongst the toolkits. + // So... YAGNI + switch (criteria.type) { + case 'OS': + return isExpected(this.context.os) + case 'ComputeEnv': + return isExpected(this.context.computeEnv) + case 'AuthType': + return hasAnyOfExpected(this.context.authTypes) + case 'AuthRegion': + return hasAnyOfExpected(this.context.authRegions) + case 'AuthState': + return hasAnyOfExpected(this.context.authStates) + case 'AuthScopes': + return isEqualSetToExpected(this.context.authScopes) + case 'InstalledExtensions': + return isSuperSetOfExpected(this.context.installedExtensions) + case 'ActiveExtensions': + return isSuperSetOfExpected(this.context.activeExtensions) + default: + throw new Error(`Unknown criteria type: ${criteria.type}`) + } + } +} diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts new file mode 100644 index 00000000000..bab3170dd87 --- /dev/null +++ b/packages/core/src/notifications/types.ts @@ -0,0 +1,88 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { EnvType, OperatingSystem } from '../shared/telemetry/util' + +/** Types of information that we can use to determine whether to show a notification or not. */ +export type Criteria = + | 'OS' + | 'ComputeEnv' + | 'AuthType' + | 'AuthRegion' + | 'AuthState' + | 'AuthScopes' + | 'InstalledExtensions' + | 'ActiveExtensions' + +/** Generic condition where the type determines how the values are evaluated. */ +export interface CriteriaCondition { + readonly type: Criteria + readonly values: string[] +} + +/** One of the subconditions (clauses) must match to be valid. */ +export interface OR { + readonly type: 'or' + readonly clauses: (Range | ExactMatch)[] +} + +/** Version must be within the bounds to be valid. Missing bound indicates that bound is open-ended. */ +export interface Range { + readonly type: 'range' + readonly lowerInclusive?: string // null means "-inf" + readonly upperExclusive?: string // null means "+inf" +} + +/** Version must be equal. */ +export interface ExactMatch { + readonly type: 'exactMatch' + readonly values: string[] +} + +export type ConditionalClause = Range | ExactMatch | OR + +/** How to display the notification. */ +export interface UIRenderInstructions { + content: { + [`en-US`]: { + title: string + description: string + } + } + // TODO actions +} + +/** Condition/criteria section of a notification. */ +export interface DisplayIf { + extensionId: string + ideVersion?: ConditionalClause + extensionVersion?: ConditionalClause + additionalCriteria?: CriteriaCondition[] +} + +export interface ToolkitNotification { + id: string + displayIf: DisplayIf + uiRenderInstructions: UIRenderInstructions +} + +export interface Notifications { + schemaVersion: string + notifications: ToolkitNotification[] +} + +export interface RuleContext { + readonly ideVersion: typeof vscode.version + readonly extensionVersion: string + readonly os: OperatingSystem + readonly computeEnv: EnvType + readonly authTypes: string[] + readonly authRegions: string[] + readonly authStates: string[] + readonly authScopes: string[] + readonly installedExtensions: string[] + readonly activeExtensions: string[] +} diff --git a/packages/core/src/shared/telemetry/util.ts b/packages/core/src/shared/telemetry/util.ts index 08a4bed0807..bd6b5b206a6 100644 --- a/packages/core/src/shared/telemetry/util.ts +++ b/packages/core/src/shared/telemetry/util.ts @@ -226,7 +226,7 @@ export function getUserAgent( * NOTES: * - append `-amzn` for any environment internal to Amazon */ -type EnvType = +export type EnvType = | 'cloud9' | 'cloud9-codecatalyst' | 'cloudDesktop-amzn' @@ -322,12 +322,13 @@ export function getOptOutPreference() { return globals.telemetry.telemetryEnabled ? 'OPTIN' : 'OPTOUT' } +export type OperatingSystem = 'MAC' | 'WINDOWS' | 'LINUX' /** * Useful for populating the sendTelemetryEvent request from codewhisperer's api for publishing custom telemetry events for AB Testing. * * Returns one of the enum values of the OperatingSystem model (see SendTelemetryRequest model in the codebase) */ -export function getOperatingSystem(): 'MAC' | 'WINDOWS' | 'LINUX' { +export function getOperatingSystem(): OperatingSystem { const osId = os.platform() // 'darwin', 'win32', 'linux', etc. if (osId === 'darwin') { return 'MAC' diff --git a/packages/core/src/test/notifications/rules.test.ts b/packages/core/src/test/notifications/rules.test.ts new file mode 100644 index 00000000000..f1772817024 --- /dev/null +++ b/packages/core/src/test/notifications/rules.test.ts @@ -0,0 +1,471 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +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', + 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'], + } + + const ruleEngine = new RuleEngine(context) + + function buildNotification(criteria: Omit): ToolkitNotification { + return { + id: 'bd22f116-edd4-4e80-8f1f-ec7340159016', + displayIf: { extensionId: globals.context.extension.id, ...criteria }, + uiRenderInstructions: { + content: { + [`en-US`]: { + title: 'Something crazy is happening!', + description: 'Something crazy is happening! Please update your extension.', + }, + }, + }, + } + } + + it('should display notification with no criteria', function () { + const notification = buildNotification({}) + assert.equal(ruleEngine.shouldDisplayNotification(notification), true) + }) + + it('should display notification with version exact criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + ideVersion: { + type: 'exactMatch', + values: ['1.82.0', '1.83.0'], + }, + }) + ), + true + ) + + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'exactMatch', + values: ['1.19.0', '1.20.0'], + }, + }) + ), + true + ) + }) + + it('should NOT display notification with invalid version exact criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + ideVersion: { + type: 'exactMatch', + values: ['1.82.0', '1.84.0'], + }, + }) + ), + false + ) + + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'exactMatch', + values: ['1.19.0', '1.21.0'], + }, + }) + ), + false + ) + }) + + it('should display notification with version range criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'range', + lowerInclusive: '1.20.0', + upperExclusive: '1.21.0', + }, + }) + ), + true + ) + + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'range', + upperExclusive: '1.23.0', + }, + }) + ), + true + ) + + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'range', + lowerInclusive: '1.0.0', + }, + }) + ), + true + ) + }) + + it('should NOT display notification with invalid version range criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'range', + lowerInclusive: '1.18.0', + upperExclusive: '1.20.0', + }, + }) + ), + false + ) + }) + + it('should display notification with version OR criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'or', + clauses: [ + { + type: 'exactMatch', + values: ['1.18.0', '1.19.0'], + }, + { + type: 'range', + lowerInclusive: '1.18.0', + upperExclusive: '1.21.0', + }, + ], + }, + }) + ), + true + ) + }) + + it('should NOT display notification with invalid version OR criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + extensionVersion: { + type: 'or', + clauses: [ + { + type: 'exactMatch', + values: ['1.18.0', '1.19.0'], + }, + { + type: 'range', + lowerInclusive: '1.18.0', + upperExclusive: '1.20.0', + }, + ], + }, + }) + ), + false + ) + }) + + it('should display notification for OS criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'OS', values: ['LINUX', 'MAC'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid OS criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'OS', values: ['MAC'] }], + }) + ), + false + ) + }) + + it('should display notification for ComputeEnv criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'ComputeEnv', values: ['local', 'ec2'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid ComputeEnv criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'ComputeEnv', values: ['ec2'] }], + }) + ), + false + ) + }) + + it('should display notification for AuthType criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthType', values: ['builderId', 'iamIdentityCenter'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid AuthType criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthType', values: ['iamIdentityCenter'] }], + }) + ), + false + ) + }) + + it('should display notification for AuthRegion criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthRegion', values: ['us-east-1', 'us-west-2'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid AuthRegion criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthRegion', values: ['us-west-2'] }], + }) + ), + false + ) + }) + + it('should display notification for AuthState criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthState', values: ['connected'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid AuthState criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthState', values: ['notConnected'] }], + }) + ), + false + ) + }) + + it('should display notification for AuthScopes criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [ + { type: 'AuthScopes', values: ['codewhisperer:completions', 'codewhisperer:analysis'] }, + ], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid AuthScopes criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'AuthScopes', values: ['codewhisperer:completions'] }], + }) + ), + false + ) + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [ + { + type: 'AuthScopes', + values: ['codewhisperer:completions', 'codewhisperer:analysis', 'sso:account:access'], + }, + ], + }) + ), + false + ) + }) + + it('should display notification for InstalledExtensions criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'InstalledExtensions', values: ['ext1', 'ext2'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid InstalledExtensions criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'InstalledExtensions', values: ['ext1', 'ext2', 'unkownExtension'] }], + }) + ), + false + ) + }) + + it('should display notification for ActiveExtensions criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'ActiveExtensions', values: ['ext1', 'ext2'] }], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid ActiveExtensions criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + additionalCriteria: [{ type: 'ActiveExtensions', values: ['ext1', 'ext2', 'unknownExtension'] }], + }) + ), + false + ) + }) + + it('should display notification for combined criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + ideVersion: { + type: 'or', + clauses: [ + { + type: 'range', + lowerInclusive: '1.70.0', + upperExclusive: '1.81.0', + }, + { + type: 'range', + lowerInclusive: '1.81.0', + upperExclusive: '1.83.3', + }, + ], + }, + extensionVersion: { + type: 'exactMatch', + values: ['1.19.0', '1.20.0'], + }, + additionalCriteria: [ + { type: 'OS', values: ['LINUX', 'MAC'] }, + { type: 'ComputeEnv', values: ['local', 'ec2'] }, + { type: 'AuthType', values: ['builderId', 'iamIdentityCenter'] }, + { type: 'AuthRegion', values: ['us-east-1', 'us-west-2'] }, + { type: 'AuthState', values: ['connected'] }, + { type: 'AuthScopes', values: ['codewhisperer:completions', 'codewhisperer:analysis'] }, + { type: 'InstalledExtensions', values: ['ext1', 'ext2'] }, + { type: 'ActiveExtensions', values: ['ext1', 'ext2'] }, + ], + }) + ), + true + ) + }) + + it('should NOT display notification for invalid combined criteria', function () { + assert.equal( + ruleEngine.shouldDisplayNotification( + buildNotification({ + ideVersion: { + type: 'or', + clauses: [ + { + type: 'range', + lowerInclusive: '1.70.0', + upperExclusive: '1.81.0', + }, + { + type: 'range', + lowerInclusive: '1.80.0', + upperExclusive: '1.83.3', + }, + ], + }, + extensionVersion: { + type: 'exactMatch', + values: ['1.19.0', '1.20.0'], + }, + additionalCriteria: [ + { type: 'OS', values: ['LINUX', 'MAC'] }, + { type: 'AuthType', values: ['builderId', 'iamIdentityCenter'] }, + { type: 'AuthRegion', values: ['us-east-1', 'us-west-2'] }, + { type: 'AuthState', values: ['connected'] }, + { type: 'AuthScopes', values: ['codewhisperer:completions', 'codewhisperer:analysis'] }, + { type: 'InstalledExtensions', values: ['ex1', 'ext2'] }, + { type: 'ActiveExtensions', values: ['ext1', 'ext2'] }, + + { type: 'ComputeEnv', values: ['ec2'] }, // no 'local' + ], + }) + ), + false + ) + }) +})