diff --git a/package-lock.json b/package-lock.json index 647c44a8d1a..ba97701fde0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "vscode-nls-dev": "^4.0.4" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.295", + "@aws-toolkits/telemetry": "^1.0.296", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", @@ -6047,9 +6047,9 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.295", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.295.tgz", - "integrity": "sha512-NGBM5vhNNHwEhok3asXpUW7oZv/z8mjZaf34LGflqEh/5+VraTd76T+QBz18sC+nE2sPvhTO+zjptR9zg5bBUA==", + "version": "1.0.296", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.296.tgz", + "integrity": "sha512-laPLYzImOCyAyZWWrmEXWWXZv2xeS5PKatVksGuCxjSohI+20fuCAUjhwMtyq7bRUPkOTkG9pzVYB7Rg49QHFg==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f3b9b4a308c..8e03e4326c8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "mergeReports": "ts-node ./scripts/mergeReports.ts" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.295", + "@aws-toolkits/telemetry": "^1.0.296", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", diff --git a/packages/amazonq/src/lsp/activation.ts b/packages/amazonq/src/lsp/activation.ts index 10e0c93eec5..783c35ddc94 100644 --- a/packages/amazonq/src/lsp/activation.ts +++ b/packages/amazonq/src/lsp/activation.ts @@ -6,12 +6,14 @@ import vscode from 'vscode' import { startLanguageServer } from './client' import { AmazonQLSPResolver } from './lspInstaller' -import { Commands, ToolkitError } from 'aws-core-vscode/shared' +import { Commands, lspSetupStage, ToolkitError } from 'aws-core-vscode/shared' export async function activate(ctx: vscode.ExtensionContext): Promise { try { - const installResult = await new AmazonQLSPResolver().resolve() - await startLanguageServer(ctx, installResult.resourcePaths) + await lspSetupStage('all', async () => { + const installResult = await new AmazonQLSPResolver().resolve() + await lspSetupStage('launch', async () => await startLanguageServer(ctx, installResult.resourcePaths)) + }) ctx.subscriptions.push( Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') diff --git a/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts b/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts index 0bf2edafa3f..5e1d3f68d47 100644 --- a/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts +++ b/packages/amazonq/test/e2e/lsp/lspInstaller.test.ts @@ -8,12 +8,17 @@ import sinon from 'sinon' import { AmazonQLSPResolver, supportedLspServerVersions } from '../../../src/lsp/lspInstaller' import { fs, + globals, LanguageServerResolver, makeTemporaryToolkitFolder, ManifestResolver, + manifestStorageKey, request, } from 'aws-core-vscode/shared' import * as semver from 'semver' +import { assertTelemetry } from 'aws-core-vscode/test' +import { LspController } from 'aws-core-vscode/amazonq' +import { LanguageServerSetup } from 'aws-core-vscode/telemetry' function createVersion(version: string) { return { @@ -40,12 +45,22 @@ describe('AmazonQLSPInstaller', () => { let resolver: AmazonQLSPResolver let sandbox: sinon.SinonSandbox let tempDir: string + // If globalState contains an ETag that is up to date with remote, we won't fetch it resulting in inconsistent behavior. + // Therefore, we clear it temporarily for these tests to ensure consistent behavior. + let manifestStorage: { [key: string]: any } + + before(async () => { + manifestStorage = globals.globalState.get(manifestStorageKey) || {} + }) beforeEach(async () => { sandbox = sinon.createSandbox() resolver = new AmazonQLSPResolver() tempDir = await makeTemporaryToolkitFolder() sandbox.stub(LanguageServerResolver.prototype, 'defaultDownloadFolder').returns(tempDir) + // Called on extension activation and can contaminate telemetry. + sandbox.stub(LspController.prototype, 'trySetupLsp') + await globals.globalState.update(manifestStorageKey, {}) }) afterEach(async () => { @@ -56,6 +71,10 @@ describe('AmazonQLSPInstaller', () => { }) }) + after(async () => { + await globals.globalState.update(manifestStorageKey, manifestStorage) + }) + describe('resolve()', () => { it('uses AWS_LANGUAGE_SERVER_OVERRIDE', async () => { const overridePath = '/custom/path/to/lsp' @@ -117,6 +136,94 @@ describe('AmazonQLSPInstaller', () => { assert.ok(fallback.assetDirectory.startsWith(tempDir)) assert.deepStrictEqual(fallback.location, 'fallback') assert.ok(semver.satisfies(fallback.version, supportedLspServerVersions)) + + /* First Try Telemetry + getManifest: remote succeeds + getServer: cache fails then remote succeeds. + validate: succeeds. + */ + const firstTryTelemetry: Partial[] = [ + { + id: 'AmazonQ', + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + result: 'Succeeded', + }, + { + id: 'AmazonQ', + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: 'AmazonQ', + languageServerLocation: 'remote', + languageServerSetupStage: 'validate', + result: 'Succeeded', + }, + { + id: 'AmazonQ', + languageServerLocation: 'remote', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + /* Second Try Telemetry + getManifest: remote fails, then cache succeeds. + getServer: cache succeeds + validate: doesn't run since its cached. + */ + const secondTryTelemetry: Partial[] = [ + { + id: 'AmazonQ', + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + result: 'Failed', + }, + { + id: 'AmazonQ', + manifestLocation: 'cache', + languageServerSetupStage: 'getManifest', + result: 'Succeeded', + }, + { + id: 'AmazonQ', + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + /* Third Try Telemetry + getManifest: (stubbed to fail, no telemetry) + getServer: remote and cache fail + validate: no validation since not remote. + */ + const thirdTryTelemetry: Partial[] = [ + { + id: 'AmazonQ', + languageServerLocation: 'cache', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: 'AmazonQ', + languageServerLocation: 'remote', + languageServerSetupStage: 'getServer', + result: 'Failed', + }, + { + id: 'AmazonQ', + languageServerLocation: 'fallback', + languageServerSetupStage: 'getServer', + result: 'Succeeded', + }, + ] + + const expectedTelemetry = firstTryTelemetry.concat(secondTryTelemetry, thirdTryTelemetry) + + assertTelemetry('languageServer_setup', expectedTelemetry) }) }) }) diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 5d96e40e71f..ef158f01c66 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -20,7 +20,7 @@ "AWS.configuration.description.suppressPrompts": "Prompts which ask for confirmation. Checking an item suppresses the prompt.", "AWS.configuration.enableCodeLenses": "Enable SAM hints in source code and template.yaml files", "AWS.configuration.description.resources.enabledResources": "AWS resources to display in the 'Resources' portion of the explorer.", - "AWS.configuration.description.featureDevelopment.allowRunningCodeAndTests": "Allow /dev to run code and test commands", + "AWS.configuration.description.featureDevelopment.allowRunningCodeAndTests": "Allow /dev to run code and test commands", "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.", "AWS.stepFunctions.asl.format.enable.desc": "Enables the default formatter used with Amazon States Language files", "AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index 3b38d9a17a2..f556a070775 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -15,6 +15,7 @@ import { isCloud9 } from '../../shared/extensionUtilities' import globals, { isWeb } from '../../shared/extensionGlobals' import { isAmazonInternalOs } from '../../shared/vscode/env' import { WorkspaceLSPResolver } from './workspaceInstaller' +import { lspSetupStage } from '../../shared' export interface Chunk { readonly filePath: string @@ -160,9 +161,7 @@ export class LspController { } setImmediate(async () => { try { - const installResult = await new WorkspaceLSPResolver().resolve() - await activateLsp(context, installResult.resourcePaths) - getLogger().info('LspController: LSP activated') + await this.setupLsp(context) void LspController.instance.buildIndex(buildIndexConfig) // log the LSP server CPU and Memory usage per 30 minutes. globals.clock.setInterval( @@ -183,4 +182,12 @@ export class LspController { } }) } + + private async setupLsp(context: vscode.ExtensionContext) { + await lspSetupStage('all', async () => { + const installResult = await new WorkspaceLSPResolver().resolve() + await lspSetupStage('launch', async () => activateLsp(context, installResult.resourcePaths)) + getLogger().info('LspController: LSP activated') + }) + } } diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index f82990bc83e..b45948308bf 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -65,6 +65,7 @@ export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants' export * from './lsp/manifestResolver' export * from './lsp/lspResolver' export * from './lsp/types' +export * from './lsp/utils/setupStage' export * from './lsp/utils/cleanup' export { default as request } from './request' export * from './lsp/utils/platform' diff --git a/packages/core/src/shared/lsp/lspResolver.ts b/packages/core/src/shared/lsp/lspResolver.ts index 5a907b96a02..265de137ed9 100644 --- a/packages/core/src/shared/lsp/lspResolver.ts +++ b/packages/core/src/shared/lsp/lspResolver.ts @@ -12,6 +12,7 @@ import AdmZip from 'adm-zip' import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types' import { getApplicationSupportFolder } from '../vscode/env' import { createHash } from '../crypto' +import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage' import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' export class LanguageServerResolver { @@ -30,51 +31,40 @@ export class LanguageServerResolver { * @throws ToolkitError if no compatible version can be found */ async resolve() { - const result: LspResult = { - location: 'unknown', - version: '', - assetDirectory: '', - } - const latestVersion = this.latestCompatibleLspVersion() const targetContents = this.getLSPTargetContents(latestVersion) const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion) - if (await this.hasValidLocalCache(cacheDirectory, targetContents)) { - result.location = 'cache' - result.version = latestVersion.serverVersion - result.assetDirectory = cacheDirectory - return result - } else { - // Delete the cached directory since it's invalid - if (await fs.existsDir(cacheDirectory)) { - await fs.delete(cacheDirectory, { - recursive: true, - }) - } - } - - if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) { - result.location = 'remote' - result.version = latestVersion.serverVersion - result.assetDirectory = cacheDirectory - return result - } else { - // clean up any leftover content that may have been downloaded - if (await fs.existsDir(cacheDirectory)) { - await fs.delete(cacheDirectory, { - recursive: true, - }) + const serverResolvers: StageResolver[] = [ + { + resolve: async () => await this.getLocalServer(cacheDirectory, latestVersion, targetContents), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'cache' }, + }, + { + resolve: async () => await this.fetchRemoteServer(cacheDirectory, latestVersion, targetContents), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'remote' }, + }, + { + resolve: async () => await this.getFallbackServer(latestVersion), + telemetryMetadata: { id: this.lsName, languageServerLocation: 'fallback' }, + }, + ] + + return await tryStageResolvers('getServer', serverResolvers, getServerVersion) + + function getServerVersion(result: LspResult) { + return { + languageServerVersion: result.version, } } + } - logger.info( - `Unable to download language server version ${latestVersion.serverVersion}. Attempting to fetch from fallback location` - ) - + private async getFallbackServer(latestVersion: LspVersion): Promise { const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion) if (!fallbackDirectory) { - throw new ToolkitError('Unable to find a compatible version of the Language Server') + throw new ToolkitError('Unable to find a compatible version of the Language Server', { + code: 'IncompatibleVersion', + }) } const version = path.basename(fallbackDirectory) @@ -82,11 +72,49 @@ export class LanguageServerResolver { `Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}` ) - result.location = 'fallback' - result.version = version - result.assetDirectory = fallbackDirectory + return { + location: 'fallback', + version: version, + assetDirectory: fallbackDirectory, + } + } + + private async fetchRemoteServer( + cacheDirectory: string, + latestVersion: LspVersion, + targetContents: TargetContent[] + ): Promise { + if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion)) { + return { + location: 'remote', + version: latestVersion.serverVersion, + assetDirectory: cacheDirectory, + } + } else { + throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' }) + } + } - return result + private async getLocalServer( + cacheDirectory: string, + latestVersion: LspVersion, + targetContents: TargetContent[] + ): Promise { + if (await this.hasValidLocalCache(cacheDirectory, targetContents)) { + return { + location: 'cache', + version: latestVersion.serverVersion, + assetDirectory: cacheDirectory, + } + } else { + // Delete the cached directory since it's invalid + if (await fs.existsDir(cacheDirectory)) { + await fs.delete(cacheDirectory, { + recursive: true, + }) + } + throw new ToolkitError('Failed to retrieve server from cache', { code: 'InvalidCache' }) + } } /** @@ -164,25 +192,37 @@ export class LanguageServerResolver { await fs.mkdir(downloadDirectory) } - const downloadTasks = contents.map(async (content) => { - const res = await new HttpResourceFetcher(content.url, { showUrl: true }).get() - if (!res || !res.ok || !res.body) { - return false + const fetchTasks = contents.map(async (content) => { + return { + res: await new HttpResourceFetcher(content.url, { showUrl: true }).get(), + hash: content.hashes[0], + filename: content.filename, } + }) + const fetchResults = await Promise.all(fetchTasks) + const verifyTasks = fetchResults + .filter((fetchResult) => fetchResult.res && fetchResult.res.ok && fetchResult.res.body) + .flatMap(async (fetchResult) => { + const arrBuffer = await fetchResult.res!.arrayBuffer() + const data = Buffer.from(arrBuffer) + + const hash = createHash('sha384', data) + if (hash === fetchResult.hash) { + return [{ filename: fetchResult.filename, data }] + } + return [] + }) + if (verifyTasks.length !== contents.length) { + return false + } - const arrBuffer = await res.arrayBuffer() - const data = Buffer.from(arrBuffer) + const filesToDownload = await lspSetupStage('validate', async () => (await Promise.all(verifyTasks)).flat()) - const hash = createHash('sha384', data) - if (hash === content.hashes[0]) { - await fs.writeFile(`${downloadDirectory}/${content.filename}`, data) - return true - } - return false - }) - const downloadResults = await Promise.all(downloadTasks) - const downloadResult = downloadResults.every(Boolean) - return downloadResult && this.extractZipFilesFromRemote(downloadDirectory) + for (const file of filesToDownload) { + await fs.writeFile(`${downloadDirectory}/${file.filename}`, file.data) + } + + return this.extractZipFilesFromRemote(downloadDirectory) } private async extractZipFilesFromRemote(downloadDirectory: string) { @@ -333,7 +373,9 @@ export class LanguageServerResolver { private getCompatibleLspTarget(version: LspVersion) { // TODO make this web friendly // TODO make this fully support windows - const platform = process.platform + + // Workaround: Manifest platform field is `windows`, whereas node returns win32 + const platform = process.platform === 'win32' ? 'windows' : process.platform const arch = process.arch return version.targets.find((x) => x.arch === arch && x.platform === platform) } diff --git a/packages/core/src/shared/lsp/manifestResolver.ts b/packages/core/src/shared/lsp/manifestResolver.ts index 0cf27b1293b..e19dcb0ced1 100644 --- a/packages/core/src/shared/lsp/manifestResolver.ts +++ b/packages/core/src/shared/lsp/manifestResolver.ts @@ -8,6 +8,7 @@ import { ToolkitError } from '../errors' import { Timeout } from '../utilities/timeoutUtils' import globals from '../extensionGlobals' import { Manifest } from './types' +import { StageResolver, tryStageResolvers } from './utils/setupStage' import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher' const logger = getLogger('lsp') @@ -19,7 +20,7 @@ interface StorageManifest { type ManifestStorage = Record -const manifestStorageKey = 'aws.toolkit.lsp.manifest' +export const manifestStorageKey = 'aws.toolkit.lsp.manifest' const manifestTimeoutMs = 15000 export class ManifestResolver { @@ -32,10 +33,23 @@ export class ManifestResolver { * Fetches the latest manifest, falling back to local cache on failure */ async resolve(): Promise { - try { - return await this.fetchRemoteManifest() - } catch (error) { - return await this.getLocalManifest() + const resolvers: StageResolver[] = [ + { + resolve: async () => await this.fetchRemoteManifest(), + telemetryMetadata: { id: this.lsName, manifestLocation: 'remote' }, + }, + { + resolve: async () => await this.getLocalManifest(), + telemetryMetadata: { id: this.lsName, manifestLocation: 'cache' }, + }, + ] + + return await tryStageResolvers('getManifest', resolvers, extractMetadata) + + function extractMetadata(r: Manifest) { + return { + manifestSchemaVersion: r.manifestSchemaVersion, + } } } @@ -52,7 +66,7 @@ export class ManifestResolver { const manifest = this.parseManifest(resp.content) await this.saveManifest(resp.eTag, resp.content) this.checkDeprecation(manifest) - + manifest.location = 'remote' return manifest } @@ -67,6 +81,7 @@ export class ManifestResolver { const manifest = this.parseManifest(manifestData.content) this.checkDeprecation(manifest) + manifest.location = 'cache' return manifest } diff --git a/packages/core/src/shared/lsp/types.ts b/packages/core/src/shared/lsp/types.ts index 4f5a3cf1c87..ef7518db69a 100644 --- a/packages/core/src/shared/lsp/types.ts +++ b/packages/core/src/shared/lsp/types.ts @@ -4,13 +4,12 @@ */ import { getLogger } from '../logger/logger' +import { LanguageServerLocation, ManifestLocation } from '../telemetry' export const logger = getLogger('lsp') -type Location = 'remote' | 'cache' | 'override' | 'fallback' | 'unknown' - export interface LspResult { - location: Location + location: LanguageServerLocation version: string assetDirectory: string } @@ -53,6 +52,7 @@ export interface Manifest { artifactDescription: string isManifestDeprecated: boolean versions: LspVersion[] + location?: ManifestLocation } export interface VersionRange { diff --git a/packages/core/src/shared/lsp/utils/setupStage.ts b/packages/core/src/shared/lsp/utils/setupStage.ts new file mode 100644 index 00000000000..f8932f0b2ef --- /dev/null +++ b/packages/core/src/shared/lsp/utils/setupStage.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageServerSetup, LanguageServerSetupStage, telemetry } from '../../telemetry' +import { tryFunctions } from '../../utilities/tsUtils' + +/** + * Runs the designated stage within a telemetry span and optionally uses the getMetadata extractor to record metadata from the result of the stage. + * @param stageName name of stage for telemetry. + * @param runStage stage to be run. + * @param getMetadata metadata extractor to be applied to result. + * @returns result of stage + */ +export async function lspSetupStage( + stageName: LanguageServerSetupStage, + runStage: () => Promise, + getMetadata?: MetadataExtractor +) { + return await telemetry.languageServer_setup.run(async (span) => { + span.record({ languageServerSetupStage: stageName }) + const result = await runStage() + if (getMetadata) { + span.record(getMetadata(result)) + } + return result + }) +} +/** + * Tries to resolve the result of a stage using the resolvers provided in order. The first one to succceed + * has its result returned, but all intermediate will emit telemetry. + * @param stageName name of stage to resolve. + * @param resolvers stage resolvers to try IN ORDER + * @param getMetadata function to be applied to result to extract necessary metadata for telemetry. + * @returns result of the first succesful resolver. + */ +export async function tryStageResolvers( + stageName: LanguageServerSetupStage, + resolvers: StageResolver[], + getMetadata: MetadataExtractor +) { + const fs = resolvers.map((resolver) => async () => { + return await lspSetupStage( + stageName, + async () => { + telemetry.record(resolver.telemetryMetadata) + const result = await resolver.resolve() + return result + }, + getMetadata + ) + }) + + return await tryFunctions(fs) +} + +/** + * A method that returns the result of a stage along with the default telemetry metadata to attach to the stage metric. + */ +export interface StageResolver { + resolve: () => Promise + telemetryMetadata: Partial +} + +type MetadataExtractor = (r: R) => Partial diff --git a/packages/core/src/shared/utilities/tsUtils.ts b/packages/core/src/shared/utilities/tsUtils.ts index 2e4838c5b7a..09a9b276280 100644 --- a/packages/core/src/shared/utilities/tsUtils.ts +++ b/packages/core/src/shared/utilities/tsUtils.ts @@ -94,6 +94,23 @@ export function createFactoryFunction any>(cto return (...args) => new ctor(...args) } +/** + * Try functions in the order presented and return the first returned result. If none return, throw the final error. + * @param functions non-empty list of functions to try. + * @returns + */ +export async function tryFunctions(functions: (() => Promise)[]): Promise { + let currentError: Error = new Error('No functions provided') + for (const func of functions) { + try { + return await func() + } catch (e) { + currentError = e as Error + } + } + throw currentError +} + /** * Split a list into two sublists based on the result of a predicate. * @param lst list to split diff --git a/packages/core/src/test/shared/lsp/manifestResolver.test.ts b/packages/core/src/test/shared/lsp/manifestResolver.test.ts new file mode 100644 index 00000000000..eb5f8e90893 --- /dev/null +++ b/packages/core/src/test/shared/lsp/manifestResolver.test.ts @@ -0,0 +1,101 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { Manifest, ManifestResolver } from '../../../shared' +import { assertTelemetry } from '../../testUtil' +import { ManifestLocation } from '../../../shared/telemetry' + +const manifestSchemaVersion = '1.0.0' +const serverName = 'myLS' + +/** + * Helper function generating valid manifest results for tests. + * @param location + * @returns + */ +function manifestResult(location: ManifestLocation): Manifest { + return { + location, + manifestSchemaVersion, + artifactId: 'artifact-id', + artifactDescription: 'artifact-description', + isManifestDeprecated: false, + versions: [], + } +} + +describe('manifestResolver', function () { + let remoteStub: sinon.SinonStub + let localStub: sinon.SinonStub + + before(function () { + remoteStub = sinon.stub(ManifestResolver.prototype, 'fetchRemoteManifest' as any) + localStub = sinon.stub(ManifestResolver.prototype, 'getLocalManifest' as any) + }) + + after(function () { + sinon.restore() + }) + + it('attempts to fetch from remote first', async function () { + remoteStub.resolves(manifestResult('remote')) + + const r = await new ManifestResolver('remote-manifest.com', serverName).resolve() + assert.strictEqual(r.location, 'remote') + assertTelemetry('languageServer_setup', { + manifestLocation: 'remote', + manifestSchemaVersion, + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Succeeded', + }) + }) + + it('uses local cache when remote fails', async function () { + remoteStub.rejects(new Error('failed to fetch')) + localStub.resolves(manifestResult('cache')) + + const r = await new ManifestResolver('remote-manifest.com', serverName).resolve() + assert.strictEqual(r.location, 'cache') + assertTelemetry('languageServer_setup', [ + { + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + { + manifestLocation: 'cache', + manifestSchemaVersion, + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Succeeded', + }, + ]) + }) + + it('fails if both local and remote fail', async function () { + remoteStub.rejects(new Error('failed to fetch')) + localStub.rejects(new Error('failed to fetch')) + + await assert.rejects(new ManifestResolver('remote-manifest.com', serverName).resolve(), /failed to fetch/) + assertTelemetry('languageServer_setup', [ + { + manifestLocation: 'remote', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + { + manifestLocation: 'cache', + languageServerSetupStage: 'getManifest', + id: serverName, + result: 'Failed', + }, + ]) + }) +}) diff --git a/packages/core/src/test/shared/utilities/tsUtils.test.ts b/packages/core/src/test/shared/utilities/tsUtils.test.ts index eb04da035e5..384d47df6ec 100644 --- a/packages/core/src/test/shared/utilities/tsUtils.test.ts +++ b/packages/core/src/test/shared/utilities/tsUtils.test.ts @@ -2,9 +2,30 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ - -import { partition } from '../../../shared/utilities/tsUtils' import assert from 'assert' +import { tryFunctions } from '../../../shared/utilities/tsUtils' +import { partition } from '../../../shared/utilities/tsUtils' + +describe('tryFunctions', function () { + it('should return the result of the first function that returns', async function () { + const f1 = () => Promise.reject('f1') + const f2 = () => Promise.resolve('f2') + const f3 = () => Promise.reject('f3') + + assert.strictEqual(await tryFunctions([f1, f2, f3]), 'f2') + }) + + it('if all reject, then should throw final error', async function () { + const f1 = () => Promise.reject('f1') + const f2 = () => Promise.reject('f2') + const f3 = () => Promise.reject('f3') + + await assert.rejects( + async () => await tryFunctions([f1, f2, f3]), + (e) => e === 'f3' + ) + }) +}) describe('partition', function () { it('should split the list according to predicate', function () {