diff --git a/extensions/azdata/package.json b/extensions/azdata/package.json index 45c50cf62a..bbbaba11b6 100644 --- a/extensions/azdata/package.json +++ b/extensions/azdata/package.json @@ -21,6 +21,7 @@ "main": "./out/extension", "dependencies": { "request": "^2.88.2", + "semver": "^7.3.2", "sudo-prompt": "^9.2.1", "vscode-nls": "^4.1.2", "which": "^2.0.2" @@ -29,6 +30,7 @@ "@types/mocha": "^5.2.5", "@types/node": "^12.11.7", "@types/request": "^2.48.5", + "@types/semver": "^7.3.1", "@types/sinon": "^9.0.4", "@types/uuid": "^8.0.0", "@types/which": "^1.3.2", diff --git a/extensions/azdata/src/azdata.ts b/extensions/azdata/src/azdata.ts index c7d2fb04a8..dc2730564a 100644 --- a/extensions/azdata/src/azdata.ts +++ b/extensions/azdata/src/azdata.ts @@ -3,21 +3,27 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as os from 'os'; -import * as vscode from 'vscode'; -import { HttpClient } from './common/httpClient'; -import * as loc from './localizedConstants'; -import { executeCommand, executeSudoCommand, ExitCodeError } from './common/childProcess'; -import { searchForCmd } from './common/utils'; import * as azdataExt from 'azdata-ext'; +import * as os from 'os'; +import { SemVer } from 'semver'; +import * as vscode from 'vscode'; +import { executeCommand, executeSudoCommand, ExitCodeError } from './common/childProcess'; +import { HttpClient } from './common/httpClient'; import Logger from './common/logger'; +import { getErrorMessage, searchForCmd } from './common/utils'; +import * as loc from './localizedConstants'; export const azdataHostname = 'https://aka.ms'; export const azdataUri = 'azdata-msi'; +export const azdataReleaseJson = 'azdata/release.json'; +/** + * Interface for an object to interact with the azdata tool installed on the box. + */ export interface IAzdataTool extends azdataExt.IAzdataApi { path: string, - toolVersion: string, + cachedVersion: SemVer + /** * Executes azdata with the specified arguments (e.g. --version) and returns the result * @param args The args to pass to azdata @@ -26,8 +32,14 @@ export interface IAzdataTool extends azdataExt.IAzdataApi { executeCommand(args: string[], additionalEnvVars?: { [key: string]: string }): Promise> } -class AzdataTool implements IAzdataTool { - constructor(public path: string, public toolVersion: string) { } +/** + * An object to interact with the azdata tool installed on the box. + */ +export class AzdataTool implements IAzdataTool { + public cachedVersion: SemVer; + constructor(public path: string, version: string) { + this.cachedVersion = new SemVer(version); + } public arc = { dc: { @@ -90,10 +102,19 @@ class AzdataTool implements IAzdataTool { return this.executeCommand(['login', '-e', endpoint, '-u', username], { 'AZDATA_PASSWORD': password }); } + /** + * Gets the output of running '--version' command on the azdata tool. + * It also updates the cachedVersion property based on the return value from the tool. + */ public async version(): Promise> { - const output = await this.executeCommand(['--version']); - this.toolVersion = parseVersion(output.stdout[0]); - return output; + const output = await executeCommand(`"${this.path}"`, ['--version']); + this.cachedVersion = new SemVer(parseVersion(output.stdout)); + return { + logs: [], + stdout: output.stdout.split(os.EOL), + stderr: output.stderr.split(os.EOL), + result: '' + }; } public async executeCommand(args: string[], additionalEnvVars?: { [key: string]: string }): Promise> { @@ -117,22 +138,24 @@ class AzdataTool implements IAzdataTool { } } +export type AzdataDarwinPackageVersionInfo = { + versions: { + stable: string, + devel: string, + head: string, + bottle: boolean + } +}; /** * Finds the existing installation of azdata, or throws an error if it couldn't find it * or encountered an unexpected error. + * The promise is rejected when Azdata is not found. */ export async function findAzdata(): Promise { Logger.log(loc.searchingForAzdata); try { - let azdata: IAzdataTool | undefined = undefined; - switch (process.platform) { - case 'win32': - azdata = await findAzdataWin32(); - break; - default: - azdata = await findSpecificAzdata('azdata'); - } - Logger.log(loc.foundExistingAzdata(azdata.path, azdata.toolVersion)); + const azdata = await findSpecificAzdata(); + Logger.log(loc.foundExistingAzdata(azdata.path, azdata.cachedVersion.raw)); return azdata; } catch (err) { Logger.log(loc.couldNotFindAzdata(err)); @@ -141,9 +164,9 @@ export async function findAzdata(): Promise { } /** - * Downloads the appropriate installer and/or runs the command to install azdata + * runs the commands to install azdata, downloading the installation package if needed */ -export async function downloadAndInstallAzdata(): Promise { +export async function installAzdata(): Promise { const statusDisposable = vscode.window.setStatusBarMessage(loc.installingAzdata); Logger.show(); Logger.log(loc.installingAzdata); @@ -161,17 +184,64 @@ export async function downloadAndInstallAzdata(): Promise { default: throw new Error(loc.platformUnsupported(process.platform)); } + Logger.log(loc.azdataInstalled); } finally { statusDisposable.dispose(); } } +/** + * Upgrades the azdata using os appropriate method + */ +export async function upgradeAzdata(): Promise { + const statusDisposable = vscode.window.setStatusBarMessage(loc.upgradingAzdata); + Logger.show(); + Logger.log(loc.upgradingAzdata); + try { + switch (process.platform) { + case 'win32': + await downloadAndInstallAzdataWin32(); + break; + case 'darwin': + await upgradeAzdataDarwin(); + break; + case 'linux': + await installAzdataLinux(); + break; + default: + throw new Error(loc.platformUnsupported(process.platform)); + } + Logger.log(loc.azdataUpgraded); + } finally { + statusDisposable.dispose(); + } +} + +/** + * Checks whether a newer version of azdata is available - and if it is prompts the user to download and + * install it. + * @param currentAzdata The current version of azdata to check against + */ +export async function checkAndUpgradeAzdata(currentAzdata?: IAzdataTool): Promise { + if (currentAzdata === undefined) { + currentAzdata = await findAzdata(); + } + const newVersion = await discoverLatestAvailableAzdataVersion(); + if (newVersion.compare(currentAzdata.cachedVersion) === 1) { + const response = await vscode.window.showInformationMessage(loc.promptForAzdataUpgrade(newVersion.raw), loc.yes, loc.no); + if (response === loc.yes) { + await upgradeAzdata(); + } + } +} + + /** * Downloads the Windows installer and runs it */ async function downloadAndInstallAzdataWin32(): Promise { const downloadFolder = os.tmpdir(); - const downloadedFile = await HttpClient.download(`${azdataHostname}/${azdataUri}`, downloadFolder); + const downloadedFile = await HttpClient.downloadFile(`${azdataHostname}/${azdataUri}`, downloadFolder); await executeCommand('msiexec', ['/qn', '/i', downloadedFile]); } @@ -184,6 +254,15 @@ async function installAzdataDarwin(): Promise { await executeCommand('brew', ['install', 'azdata-cli']); } +/** + * Runs commands to upgrade azdata on MacOS + */ +async function upgradeAzdataDarwin(): Promise { + await executeCommand('brew', ['tap', 'microsoft/azdata-cli-release']); + await executeCommand('brew', ['update']); + await executeCommand('brew', ['upgrade', 'azdata-cli']); +} + /** * Runs commands to install azdata on Linux */ @@ -203,20 +282,46 @@ async function installAzdataLinux(): Promise { } /** - * Finds azdata specifically on Windows */ -async function findAzdataWin32(): Promise { - const promise = searchForCmd('azdata.cmd'); - return findSpecificAzdata(await promise); +async function findSpecificAzdata(): Promise { + const promise = ((process.platform === 'win32') ? searchForCmd('azdata.cmd') : searchForCmd('azdata')); + const path = `"${await promise}"`; // throws if azdata is not found + const versionOutput = await executeCommand(`"${path}"`, ['--version']); + return new AzdataTool(path, parseVersion(versionOutput.stdout)); } /** - * Gets the version using a known azdata path - * @param path The path to the azdata executable + * Gets the latest azdata version available for a given platform */ -async function findSpecificAzdata(path: string): Promise { - const versionOutput = await executeCommand(`"${path}"`, ['--version']); - return new AzdataTool(path, parseVersion(versionOutput.stdout)); +export async function discoverLatestAvailableAzdataVersion(): Promise { + Logger.log(loc.checkingLatestAzdataVersion); + switch (process.platform) { + case 'darwin': + return await discoverLatestStableAzdataVersionDarwin(); + // case 'linux': + // ideally we would not to discover linux package availability using the apt/apt-get/apt-cache package manager commands. + // However, doing discovery that way required apt update to be performed which requires sudo privileges. At least currently this code path + // gets invoked on extension start up and prompt user for sudo privileges is annoying at best. So for now basing linux discovery also on a releaseJson file. + default: + return await discoverLatestAzdataVersionFromJson(); + } +} + +/** + * Gets the latest azdata version from a json document published by azdata release + */ +async function discoverLatestAzdataVersionFromJson(): Promise { + // get version information for current platform from http://aka.ms/azdata/release.json + const fileContents = await HttpClient.getTextContent(`${azdataHostname}/${azdataReleaseJson}`); + let azdataReleaseInfo; + try { + azdataReleaseInfo = JSON.parse(fileContents); + } catch (e) { + throw Error(`failed to parse the JSON of contents at: ${azdataHostname}/${azdataReleaseJson}, text being parsed: '${fileContents}', error:${getErrorMessage(e)}`); + } + const version = azdataReleaseInfo[process.platform]['version']; + Logger.log(loc.foundAzdataVersionToUpgradeTo(version)); + return new SemVer(version); } /** @@ -229,3 +334,38 @@ function parseVersion(raw: string): string { const lines = raw.split(os.EOL); return lines[0].trim(); } +/** + * Gets the latest azdata version for MacOs clients + */ +async function discoverLatestStableAzdataVersionDarwin(): Promise { + // set brew tap to azdata-cli repository + await executeCommand('brew', ['tap', 'microsoft/azdata-cli-release']); + await executeCommand('brew', ['update']); + let brewInfoAzdataCliJson; + // Get the package version 'info' about 'azdata-cli' from 'brew' as a json object + const brewInfoOutput = (await executeCommand('brew', ['info', 'azdata-cli', '--json'])).stdout; + try { + brewInfoAzdataCliJson = JSON.parse(brewInfoOutput); + } catch (e) { + throw Error(`failed to parse the JSON contents output of: 'brew info azdata-cli --json', text being parsed: '${brewInfoOutput}', error:${getErrorMessage(e)}`); + } + const azdataPackageVersionInfo: AzdataDarwinPackageVersionInfo = brewInfoAzdataCliJson.shift(); + Logger.log(loc.foundAzdataVersionToUpgradeTo(azdataPackageVersionInfo.versions.stable)); + return new SemVer(azdataPackageVersionInfo.versions.stable); +} + +/** + * Gets the latest azdata version for linux clients + * This method requires sudo permission so not suitable to be run during startup. + */ +// async function discoverLatestStableAzdataVersionLinux(): Promise { +// // Update repository information and install azdata +// await executeSudoCommand('apt-get update'); +// const output = (await executeCommand('apt', ['list', 'azdata-cli', '--upgradeable'])).stdout; +// // the packageName (with version) string is the second space delimited token on the 2nd line +// const packageName = output.split('\n')[1].split(' ')[1]; +// // the version string is the first part of the package sting before '~' +// const version = packageName.split('~')[0]; +// Logger.log(loc.foundAzdataVersionToUpgradeTo(version)); +// return new SemVer(version); +// } diff --git a/extensions/azdata/src/common/httpClient.ts b/extensions/azdata/src/common/httpClient.ts index b51f424270..fed054031e 100644 --- a/extensions/azdata/src/common/httpClient.ts +++ b/extensions/azdata/src/common/httpClient.ts @@ -17,13 +17,34 @@ export namespace HttpClient { * Downloads a file from the given URL, resolving to the full path of the downloaded file when complete * @param downloadUrl The URL to download the file from * @param targetFolder The folder to download the file to - * @returns Full path to the downloaded file + * @returns a promise to a full path to the downloaded file */ - export function download(downloadUrl: string, targetFolder: string): Promise { + export function downloadFile(downloadUrl: string, targetFolder: string): Promise { + return download(downloadUrl, targetFolder); + } + + /** + * Downloads the text contents of the document at the given URL, resolving to a string containing the text when complete + * @param url The URL of the document whose contents need to be fetched + * @returns a promise to a string that has the contents of document at the provided url + */ + export async function getTextContent(url: string): Promise { + Logger.log(loc.gettingTextContentsOfUrl(url)); + return await download(url); + } + + /** + * Gets a file/fileContents at the given URL. + * @param downloadUrl The URL to download the file from + * @param targetFolder The folder to download the file to. If not defined then return value is the contents of the downloaded file. + * @returns Full path to the downloaded file or the contents of the file at the given downloadUrl + */ + function download(downloadUrl: string, targetFolder?: string): Promise { return new Promise((resolve, reject) => { let totalMegaBytes: number | undefined = undefined; let receivedBytes = 0; let printThreshold = 0.1; + let strings: string[] = []; let downloadRequest = request.get(downloadUrl, { timeout: DownloadTimeout }) .on('error', downloadError => { Logger.log(loc.downloadError); @@ -34,28 +55,34 @@ export namespace HttpClient { if (response.statusCode !== 200) { Logger.log(loc.downloadError); Logger.log(response.statusMessage); + Logger.log(`response code: ${response.statusCode}`); return reject(response.statusMessage); } - const filename = path.basename(response.request.path); - const targetPath = path.join(targetFolder, filename); - Logger.log(loc.downloadingTo(filename, targetPath)); - // Wait to create the WriteStream until here so we can use the actual - // filename based off of the URI. - downloadRequest.pipe(fs.createWriteStream(targetPath)) - .on('close', async () => { - Logger.log(loc.downloadFinished); - resolve(targetPath); - }) - .on('error', (downloadError) => { - reject(downloadError); - downloadRequest.abort(); - }); + if (targetFolder !== undefined) { + const filename = path.basename(response.request.path); + const targetPath = path.join(targetFolder, filename); + Logger.log(loc.downloadingTo(filename, targetPath)); + // Wait to create the WriteStream until here so we can use the actual + // filename based off of the URI. + downloadRequest.pipe(fs.createWriteStream(targetPath)) + .on('close', async () => { + Logger.log(loc.downloadFinished); + resolve(targetPath); + }) + .on('error', (downloadError) => { + reject(downloadError); + downloadRequest.abort(); + }); + } let contentLength = response.headers['content-length']; let totalBytes = parseInt(contentLength || '0'); totalMegaBytes = totalBytes / (1024 * 1024); Logger.log(loc.downloadingProgressMb('0', totalMegaBytes.toFixed(2))); }) .on('data', (data) => { + if (targetFolder === undefined) { + strings.push(data.toString('utf-8')); + } receivedBytes += data.length; if (totalMegaBytes) { let receivedMegaBytes = receivedBytes / (1024 * 1024); @@ -65,30 +92,13 @@ export namespace HttpClient { printThreshold += 0.1; } } - }); - }); - } - - /** - * Gets the filename for the specified URL - following redirects as needed - * @param url The URL to get the filename of - */ - export async function getFilename(url: string): Promise { - Logger.log(loc.gettingFilenameOfUrl(url)); - return new Promise((resolve, reject) => { - let httpRequest = request.get(url, { timeout: DownloadTimeout }) - .on('error', downloadError => { - reject(downloadError); }) - .on('response', (response) => { - if (response.statusCode !== 200) { - return reject(response.statusMessage); + .on('close', async () => { + if (targetFolder === undefined) { + + Logger.log(loc.downloadFinished); + resolve(strings.join('')); } - // We don't want to actually download the file so abort the request now - httpRequest.abort(); - const filename = path.basename(response.request.path); - Logger.log(loc.gotFilenameOfUrl(response.request.path, filename)); - resolve(filename); }); }); } diff --git a/extensions/azdata/src/common/utils.ts b/extensions/azdata/src/common/utils.ts index 5f3fbacda6..3f305a6ef8 100644 --- a/extensions/azdata/src/common/utils.ts +++ b/extensions/azdata/src/common/utils.ts @@ -13,3 +13,11 @@ export function searchForCmd(exe: string): Promise { // Note : This is separated out to allow for easy test stubbing return new Promise((resolve, reject) => which(exe, (err, path) => err ? reject(err) : resolve(path))); } + +/** + * Gets the message to display for a given error object that may be a variety of types. + * @param error The error object + */ +export function getErrorMessage(error: any): string { + return error.message ?? error; +} diff --git a/extensions/azdata/src/extension.ts b/extensions/azdata/src/extension.ts index a3df57edb7..55f00a3dbd 100644 --- a/extensions/azdata/src/extension.ts +++ b/extensions/azdata/src/extension.ts @@ -4,13 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as azdataExt from 'azdata-ext'; -import { findAzdata, IAzdataTool } from './azdata'; +import * as vscode from 'vscode'; +import { checkAndUpgradeAzdata, findAzdata, IAzdataTool } from './azdata'; import * as loc from './localizedConstants'; let localAzdata: IAzdataTool | undefined = undefined; export async function activate(): Promise { localAzdata = await checkForAzdata(); + // Don't block on this since we want the extension to finish activating without needing user input + checkAndUpgradeAzdata(localAzdata) + .then(async () => { + localAzdata = await findAzdata(); // now again find and return the currently installed azdata + }) + .catch(err => vscode.window.showWarningMessage(loc.updateError(err))); //update if available and user wants it. return { azdata: { arc: { @@ -85,11 +92,11 @@ function throwIfNoAzdata(): void { async function checkForAzdata(): Promise { try { - return await findAzdata(); + return await findAzdata(); // find currently installed Azdata } catch (err) { // Don't block on this since we want the extension to finish activating without needing user input. // Calls will be made to handle azdata not being installed - promptToInstallAzdata().catch(e => console.log(`Unexpected error prompting to install azdata ${e}`)); + await promptToInstallAzdata().catch(e => console.log(`Unexpected error prompting to install azdata ${e}`)); } return undefined; } diff --git a/extensions/azdata/src/localizedConstants.ts b/extensions/azdata/src/localizedConstants.ts index 245c474c65..5a84838742 100644 --- a/extensions/azdata/src/localizedConstants.ts +++ b/extensions/azdata/src/localizedConstants.ts @@ -7,25 +7,30 @@ import * as nls from 'vscode-nls'; const localize = nls.loadMessageBundle(); export const searchingForAzdata = localize('azdata.searchingForAzdata', "Searching for existing azdata installation..."); -export function foundExistingAzdata(path: string, version: string): string { return localize('azdata.foundExistingAzdata', "Found existing azdata installation at {0} (v{1})", path, version); } -export function downloadingProgressMb(currentMb: string, totalMb: string): string { return localize('azdata.downloadingProgressMb', "Downloading ({0} / {1} MB)", currentMb, totalMb); } +export const foundExistingAzdata = (path: string, version: string): string => localize('azdata.foundExistingAzdata', "Found existing azdata installation of version (v{0}) at path:{1}", version, path); +export const downloadingProgressMb = (currentMb: string, totalMb: string): string => localize('azdata.downloadingProgressMb', "Downloading ({0} / {1} MB)", currentMb, totalMb); export const downloadFinished = localize('azdata.downloadFinished', "Download finished"); -export const install = localize('azdata.install', "Install"); export const installingAzdata = localize('azdata.installingAzdata', "Installing azdata..."); +export const upgradingAzdata = localize('azdata.upgradingAzdata', "Upgrading azdata..."); export const azdataInstalled = localize('azdata.azdataInstalled', "azdata was successfully installed. Restarting Azure Data Studio is required to complete configuration - features will not be activated until this is done."); +export const azdataUpgraded = localize('azdata.azdataUpgraded', "azdata was successfully upgraded."); export const cancel = localize('azdata.cancel', "Cancel"); -export function downloadingTo(name: string, location: string): string { return localize('azdata.downloadingTo', "Downloading {0} to {1}", name, location); } -export function executingCommand(command: string, args: string[]): string { return localize('azdata.executingCommand', "Executing command \"{0} {1}\"", command, args?.join(' ')); } -export function stdoutOutput(stdout: string): string { return localize('azdat.stdoutOutput', "stdout : {0}", stdout); } -export function stderrOutput(stderr: string): string { return localize('azdat.stderrOutput', "stderr : {0}", stderr); } -export function gettingFilenameOfUrl(url: string): string { return localize('azdata.gettingFilenameOfUrl', "Getting filename of resource at URL {0}", url); } -export function gotFilenameOfUrl(url: string, filename: string): string { return localize('azdata.gotFilenameOfUrl', "Got filename {0} from URL {1}", filename, url); } - -export function couldNotFindAzdata(err: any): string { return localize('azdata.couldNotFindAzdata', "Could not find azdata. Error : {0}", err.message ?? err); } +export const yes = localize('azdata.yes', "Yes"); +export const no = localize('azdata.no', "No"); +export const downloadingTo = (name: string, location: string): string => localize('azdata.downloadingTo', "Downloading {0} to {1}", name, location); +export const executingCommand = (command: string, args: string[]): string => localize('azdata.executingCommand', "Executing command \"{0} {1}\"", command, args?.join(' ')); +export const stdoutOutput = (stdout: string): string => localize('azdata.stdoutOutput', "stdout : {0}", stdout); +export const stderrOutput = (stderr: string): string => localize('azdata.stderrOutput', "stderr : {0}", stderr); +export const checkingLatestAzdataVersion = localize('azdata.checkingLatestAzdataVersion', "Checking for latest version of azdata"); +export const gettingTextContentsOfUrl = (url: string): string => localize('azdata.gettingTextContentsOfUrl', "Getting text contents of resource at URL {0}", url); +export const foundAzdataVersionToUpgradeTo = (version: string): string => localize('azdata.versionForUpgrade', "Found version {0} that azdata-cli can be upgraded to.", version); +export const promptForAzdataUpgrade = (version: string): string => localize('azdata.promptForAzdataUpgrade', "An updated version of azdata ( {0} ) is available, do you wish to install it now?", version); +export const couldNotFindAzdata = (err: any): string => localize('azdata.couldNotFindAzdata', "Could not find azdata. Error : {0}", err.message ?? err); export const couldNotFindAzdataWithPrompt = localize('azdata.couldNotFindAzdataWithPrompt', "Could not find azdata, install it now? If not then some features will not be able to function."); export const downloadError = localize('azdata.downloadError', "Error while downloading"); -export function installError(err: any): string { return localize('azdata.installError', "Error installing azdata : {0}", err.message ?? err); } -export function platformUnsupported(platform: string): string { return localize('azdata.platformUnsupported', "Platform '{0}' is currently unsupported", platform); } -export function unexpectedCommandError(errMsg: string): string { return localize('azdata.unexpectedCommandError', "Unexpected error executing command : {0}", errMsg); } -export function unexpectedExitCode(code: number, err: string): string { return localize('azdata.unexpectedExitCode', "Unexpected exit code from command : {1} ({0})", code, err); } +export const installError = (err: any): string => localize('azdata.installError', "Error installing azdata : {0}", err.message ?? err); +export const platformUnsupported = (platform: string): string => localize('azdata.platformUnsupported', "Platform '{0}' is currently unsupported", platform); +export const unexpectedCommandError = (errMsg: string): string => localize('azdata.unexpectedCommandError', "Unexpected error executing command : {0}", errMsg); +export const updateError = (err: any): string => localize('azdata.updateError', "Error updating azdata : {0}", err.message ?? err); +export const unexpectedExitCode = (code: number, err: string): string => localize('azdata.unexpectedExitCode', "Unexpected exit code from command : {1} ({0})", code, err); export const noAzdata = localize('azdata.NoAzdata', "No azdata available"); diff --git a/extensions/azdata/src/test/azdata.test.ts b/extensions/azdata/src/test/azdata.test.ts index 013b977955..60e833b07c 100644 --- a/extensions/azdata/src/test/azdata.test.ts +++ b/extensions/azdata/src/test/azdata.test.ts @@ -3,29 +3,30 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as azdata from '../azdata'; -import * as sinon from 'sinon'; -import * as childProcess from '../common/childProcess'; +import * as path from 'path'; +import { SemVer } from 'semver'; import * as should from 'should'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as azdata from '../azdata'; +import * as childProcess from '../common/childProcess'; +import { HttpClient } from '../common/httpClient'; import * as utils from '../common/utils'; -import * as nock from 'nock'; +import * as loc from '../localizedConstants'; + +const oldAzdataMock = {path:'/path/to/azdata', cachedVersion: new SemVer('0.0.0')}; describe('azdata', function () { - afterEach(function (): void { sinon.restore(); - nock.cleanAll(); - nock.enableNetConnect(); }); describe('findAzdata', function () { it('successful', async function (): Promise { - if (process.platform === 'win32') { - // Mock searchForCmd to return a path to azdata.cmd - sinon.stub(utils, 'searchForCmd').returns(Promise.resolve('C:\\path\\to\\azdata.cmd')); - } + // Mock searchForCmd to return a path to azdata.cmd + sinon.stub(utils, 'searchForCmd').returns(Promise.resolve('/path/to/azdata')); // Mock call to --version to simulate azdata being installed - sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: 'v1.0.0', stderr: '' })); + sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: '1.0.0', stderr: '' })); await should(azdata.findAzdata()).not.be.rejected(); }); it('unsuccessful', async function (): Promise { @@ -40,26 +41,221 @@ describe('azdata', function () { }); }); - // TODO: Install not implemented on linux yet - describe('downloadAndInstallAzdata', function (): void { - it('successful download & install', async function (): Promise { - sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); - if (process.platform === 'linux') { - sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); + describe('installAzdata', function (): void { + it('successful install', async function (): Promise { + switch (process.platform) { + case 'win32': + await testWin32SuccessfulInstall(); + break; + case 'darwin': + await testDarwinSuccessfulInstall(); + break; + case 'linux': + await testLinuxSuccessfulInstall(); + break; } - nock(azdata.azdataHostname) - .get(`/${azdata.azdataUri}`) - .replyWithFile(200, __filename); - const downloadPromise = azdata.downloadAndInstallAzdata(); - await downloadPromise; }); - it('errors on unsuccessful download', async function (): Promise { - nock('https://aka.ms') - .get('/azdata-msi') - .reply(404); - const downloadPromise = azdata.downloadAndInstallAzdata(); - await should(downloadPromise).be.rejected(); + if (process.platform === 'win32') { + it('unsuccessful download - win32', async function (): Promise { + sinon.stub(HttpClient, 'downloadFile').rejects(); + const downloadPromise = azdata.installAzdata(); + await should(downloadPromise).be.rejected(); + }); + } + + it('unsuccessful install', async function (): Promise { + switch (process.platform) { + case 'win32': + await testWin32UnsuccessfulInstall(); + break; + case 'darwin': + await testDarwinUnsuccessfulInstall(); + break; + case 'linux': + await testLinuxUnsuccessfulInstall(); + break; + } + }); + }); + + describe('upgradeAzdata', function (): void { + beforeEach(function (): void { + sinon.stub(vscode.window, 'showInformationMessage').returns(Promise.resolve(loc.yes)); + }); + + it('successful upgrade', async function (): Promise { + const releaseJson = { + win32: { + 'version': '9999.999.999', + 'link': 'https://download.com/azdata-20.0.1.msi' + }, + darwin: { + 'version': '9999.999.999' + }, + linux: { + 'version': '9999.999.999' + } + }; + switch (process.platform) { + case 'win32': + await testWin32SuccessfulUpgrade(releaseJson); + break; + + case 'darwin': + await testDarwinSuccessfulUpgrade(); + break; + case 'linux': + await testLinuxSuccessfulUpgrade(releaseJson); + break; + } + }); + + + it('unsuccessful upgrade', async function (): Promise { + switch (process.platform) { + case 'win32': + await testWin32UnsuccessfulUpgrade(); + break; + case 'darwin': + await testDarwinUnsuccessfulUpgrade(); + break; + + case 'linux': + await testLinuxUnsuccessfulUpgrade(); + } + }); + + describe('discoverLatestAvailableAzdataVersion', function (): void { + this.timeout(20000); + it(`finds latest available version of azdata successfully`, async function (): Promise { + // if the latest version is not discovered then the following call throws failing the test + await azdata.discoverLatestAvailableAzdataVersion(); + }); }); }); }); + +async function testLinuxUnsuccessfulUpgrade() { + const executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').rejects(); + const upgradePromise = azdata.checkAndUpgradeAzdata(oldAzdataMock); + await should(upgradePromise).be.rejected(); + should(executeSudoCommandStub.calledOnce).be.true(); +} + +async function testDarwinUnsuccessfulUpgrade() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').rejects(); + const upgradePromise = azdata.checkAndUpgradeAzdata(oldAzdataMock); + await should(upgradePromise).be.rejected(); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testWin32UnsuccessfulUpgrade() { + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + sinon.stub(childProcess, 'executeCommand').rejects(); + const upgradePromise = azdata.checkAndUpgradeAzdata(oldAzdataMock); + await should(upgradePromise).be.rejected(); +} + +async function testLinuxSuccessfulUpgrade(releaseJson: { win32: { version: string; }; darwin: { version: string; }; linux: { version: string; }; }) { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: 'success', stderr: '' })); + const executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: 'success', stderr: '' })); + await azdata.checkAndUpgradeAzdata(oldAzdataMock); + should(executeSudoCommandStub.callCount).be.equal(6); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testDarwinSuccessfulUpgrade() { + const brewInfoOutput = [{ + name: 'azdata-cli', + full_name: 'microsoft/azdata-cli-release/azdata-cli', + versions: { + 'stable': '9999.999.999', + 'devel': null, + 'head': null, + 'bottle': true + } + }]; + const executeCommandStub = sinon.stub(childProcess, 'executeCommand') + .onThirdCall() //third call is brew info azdata-cli --json which needs to return json of new available azdata versions. + .callsFake(async (command: string, args: string[]) => { + should(command).be.equal('brew'); + should(args).deepEqual(['info', 'azdata-cli', '--json']); + return Promise.resolve({ + stderr: '', + stdout: JSON.stringify(brewInfoOutput) + }); + }) + .callsFake(async (_command: string, _args: string[]) => { // return success on all other command executions + return Promise.resolve({ stdout: 'success', stderr: '' }); + }); + await azdata.checkAndUpgradeAzdata(oldAzdataMock); + should(executeCommandStub.callCount).be.equal(6); +} + +async function testWin32SuccessfulUpgrade(releaseJson: { win32: { version: string; link: string; }; darwin: { version: string; }; linux: { version: string; }; }) { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').callsFake(async (command: string, args: string[]) => { + should(command).be.equal('msiexec'); + should(args[0]).be.equal('/qn'); + should(args[1]).be.equal('/i'); + should(path.basename(args[2])).be.equal(azdata.azdataUri); + return { stdout: 'success', stderr: '' }; + }); + await azdata.checkAndUpgradeAzdata(oldAzdataMock); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testWin32SuccessfulInstall() { + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').callsFake(async (command: string, args: string[]) => { + should(command).be.equal('msiexec'); + should(args[0]).be.equal('/qn'); + should(args[1]).be.equal('/i'); + should(path.basename(args[2])).be.equal(azdata.azdataUri); + return { stdout: 'success', stderr: '' }; + }); + await azdata.installAzdata(); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testDarwinSuccessfulInstall() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').callsFake(async (command: string, _args: string[]) => { + should(command).be.equal('brew'); + return { stdout: 'success', stderr: '' }; + }); + await azdata.installAzdata(); + should(executeCommandStub.calledThrice).be.true(); +} + +async function testLinuxSuccessfulInstall() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: 'success', stderr: '' })); + const executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: 'success', stderr: '' })); + await azdata.installAzdata(); + should(executeSudoCommandStub.callCount).be.equal(6); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testLinuxUnsuccessfulInstall() { + const executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').rejects(); + const downloadPromise = azdata.installAzdata(); + await should(downloadPromise).be.rejected(); + should(executeSudoCommandStub.calledOnce).be.true(); +} + +async function testDarwinUnsuccessfulInstall() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').rejects(); + const downloadPromise = azdata.installAzdata(); + await should(downloadPromise).be.rejected(); + should(executeCommandStub.calledOnce).be.true(); +} + +async function testWin32UnsuccessfulInstall() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand').rejects(); + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + const downloadPromise = azdata.installAzdata(); + await should(downloadPromise).be.rejected(); + should(executeCommandStub.calledOnce).be.true(); +} diff --git a/extensions/azdata/src/test/common/httpClient.test.ts b/extensions/azdata/src/test/common/httpClient.test.ts index 648b7d9db1..3d79597201 100644 --- a/extensions/azdata/src/test/common/httpClient.test.ts +++ b/extensions/azdata/src/test/common/httpClient.test.ts @@ -3,13 +3,13 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as should from 'should'; -import { HttpClient } from '../../common/httpClient'; -import * as os from 'os'; import * as fs from 'fs'; import * as nock from 'nock'; +import * as os from 'os'; +import * as should from 'should'; import * as sinon from 'sinon'; import { PassThrough } from 'stream'; +import { HttpClient } from '../../common/httpClient'; import { Deferred } from '../../common/promise'; describe('HttpClient', function (): void { @@ -17,15 +17,16 @@ describe('HttpClient', function (): void { afterEach(function (): void { nock.cleanAll(); nock.enableNetConnect(); + sinon.restore(); }); - describe('download', function(): void { + describe('downloadFile', function (): void { it('downloads file successfully', async function (): Promise { nock('https://127.0.0.1') .get('/README.md') .replyWithFile(200, __filename); const downloadFolder = os.tmpdir(); - const downloadPath = await HttpClient.download('https://127.0.0.1/README.md', downloadFolder); + const downloadPath = await HttpClient.downloadFile('https://127.0.0.1/README.md', downloadFolder); // Verify file was downloaded correctly await fs.promises.stat(downloadPath); }); @@ -35,8 +36,7 @@ describe('HttpClient', function (): void { nock('https://127.0.0.1') .get('/') .replyWithError('Unexpected Error'); - const downloadPromise = HttpClient.download('https://127.0.0.1', downloadFolder); - + const downloadPromise = HttpClient.downloadFile('https://127.0.0.1', downloadFolder); await should(downloadPromise).be.rejected(); }); @@ -45,8 +45,7 @@ describe('HttpClient', function (): void { nock('https://127.0.0.1') .get('/') .reply(404, ''); - const downloadPromise = HttpClient.download('https://127.0.0.1', downloadFolder); - + const downloadPromise = HttpClient.downloadFile('https://127.0.0.1', downloadFolder); await should(downloadPromise).be.rejected(); }); @@ -61,7 +60,7 @@ describe('HttpClient', function (): void { nock('https://127.0.0.1') .get('/') .reply(200, ''); - const downloadPromise = HttpClient.download('https://127.0.0.1', downloadFolder); + const downloadPromise = HttpClient.downloadFile('https://127.0.0.1', downloadFolder); // Wait for the stream to be created before throwing the error or HttpClient will miss the event await deferredPromise; try { @@ -73,34 +72,29 @@ describe('HttpClient', function (): void { }); }); - describe('getFilename', function(): void { - it('Gets filename correctly', async function (): Promise { - const filename = 'azdata-cli-20.0.0.msi'; + describe('getTextContent', function (): void { + it.skip('Gets file contents correctly', async function (): Promise { nock('https://127.0.0.1') - .get(`/${filename}`) - .reply(200); - const receivedFilename = await HttpClient.getFilename(`https://127.0.0.1/${filename}`); - - should(receivedFilename).equal(filename); + .get('/arbitraryFile') + .replyWithFile(200, __filename); + const receivedContents = await HttpClient.getTextContent(`https://127.0.0.1/arbitraryFile`); + should(receivedContents).equal(await fs.promises.readFile(__filename)); }); - it('errors on response error', async function (): Promise { + it('rejects on response error', async function (): Promise { nock('https://127.0.0.1') .get('/') .replyWithError('Unexpected Error'); - const getFilenamePromise = HttpClient.getFilename('https://127.0.0.1'); - - await should(getFilenamePromise).be.rejected(); + const getFileContentsPromise = HttpClient.getTextContent('https://127.0.0.1/', ); + await should(getFileContentsPromise).be.rejected(); }); it('rejects on non-OK status code', async function (): Promise { nock('https://127.0.0.1') .get('/') .reply(404, ''); - const getFilenamePromise = HttpClient.getFilename('https://127.0.0.1'); - - await should(getFilenamePromise).be.rejected(); + const getFileContentsPromise = HttpClient.getTextContent('https://127.0.0.1/', ); + await should(getFileContentsPromise).be.rejected(); }); }); - }); diff --git a/extensions/azdata/src/test/common/utils.test.ts b/extensions/azdata/src/test/common/utils.test.ts index 26f48cf350..a5078733d5 100644 --- a/extensions/azdata/src/test/common/utils.test.ts +++ b/extensions/azdata/src/test/common/utils.test.ts @@ -2,16 +2,15 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import * as should from 'should'; import { searchForCmd as searchForExe } from '../../common/utils'; describe('utils', function () { - describe('searchForExe', function(): void { - it('finds exe successfully', async function(): Promise { + describe('searchForExe', function (): void { + it('finds exe successfully', async function (): Promise { await searchForExe('node'); }); - it('throws for non-existent exe', async function(): Promise { + it('throws for non-existent exe', async function (): Promise { await should(searchForExe('someFakeExe')).be.rejected(); }); }); diff --git a/extensions/azdata/yarn.lock b/extensions/azdata/yarn.lock index e9103e22c0..8bca74e30b 100644 --- a/extensions/azdata/yarn.lock +++ b/extensions/azdata/yarn.lock @@ -247,6 +247,13 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/semver@^7.3.1": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.1.tgz#7a9a5d595b6d873f338c867dcef64df289468cfa" + integrity sha512-ooD/FJ8EuwlDKOI6D9HWxgIgJjMg2cuziXm/42npDC8y4NjxplBUn9loewZiBNCt44450lHAU0OSb51/UqXeag== + dependencies: + "@types/node" "*" + "@types/sinon@^9.0.4": version "9.0.4" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.4.tgz#e934f904606632287a6e7f7ab0ce3f08a0dad4b1" @@ -1071,6 +1078,11 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + should-equal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3"