Changes to discover and perform azdata update (#11906)

* WIP

* first version with working tests

* fixes needed after merge from main

* Linux untest changes and merge from other changes from mac

* after testing getTextContent

* rename 2 methods

* linux discovery

* tested code on linux

* using release.json for update discovery on linux

* comment added

* dead code removed

* coomments

* revert unrelated change

* revert testing changes

* PR feedback

* remove SendOutputChannelToConsole

* cleanup

* pr feedback

* PR Feedback

* pr feedback

* pr feedback

* merge from main

* merge from main

* cleanup and pr feedback

* pr feedback

* pr feedback.

* pr feedback

Co-authored-by: chgagnon <chgagnon@microsoft.com>
This commit is contained in:
Arvind Ranasaria
2020-08-27 13:25:54 -07:00
committed by GitHub
parent b715e6ed82
commit 00c7600b05
10 changed files with 521 additions and 148 deletions

View File

@@ -21,6 +21,7 @@
"main": "./out/extension", "main": "./out/extension",
"dependencies": { "dependencies": {
"request": "^2.88.2", "request": "^2.88.2",
"semver": "^7.3.2",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"vscode-nls": "^4.1.2", "vscode-nls": "^4.1.2",
"which": "^2.0.2" "which": "^2.0.2"
@@ -29,6 +30,7 @@
"@types/mocha": "^5.2.5", "@types/mocha": "^5.2.5",
"@types/node": "^12.11.7", "@types/node": "^12.11.7",
"@types/request": "^2.48.5", "@types/request": "^2.48.5",
"@types/semver": "^7.3.1",
"@types/sinon": "^9.0.4", "@types/sinon": "^9.0.4",
"@types/uuid": "^8.0.0", "@types/uuid": "^8.0.0",
"@types/which": "^1.3.2", "@types/which": "^1.3.2",

View File

@@ -3,21 +3,27 @@
* Licensed under the Source EULA. See License.txt in the project root for license information. * 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 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 Logger from './common/logger';
import { getErrorMessage, searchForCmd } from './common/utils';
import * as loc from './localizedConstants';
export const azdataHostname = 'https://aka.ms'; export const azdataHostname = 'https://aka.ms';
export const azdataUri = 'azdata-msi'; 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 { export interface IAzdataTool extends azdataExt.IAzdataApi {
path: string, path: string,
toolVersion: string, cachedVersion: SemVer
/** /**
* Executes azdata with the specified arguments (e.g. --version) and returns the result * Executes azdata with the specified arguments (e.g. --version) and returns the result
* @param args The args to pass to azdata * @param args The args to pass to azdata
@@ -26,8 +32,14 @@ export interface IAzdataTool extends azdataExt.IAzdataApi {
executeCommand<R>(args: string[], additionalEnvVars?: { [key: string]: string }): Promise<azdataExt.AzdataOutput<R>> executeCommand<R>(args: string[], additionalEnvVars?: { [key: string]: string }): Promise<azdataExt.AzdataOutput<R>>
} }
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 = { public arc = {
dc: { dc: {
@@ -90,10 +102,19 @@ class AzdataTool implements IAzdataTool {
return this.executeCommand<void>(['login', '-e', endpoint, '-u', username], { 'AZDATA_PASSWORD': password }); return this.executeCommand<void>(['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<azdataExt.AzdataOutput<string>> { public async version(): Promise<azdataExt.AzdataOutput<string>> {
const output = await this.executeCommand<string>(['--version']); const output = await executeCommand(`"${this.path}"`, ['--version']);
this.toolVersion = parseVersion(output.stdout[0]); this.cachedVersion = new SemVer(parseVersion(output.stdout));
return output; return {
logs: [],
stdout: output.stdout.split(os.EOL),
stderr: output.stderr.split(os.EOL),
result: ''
};
} }
public async executeCommand<R>(args: string[], additionalEnvVars?: { [key: string]: string }): Promise<azdataExt.AzdataOutput<R>> { public async executeCommand<R>(args: string[], additionalEnvVars?: { [key: string]: string }): Promise<azdataExt.AzdataOutput<R>> {
@@ -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 * Finds the existing installation of azdata, or throws an error if it couldn't find it
* or encountered an unexpected error. * or encountered an unexpected error.
* The promise is rejected when Azdata is not found.
*/ */
export async function findAzdata(): Promise<IAzdataTool> { export async function findAzdata(): Promise<IAzdataTool> {
Logger.log(loc.searchingForAzdata); Logger.log(loc.searchingForAzdata);
try { try {
let azdata: IAzdataTool | undefined = undefined; const azdata = await findSpecificAzdata();
switch (process.platform) { Logger.log(loc.foundExistingAzdata(azdata.path, azdata.cachedVersion.raw));
case 'win32':
azdata = await findAzdataWin32();
break;
default:
azdata = await findSpecificAzdata('azdata');
}
Logger.log(loc.foundExistingAzdata(azdata.path, azdata.toolVersion));
return azdata; return azdata;
} catch (err) { } catch (err) {
Logger.log(loc.couldNotFindAzdata(err)); Logger.log(loc.couldNotFindAzdata(err));
@@ -141,9 +164,9 @@ export async function findAzdata(): Promise<IAzdataTool> {
} }
/** /**
* 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<void> { export async function installAzdata(): Promise<void> {
const statusDisposable = vscode.window.setStatusBarMessage(loc.installingAzdata); const statusDisposable = vscode.window.setStatusBarMessage(loc.installingAzdata);
Logger.show(); Logger.show();
Logger.log(loc.installingAzdata); Logger.log(loc.installingAzdata);
@@ -161,17 +184,64 @@ export async function downloadAndInstallAzdata(): Promise<void> {
default: default:
throw new Error(loc.platformUnsupported(process.platform)); throw new Error(loc.platformUnsupported(process.platform));
} }
Logger.log(loc.azdataInstalled);
} finally { } finally {
statusDisposable.dispose(); statusDisposable.dispose();
} }
} }
/**
* Upgrades the azdata using os appropriate method
*/
export async function upgradeAzdata(): Promise<void> {
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<void> {
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 * Downloads the Windows installer and runs it
*/ */
async function downloadAndInstallAzdataWin32(): Promise<void> { async function downloadAndInstallAzdataWin32(): Promise<void> {
const downloadFolder = os.tmpdir(); 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]); await executeCommand('msiexec', ['/qn', '/i', downloadedFile]);
} }
@@ -184,6 +254,15 @@ async function installAzdataDarwin(): Promise<void> {
await executeCommand('brew', ['install', 'azdata-cli']); await executeCommand('brew', ['install', 'azdata-cli']);
} }
/**
* Runs commands to upgrade azdata on MacOS
*/
async function upgradeAzdataDarwin(): Promise<void> {
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 * Runs commands to install azdata on Linux
*/ */
@@ -203,20 +282,46 @@ async function installAzdataLinux(): Promise<void> {
} }
/** /**
* Finds azdata specifically on Windows
*/ */
async function findAzdataWin32(): Promise<IAzdataTool> { async function findSpecificAzdata(): Promise<IAzdataTool> {
const promise = searchForCmd('azdata.cmd'); const promise = ((process.platform === 'win32') ? searchForCmd('azdata.cmd') : searchForCmd('azdata'));
return findSpecificAzdata(await promise); 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 * Gets the latest azdata version available for a given platform
* @param path The path to the azdata executable
*/ */
async function findSpecificAzdata(path: string): Promise<IAzdataTool> { export async function discoverLatestAvailableAzdataVersion(): Promise<SemVer> {
const versionOutput = await executeCommand(`"${path}"`, ['--version']); Logger.log(loc.checkingLatestAzdataVersion);
return new AzdataTool(path, parseVersion(versionOutput.stdout)); 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<SemVer> {
// 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); const lines = raw.split(os.EOL);
return lines[0].trim(); return lines[0].trim();
} }
/**
* Gets the latest azdata version for MacOs clients
*/
async function discoverLatestStableAzdataVersionDarwin(): Promise<SemVer> {
// 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<SemVer> {
// // 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);
// }

View File

@@ -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 * 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 downloadUrl The URL to download the file from
* @param targetFolder The folder to download the file to * @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<string> { export function downloadFile(downloadUrl: string, targetFolder: string): Promise<string> {
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<string> {
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<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let totalMegaBytes: number | undefined = undefined; let totalMegaBytes: number | undefined = undefined;
let receivedBytes = 0; let receivedBytes = 0;
let printThreshold = 0.1; let printThreshold = 0.1;
let strings: string[] = [];
let downloadRequest = request.get(downloadUrl, { timeout: DownloadTimeout }) let downloadRequest = request.get(downloadUrl, { timeout: DownloadTimeout })
.on('error', downloadError => { .on('error', downloadError => {
Logger.log(loc.downloadError); Logger.log(loc.downloadError);
@@ -34,28 +55,34 @@ export namespace HttpClient {
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
Logger.log(loc.downloadError); Logger.log(loc.downloadError);
Logger.log(response.statusMessage); Logger.log(response.statusMessage);
Logger.log(`response code: ${response.statusCode}`);
return reject(response.statusMessage); return reject(response.statusMessage);
} }
const filename = path.basename(response.request.path); if (targetFolder !== undefined) {
const targetPath = path.join(targetFolder, filename); const filename = path.basename(response.request.path);
Logger.log(loc.downloadingTo(filename, targetPath)); const targetPath = path.join(targetFolder, filename);
// Wait to create the WriteStream until here so we can use the actual Logger.log(loc.downloadingTo(filename, targetPath));
// filename based off of the URI. // Wait to create the WriteStream until here so we can use the actual
downloadRequest.pipe(fs.createWriteStream(targetPath)) // filename based off of the URI.
.on('close', async () => { downloadRequest.pipe(fs.createWriteStream(targetPath))
Logger.log(loc.downloadFinished); .on('close', async () => {
resolve(targetPath); Logger.log(loc.downloadFinished);
}) resolve(targetPath);
.on('error', (downloadError) => { })
reject(downloadError); .on('error', (downloadError) => {
downloadRequest.abort(); reject(downloadError);
}); downloadRequest.abort();
});
}
let contentLength = response.headers['content-length']; let contentLength = response.headers['content-length'];
let totalBytes = parseInt(contentLength || '0'); let totalBytes = parseInt(contentLength || '0');
totalMegaBytes = totalBytes / (1024 * 1024); totalMegaBytes = totalBytes / (1024 * 1024);
Logger.log(loc.downloadingProgressMb('0', totalMegaBytes.toFixed(2))); Logger.log(loc.downloadingProgressMb('0', totalMegaBytes.toFixed(2)));
}) })
.on('data', (data) => { .on('data', (data) => {
if (targetFolder === undefined) {
strings.push(data.toString('utf-8'));
}
receivedBytes += data.length; receivedBytes += data.length;
if (totalMegaBytes) { if (totalMegaBytes) {
let receivedMegaBytes = receivedBytes / (1024 * 1024); let receivedMegaBytes = receivedBytes / (1024 * 1024);
@@ -65,30 +92,13 @@ export namespace HttpClient {
printThreshold += 0.1; 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<string> {
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) => { .on('close', async () => {
if (response.statusCode !== 200) { if (targetFolder === undefined) {
return reject(response.statusMessage);
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);
}); });
}); });
} }

View File

@@ -13,3 +13,11 @@ export function searchForCmd(exe: string): Promise<string> {
// Note : This is separated out to allow for easy test stubbing // Note : This is separated out to allow for easy test stubbing
return new Promise<string>((resolve, reject) => which(exe, (err, path) => err ? reject(err) : resolve(path))); return new Promise<string>((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;
}

View File

@@ -4,13 +4,20 @@
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as azdataExt from 'azdata-ext'; 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'; import * as loc from './localizedConstants';
let localAzdata: IAzdataTool | undefined = undefined; let localAzdata: IAzdataTool | undefined = undefined;
export async function activate(): Promise<azdataExt.IExtension> { export async function activate(): Promise<azdataExt.IExtension> {
localAzdata = await checkForAzdata(); 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 { return {
azdata: { azdata: {
arc: { arc: {
@@ -85,11 +92,11 @@ function throwIfNoAzdata(): void {
async function checkForAzdata(): Promise<IAzdataTool | undefined> { async function checkForAzdata(): Promise<IAzdataTool | undefined> {
try { try {
return await findAzdata(); return await findAzdata(); // find currently installed Azdata
} catch (err) { } catch (err) {
// Don't block on this since we want the extension to finish activating without needing user input. // 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 // 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; return undefined;
} }

View File

@@ -7,25 +7,30 @@ import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle(); const localize = nls.loadMessageBundle();
export const searchingForAzdata = localize('azdata.searchingForAzdata', "Searching for existing azdata installation..."); 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 const foundExistingAzdata = (path: string, version: string): string => localize('azdata.foundExistingAzdata', "Found existing azdata installation of version (v{0}) at path:{1}", version, path);
export function downloadingProgressMb(currentMb: string, totalMb: string): string { return localize('azdata.downloadingProgressMb', "Downloading ({0} / {1} MB)", currentMb, totalMb); } 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 downloadFinished = localize('azdata.downloadFinished', "Download finished");
export const install = localize('azdata.install', "Install");
export const installingAzdata = localize('azdata.installingAzdata', "Installing azdata..."); 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 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 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 const yes = localize('azdata.yes', "Yes");
export function executingCommand(command: string, args: string[]): string { return localize('azdata.executingCommand', "Executing command \"{0} {1}\"", command, args?.join(' ')); } export const no = localize('azdata.no', "No");
export function stdoutOutput(stdout: string): string { return localize('azdat.stdoutOutput', "stdout : {0}", stdout); } export const downloadingTo = (name: string, location: string): string => localize('azdata.downloadingTo', "Downloading {0} to {1}", name, location);
export function stderrOutput(stderr: string): string { return localize('azdat.stderrOutput', "stderr : {0}", stderr); } export const executingCommand = (command: string, args: string[]): string => localize('azdata.executingCommand', "Executing command \"{0} {1}\"", command, args?.join(' '));
export function gettingFilenameOfUrl(url: string): string { return localize('azdata.gettingFilenameOfUrl', "Getting filename of resource at URL {0}", url); } export const stdoutOutput = (stdout: string): string => localize('azdata.stdoutOutput', "stdout : {0}", stdout);
export function gotFilenameOfUrl(url: string, filename: string): string { return localize('azdata.gotFilenameOfUrl', "Got filename {0} from URL {1}", filename, url); } 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 function couldNotFindAzdata(err: any): string { return localize('azdata.couldNotFindAzdata', "Could not find azdata. Error : {0}", err.message ?? err); } 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 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 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 const installError = (err: any): string => 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 const platformUnsupported = (platform: string): string => 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 const unexpectedCommandError = (errMsg: string): string => 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 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"); export const noAzdata = localize('azdata.NoAzdata', "No azdata available");

View File

@@ -3,29 +3,30 @@
* Licensed under the Source EULA. See License.txt in the project root for license information. * Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as azdata from '../azdata'; import * as path from 'path';
import * as sinon from 'sinon'; import { SemVer } from 'semver';
import * as childProcess from '../common/childProcess';
import * as should from 'should'; 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 utils from '../common/utils';
import * as nock from 'nock'; import * as loc from '../localizedConstants';
const oldAzdataMock = <azdata.AzdataTool>{path:'/path/to/azdata', cachedVersion: new SemVer('0.0.0')};
describe('azdata', function () { describe('azdata', function () {
afterEach(function (): void { afterEach(function (): void {
sinon.restore(); sinon.restore();
nock.cleanAll();
nock.enableNetConnect();
}); });
describe('findAzdata', function () { describe('findAzdata', function () {
it('successful', async function (): Promise<void> { it('successful', async function (): Promise<void> {
if (process.platform === 'win32') { // Mock searchForCmd to return a path to azdata.cmd
// Mock searchForCmd to return a path to azdata.cmd sinon.stub(utils, 'searchForCmd').returns(Promise.resolve('/path/to/azdata'));
sinon.stub(utils, 'searchForCmd').returns(Promise.resolve('C:\\path\\to\\azdata.cmd'));
}
// Mock call to --version to simulate azdata being installed // 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(); await should(azdata.findAzdata()).not.be.rejected();
}); });
it('unsuccessful', async function (): Promise<void> { it('unsuccessful', async function (): Promise<void> {
@@ -40,26 +41,221 @@ describe('azdata', function () {
}); });
}); });
// TODO: Install not implemented on linux yet describe('installAzdata', function (): void {
describe('downloadAndInstallAzdata', function (): void { it('successful install', async function (): Promise<void> {
it('successful download & install', async function (): Promise<void> { switch (process.platform) {
sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); case 'win32':
if (process.platform === 'linux') { await testWin32SuccessfulInstall();
sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); 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<void> { if (process.platform === 'win32') {
nock('https://aka.ms') it('unsuccessful download - win32', async function (): Promise<void> {
.get('/azdata-msi') sinon.stub(HttpClient, 'downloadFile').rejects();
.reply(404); const downloadPromise = azdata.installAzdata();
const downloadPromise = azdata.downloadAndInstallAzdata(); await should(downloadPromise).be.rejected();
await should(downloadPromise).be.rejected(); });
}
it('unsuccessful install', async function (): Promise<void> {
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(<any>loc.yes));
});
it('successful upgrade', async function (): Promise<void> {
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<void> {
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<void> {
// 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();
}

View File

@@ -3,13 +3,13 @@
* Licensed under the Source EULA. See License.txt in the project root for license information. * 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 fs from 'fs';
import * as nock from 'nock'; import * as nock from 'nock';
import * as os from 'os';
import * as should from 'should';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import { HttpClient } from '../../common/httpClient';
import { Deferred } from '../../common/promise'; import { Deferred } from '../../common/promise';
describe('HttpClient', function (): void { describe('HttpClient', function (): void {
@@ -17,15 +17,16 @@ describe('HttpClient', function (): void {
afterEach(function (): void { afterEach(function (): void {
nock.cleanAll(); nock.cleanAll();
nock.enableNetConnect(); nock.enableNetConnect();
sinon.restore();
}); });
describe('download', function(): void { describe('downloadFile', function (): void {
it('downloads file successfully', async function (): Promise<void> { it('downloads file successfully', async function (): Promise<void> {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/README.md') .get('/README.md')
.replyWithFile(200, __filename); .replyWithFile(200, __filename);
const downloadFolder = os.tmpdir(); 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 // Verify file was downloaded correctly
await fs.promises.stat(downloadPath); await fs.promises.stat(downloadPath);
}); });
@@ -35,8 +36,7 @@ describe('HttpClient', function (): void {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/') .get('/')
.replyWithError('Unexpected Error'); .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(); await should(downloadPromise).be.rejected();
}); });
@@ -45,8 +45,7 @@ describe('HttpClient', function (): void {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/') .get('/')
.reply(404, ''); .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(); await should(downloadPromise).be.rejected();
}); });
@@ -61,7 +60,7 @@ describe('HttpClient', function (): void {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/') .get('/')
.reply(200, ''); .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 // Wait for the stream to be created before throwing the error or HttpClient will miss the event
await deferredPromise; await deferredPromise;
try { try {
@@ -73,34 +72,29 @@ describe('HttpClient', function (): void {
}); });
}); });
describe('getFilename', function(): void { describe('getTextContent', function (): void {
it('Gets filename correctly', async function (): Promise<void> { it.skip('Gets file contents correctly', async function (): Promise<void> {
const filename = 'azdata-cli-20.0.0.msi';
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get(`/${filename}`) .get('/arbitraryFile')
.reply(200); .replyWithFile(200, __filename);
const receivedFilename = await HttpClient.getFilename(`https://127.0.0.1/${filename}`); const receivedContents = await HttpClient.getTextContent(`https://127.0.0.1/arbitraryFile`);
should(receivedContents).equal(await fs.promises.readFile(__filename));
should(receivedFilename).equal(filename);
}); });
it('errors on response error', async function (): Promise<void> { it('rejects on response error', async function (): Promise<void> {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/') .get('/')
.replyWithError('Unexpected Error'); .replyWithError('Unexpected Error');
const getFilenamePromise = HttpClient.getFilename('https://127.0.0.1'); const getFileContentsPromise = HttpClient.getTextContent('https://127.0.0.1/', );
await should(getFileContentsPromise).be.rejected();
await should(getFilenamePromise).be.rejected();
}); });
it('rejects on non-OK status code', async function (): Promise<void> { it('rejects on non-OK status code', async function (): Promise<void> {
nock('https://127.0.0.1') nock('https://127.0.0.1')
.get('/') .get('/')
.reply(404, ''); .reply(404, '');
const getFilenamePromise = HttpClient.getFilename('https://127.0.0.1'); const getFileContentsPromise = HttpClient.getTextContent('https://127.0.0.1/', );
await should(getFileContentsPromise).be.rejected();
await should(getFilenamePromise).be.rejected();
}); });
}); });
}); });

View File

@@ -2,16 +2,15 @@
* Copyright (c) Microsoft Corporation. All rights reserved. * Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information. * Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as should from 'should'; import * as should from 'should';
import { searchForCmd as searchForExe } from '../../common/utils'; import { searchForCmd as searchForExe } from '../../common/utils';
describe('utils', function () { describe('utils', function () {
describe('searchForExe', function(): void { describe('searchForExe', function (): void {
it('finds exe successfully', async function(): Promise<void> { it('finds exe successfully', async function (): Promise<void> {
await searchForExe('node'); await searchForExe('node');
}); });
it('throws for non-existent exe', async function(): Promise<void> { it('throws for non-existent exe', async function (): Promise<void> {
await should(searchForExe('someFakeExe')).be.rejected(); await should(searchForExe('someFakeExe')).be.rejected();
}); });
}); });

View File

@@ -247,6 +247,13 @@
"@types/tough-cookie" "*" "@types/tough-cookie" "*"
form-data "^2.5.0" 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": "@types/sinon@^9.0.4":
version "9.0.4" version "9.0.4"
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.4.tgz#e934f904606632287a6e7f7ab0ce3f08a0dad4b1" 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" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 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: should-equal@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3"