Feature/profile file (#98)
* Initial commit of profile file saving support * Fix path used on mac/linux * Fixed issue with loading profiles from settings file * Refactored out file logic to separate class to fix tests * Fixed all existing tests * Fixed unhandled exceptions leaking in tests * Added unit tests for connection config * Minor fix for edge case * Addressing feedback
This commit is contained in:
Родитель
3a4625f072
Коммит
1b1b7dc920
|
@ -0,0 +1,169 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
// Note: this is a copy of 'vscode-mssql.connections' schema located in package.json. Ensure that both are identical when making changes.
|
||||
"vscode-mssql.connections": {
|
||||
"type": "array",
|
||||
"default": [{
|
||||
"server": "{{put-server-name-here}}",
|
||||
"database": "{{put-database-name-here}}",
|
||||
"user": "{{put-username-here}}",
|
||||
"password": "{{put-password-here}}"
|
||||
}],
|
||||
"description": "Connections placed here are shown in the connections picklist across VS Code sessions.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string",
|
||||
"default": "{{put-server-name-here}}",
|
||||
"description": "[Required] Server to connect to. Use 'hostname\\instance' or '<server>.database.windows.net'."
|
||||
},
|
||||
"database": {
|
||||
"type": "string",
|
||||
"default": "{{put-database-name-here}}",
|
||||
"description": "[Optional] Database to connect to. If this is empty, default depends on server configuration, typically 'master'."
|
||||
},
|
||||
"user": {
|
||||
"type": "string",
|
||||
"default": "{{put-username-here}}",
|
||||
"description": "[Optional] User name for SQL authentication. If this is empty, you are prompted when you connect."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"default": "{{put-password-here}}",
|
||||
"description": "[Optional] Password for SQL authentication. If this is empty, you are prompted when you connect."
|
||||
},
|
||||
"authenticationType": {
|
||||
"type": "string",
|
||||
"default": "SqlLogin",
|
||||
"enum": [
|
||||
"Integrated",
|
||||
"SqlLogin"
|
||||
],
|
||||
"description": "[Optional] Specifies the method of authenticating with SQL Server."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"default": 1433,
|
||||
"description": "[Optional] Specify the port number to connect to."
|
||||
},
|
||||
"encrypt": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "[Optional] Specify if the connection will be encrypted. Always set to 'true' for Azure SQL DB and loaded from here otherwise."
|
||||
},
|
||||
"trustServerCertificate": {
|
||||
"type": "boolean",
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"persistSecurityInfo": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"connectTimeout": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"description": "[Optional] The length of time in seconds to wait for a connection to the server before terminating the attempt and generating error."
|
||||
},
|
||||
"connectRetryCount": {
|
||||
"type": "number",
|
||||
"default": 1,
|
||||
"description": "[Optional] Number of attempts to restore connection."
|
||||
},
|
||||
"connectRetryInterval": {
|
||||
"type": "number",
|
||||
"default": 10,
|
||||
"description": "[Optional] Delay between attempts to restore connection."
|
||||
},
|
||||
"applicationName": {
|
||||
"type": "string",
|
||||
"default": "vscode-mssql",
|
||||
"description": "[Optional] Application name used for SQL server logging (default: 'vscode-mssql')."
|
||||
},
|
||||
"workstationId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "[Optional] The name of the workstation connecting to SQL Server."
|
||||
},
|
||||
"applicationIntent": {
|
||||
"type": "string",
|
||||
"default": "ReadWrite",
|
||||
"enum": [
|
||||
"ReadWrite",
|
||||
"ReadOnly"
|
||||
],
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"currentLanguage": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"pooling": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"maxPoolSize": {
|
||||
"type": "number",
|
||||
"default": 100,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"minPoolSize": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"loadBalanceTimeout": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"replication": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"attachDbFilename": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"failoverPartner": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"multiSubnetFailover": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"multipleActiveResultSets": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"packetSize": {
|
||||
"type": "number",
|
||||
"default": 8192,
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"typeSystemVersion": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Latest"
|
||||
],
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"profileName": {
|
||||
"type": "string",
|
||||
"description": "[Optional]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
package.json
15
package.json
|
@ -133,6 +133,11 @@
|
|||
"command": "extension.chooseDatabase",
|
||||
"title": "Switch database on current server",
|
||||
"category": "MSSQL"
|
||||
},
|
||||
{
|
||||
"command": "extension.openConnectionSettingsFile",
|
||||
"title": "Open Connection Profile Settings",
|
||||
"category": "MSSQL"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
|
@ -161,6 +166,12 @@
|
|||
"when": "editorTextFocus && editorLangId == 'sql'"
|
||||
}
|
||||
],
|
||||
"jsonValidation": [
|
||||
{
|
||||
"fileMatch" : "connections.json",
|
||||
"url": "./connections.schema.json"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"title": "MSSQL configuration",
|
||||
|
@ -332,6 +343,10 @@
|
|||
"Latest"
|
||||
],
|
||||
"description": "[Optional]"
|
||||
},
|
||||
"profileName": {
|
||||
"type": "string",
|
||||
"description": "[Optional]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
'use strict';
|
||||
|
||||
import fs = require('fs');
|
||||
import os = require('os');
|
||||
import * as Constants from '../models/constants';
|
||||
import * as Utils from '../models/utils';
|
||||
import { IConnectionCredentials, IConnectionProfile } from '../models/interfaces';
|
||||
import { IConnectionConfig } from './iconnectionconfig';
|
||||
import VscodeWrapper from '../controllers/vscodeWrapper';
|
||||
|
||||
/**
|
||||
* Implements connection profile file storage.
|
||||
*/
|
||||
export class ConnectionConfig implements IConnectionConfig {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public constructor(private _fs?: any, private _vscodeWrapper?: VscodeWrapper) {
|
||||
if (!this._fs) {
|
||||
this._fs = fs;
|
||||
}
|
||||
if (!this.vscodeWrapper) {
|
||||
this.vscodeWrapper = new VscodeWrapper();
|
||||
}
|
||||
}
|
||||
|
||||
private get vscodeWrapper(): VscodeWrapper {
|
||||
return this._vscodeWrapper;
|
||||
}
|
||||
|
||||
private set vscodeWrapper(value: VscodeWrapper) {
|
||||
this._vscodeWrapper = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read connection profiles stored in connection json file, if it exists.
|
||||
*/
|
||||
public readConnectionsFromConfigFile(): IConnectionProfile[] {
|
||||
let profiles: IConnectionProfile[] = [];
|
||||
|
||||
try {
|
||||
let fileBuffer: Buffer = this._fs.readFileSync(ConnectionConfig.configFilePath);
|
||||
if (fileBuffer) {
|
||||
let fileContents: string = fileBuffer.toString();
|
||||
if (!Utils.isEmpty(fileContents)) {
|
||||
try {
|
||||
let json: any = JSON.parse(fileContents);
|
||||
if (json && json.hasOwnProperty(Constants.connectionsArrayName)) {
|
||||
profiles = json[Constants.connectionsArrayName];
|
||||
} else {
|
||||
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
|
||||
}
|
||||
} catch (e) { // Error parsing JSON
|
||||
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { // Error reading the file
|
||||
if (e.code !== 'ENOENT') { // Ignore error if the file doesn't exist
|
||||
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write connection profiles to the configuration json file.
|
||||
*/
|
||||
public writeConnectionsToConfigFile(connections: IConnectionCredentials[]): Promise<void> {
|
||||
const self = this;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
self.createConfigFileDirectory().then(() => {
|
||||
let connectionsObject = {};
|
||||
connectionsObject[Constants.connectionsArrayName] = connections;
|
||||
|
||||
// Format the file using 4 spaces as indentation
|
||||
self._fs.writeFile(ConnectionConfig.configFilePath, JSON.stringify(connectionsObject, undefined, 4), err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory containing the connection config file.
|
||||
*/
|
||||
private static get configFileDirectory(): string {
|
||||
if (os.platform() === 'win32') {
|
||||
// On Windows, we store connection configurations in %APPDATA%\<extension name>\
|
||||
return process.env['APPDATA'] + '\\' + Constants.extensionName + '\\';
|
||||
} else {
|
||||
// On OSX/Linux, we store connection configurations in ~/.config/<extension name>/
|
||||
return process.env['HOME'] + '/.config/' + Constants.extensionName + '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path of the connection config filename.
|
||||
*/
|
||||
public static get configFilePath(): string {
|
||||
return this.configFileDirectory + Constants.connectionConfigFilename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public for testing purposes.
|
||||
*/
|
||||
public createConfigFileDirectory(): Promise<void> {
|
||||
const self = this;
|
||||
const configFileDir: string = ConnectionConfig.configFileDirectory;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
self._fs.mkdir(configFileDir, err => {
|
||||
// If the directory already exists, ignore the error
|
||||
if (err && err.code !== 'EEXIST') {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
'use strict';
|
||||
|
||||
import { IConnectionCredentials, IConnectionProfile } from '../models/interfaces';
|
||||
|
||||
/**
|
||||
* Interface for a configuration file that stores connection profiles.
|
||||
*
|
||||
* @export
|
||||
* @interface IConnectionConfig
|
||||
*/
|
||||
export interface IConnectionConfig {
|
||||
readConnectionsFromConfigFile(): IConnectionProfile[];
|
||||
writeConnectionsToConfigFile(connections: IConnectionCredentials[]): Promise<void>;
|
||||
}
|
|
@ -70,6 +70,8 @@ export default class MainController implements vscode.Disposable {
|
|||
this._event.on(Constants.cmdRemoveProfile, () => { self.runAndLogErrors(self.onRemoveProfile()); });
|
||||
this.registerCommand(Constants.cmdChooseDatabase);
|
||||
this._event.on(Constants.cmdChooseDatabase, () => { self.onChooseDatabase(); } );
|
||||
this.registerCommand(Constants.cmdOpenConnectionSettings);
|
||||
this._event.on(Constants.cmdOpenConnectionSettings, () => { self.onOpenConnectionSettings(); } );
|
||||
|
||||
this._vscodeWrapper = new VscodeWrapper();
|
||||
|
||||
|
@ -174,6 +176,13 @@ export default class MainController implements vscode.Disposable {
|
|||
return this._connectionMgr.onRemoveProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the settings file where connection profiles are stored.
|
||||
*/
|
||||
public onOpenConnectionSettings(): void {
|
||||
this._connectionMgr.connectionUI.openConnectionProfileConfigFile();
|
||||
}
|
||||
|
||||
private runAndLogErrors<T>(promise: Promise<T>): Promise<T> {
|
||||
let self = this;
|
||||
return promise.catch(err => {
|
||||
|
|
|
@ -78,6 +78,25 @@ export default class VscodeWrapper {
|
|||
return vscode.workspace.onDidSaveTextDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the denoted document from disk. Will return early if the
|
||||
* document is already open, otherwise the document is loaded and the
|
||||
* [open document](#workspace.onDidOpenTextDocument)-event fires.
|
||||
* The document to open is denoted by the [uri](#Uri). Two schemes are supported:
|
||||
*
|
||||
* file: A file on disk, will be rejected if the file does not exist or cannot be loaded, e.g. `file:///Users/frodo/r.ini`.
|
||||
* untitled: A new file that should be saved on disk, e.g. `untitled:c:\frodo\new.js`. The language will be derived from the file name.
|
||||
*
|
||||
* Uris with other schemes will make this method return a rejected promise.
|
||||
*
|
||||
* @param uri Identifies the resource to open.
|
||||
* @return A promise that resolves to a [document](#TextDocument).
|
||||
* @see vscode.workspace.openTextDocument
|
||||
*/
|
||||
public openTextDocument(uri: vscode.Uri): Thenable<vscode.TextDocument> {
|
||||
return vscode.workspace.openTextDocument(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log messages to "MSSQL" output channel.
|
||||
*/
|
||||
|
@ -125,10 +144,48 @@ export default class VscodeWrapper {
|
|||
return vscode.window.showQuickPick<T>(items, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the given document in a text editor. A [column](#ViewColumn) can be provided
|
||||
* to control where the editor is being shown. Might change the [active editor](#window.activeTextEditor).
|
||||
*
|
||||
* @param document A text document to be shown.
|
||||
* @param column A view column in which the editor should be shown. The default is the [one](#ViewColumn.One), other values
|
||||
* are adjusted to be __Min(column, columnCount + 1)__.
|
||||
* @param preserveFocus When `true` the editor will not take focus.
|
||||
* @return A promise that resolves to an [editor](#TextEditor).
|
||||
*/
|
||||
public showTextDocument(document: vscode.TextDocument, column?: vscode.ViewColumn, preserveFocus?: boolean): Thenable<vscode.TextEditor> {
|
||||
return vscode.window.showTextDocument(document, column, preserveFocus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats and shows a vscode warning message
|
||||
*/
|
||||
public showWarningMessage(msg: string): Thenable<string> {
|
||||
return vscode.window.showWarningMessage(Constants.extensionName + ': ' + msg );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an URI from a file system path. The [scheme](#Uri.scheme)
|
||||
* will be `file`.
|
||||
*
|
||||
* @param path A file system or UNC path.
|
||||
* @return A new Uri instance.
|
||||
* @see vscode.Uri.file
|
||||
*/
|
||||
public uriFile(path: string): vscode.Uri {
|
||||
return vscode.Uri.file(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an URI from a string. Will throw if the given value is not
|
||||
* valid.
|
||||
*
|
||||
* @param value The string value of an Uri.
|
||||
* @return A new Uri instance.
|
||||
* @see vscode.Uri.parse
|
||||
*/
|
||||
public uriParse(value: string): vscode.Uri {
|
||||
return vscode.Uri.parse(value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import { ConnectionCredentials } from '../models/connectionCredentials';
|
|||
import { IConnectionCredentials, IConnectionProfile, IConnectionCredentialsQuickPickItem, CredentialsQuickPickItemType } from '../models/interfaces';
|
||||
import { ICredentialStore } from '../credentialstore/icredentialstore';
|
||||
import { CredentialStore } from '../credentialstore/credentialstore';
|
||||
import { IConnectionConfig } from '../connectionconfig/iconnectionconfig';
|
||||
import { ConnectionConfig } from '../connectionconfig/connectionconfig';
|
||||
import VscodeWrapper from '../controllers/vscodeWrapper';
|
||||
|
||||
/**
|
||||
|
@ -21,12 +23,16 @@ export class ConnectionStore {
|
|||
constructor(
|
||||
private _context: vscode.ExtensionContext,
|
||||
private _credentialStore?: ICredentialStore,
|
||||
private _connectionConfig?: IConnectionConfig,
|
||||
private _vscodeWrapper?: VscodeWrapper) {
|
||||
if (!this._credentialStore) {
|
||||
this._credentialStore = new CredentialStore();
|
||||
}
|
||||
if (!this._vscodeWrapper) {
|
||||
this._vscodeWrapper = new VscodeWrapper();
|
||||
if (!this.vscodeWrapper) {
|
||||
this.vscodeWrapper = new VscodeWrapper();
|
||||
}
|
||||
if (!this._connectionConfig) {
|
||||
this._connectionConfig = new ConnectionConfig();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,10 +155,7 @@ export class ConnectionStore {
|
|||
const self = this;
|
||||
return new Promise<IConnectionProfile>((resolve, reject) => {
|
||||
// Get all profiles
|
||||
let configValues = self._context.globalState.get<IConnectionProfile[]>(Constants.configMyConnections);
|
||||
if (!configValues) {
|
||||
configValues = [];
|
||||
}
|
||||
let configValues = self.getConnectionsFromConfigFile();
|
||||
|
||||
// Remove the profile if already set
|
||||
configValues = configValues.filter(value => !Utils.isSameProfile(value, profile));
|
||||
|
@ -160,7 +163,7 @@ export class ConnectionStore {
|
|||
// Add the profile to the saved list, taking care to clear out the password field
|
||||
let savedProfile: IConnectionProfile = Object.assign({}, profile, { password: '' });
|
||||
configValues.push(savedProfile);
|
||||
self._context.globalState.update(Constants.configMyConnections, configValues)
|
||||
self._connectionConfig.writeConnectionsToConfigFile(configValues)
|
||||
.then(() => {
|
||||
// Only save if we successfully added the profile
|
||||
return self.saveProfilePasswordIfNeeded(profile);
|
||||
|
@ -268,10 +271,7 @@ export class ConnectionStore {
|
|||
const self = this;
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
// Get all profiles
|
||||
let configValues = self._context.globalState.get<IConnectionProfile[]>(Constants.configMyConnections);
|
||||
if (!configValues) {
|
||||
configValues = [];
|
||||
}
|
||||
let configValues = self.getConnectionsFromConfigFile();
|
||||
|
||||
// Remove the profile if already set
|
||||
let found: boolean = false;
|
||||
|
@ -285,7 +285,7 @@ export class ConnectionStore {
|
|||
}
|
||||
});
|
||||
|
||||
self._context.globalState.update(Constants.configMyConnections, configValues).then(() => {
|
||||
self._connectionConfig.writeConnectionsToConfigFile(configValues).then(() => {
|
||||
resolve(found);
|
||||
}, err => reject(err));
|
||||
}).then(profileFound => {
|
||||
|
@ -324,8 +324,7 @@ export class ConnectionStore {
|
|||
let profilesInConfiguration = this.getConnectionsFromConfig<IConnectionCredentials>(Constants.configMyConnections);
|
||||
quickPickItems = quickPickItems.concat(this.mapToQuickPickItems(profilesInConfiguration, CredentialsQuickPickItemType.Profile));
|
||||
|
||||
// next read from the profiles saved in our own memento
|
||||
// TODO remove once user settings are editable programmatically
|
||||
// next read from the profiles saved in the user-editable config file
|
||||
let profiles = this.loadProfiles();
|
||||
quickPickItems = quickPickItems.concat(profiles);
|
||||
|
||||
|
@ -341,6 +340,14 @@ export class ConnectionStore {
|
|||
return connections;
|
||||
}
|
||||
|
||||
private getConnectionsFromConfigFile<T extends IConnectionProfile>(): T[] {
|
||||
let connections: T[] = [];
|
||||
// read from the config file
|
||||
let configValues = this._connectionConfig.readConnectionsFromConfigFile();
|
||||
this.addConnections(connections, configValues);
|
||||
return connections;
|
||||
}
|
||||
|
||||
private getConnectionsFromConfig<T extends IConnectionCredentials>(configName: string): T[] {
|
||||
let config = this._vscodeWrapper.getConfiguration(Constants.extensionName);
|
||||
// we do not want the default value returned since it's used for helping users only
|
||||
|
@ -362,7 +369,7 @@ export class ConnectionStore {
|
|||
}
|
||||
|
||||
private loadProfiles(): IConnectionCredentialsQuickPickItem[] {
|
||||
let connections: IConnectionProfile[] = this.getConnectionsFromGlobalState<IConnectionProfile>(Constants.configMyConnections);
|
||||
let connections: IConnectionProfile[] = this.getConnectionsFromConfigFile<IConnectionProfile>();
|
||||
let quickPickItems = connections.map(c => this.createQuickPickItem(c, CredentialsQuickPickItemType.Profile));
|
||||
return quickPickItems;
|
||||
}
|
||||
|
|
|
@ -3,12 +3,26 @@ export const languageId = 'sql';
|
|||
export const extensionName = 'vscode-mssql';
|
||||
export const outputChannelName = 'MSSQL';
|
||||
|
||||
export const connectionConfigFilename = 'connections.json';
|
||||
export const connectionsArrayName = 'vscode-mssql.connections';
|
||||
export const defaultConnectionSettingsFileJson = {
|
||||
'vscode-mssql.connections': [
|
||||
{
|
||||
'server': '{{put-server-name-here}}',
|
||||
'database': '{{put-database-name-here}}',
|
||||
'user': '{{put-username-here}}',
|
||||
'password': '{{put-password-here}}'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const cmdRunQuery = 'extension.runQuery';
|
||||
export const cmdConnect = 'extension.connect';
|
||||
export const cmdDisconnect = 'extension.disconnect';
|
||||
export const cmdCreateProfile = 'extension.createprofile';
|
||||
export const cmdRemoveProfile = 'extension.removeprofile';
|
||||
export const cmdChooseDatabase = 'extension.chooseDatabase';
|
||||
export const cmdOpenConnectionSettings = 'extension.openConnectionSettingsFile';
|
||||
|
||||
export const sqlDbPrefix = '.database.windows.net';
|
||||
export const defaultConnectionTimeout = 15;
|
||||
|
@ -147,3 +161,7 @@ export const msgChangedDatabaseContext = 'Changed database context to \"{0}\" fo
|
|||
export const msgPromptRetryCreateProfile = 'Error: Unable to connect using the profile information provided. Retry profile creation?';
|
||||
|
||||
export const msgConnectedServerInfo = 'Connected to server \"{0}\" on document \"{1}\". Server information: {2}';
|
||||
|
||||
export const msgErrorReadingConfigFile = 'Error: Unable to load connection profiles from [{0}]. Check that the file is formatted correctly.';
|
||||
export const msgNewConfigFileHelpInfo = 'Save connections.json to enable autocomplete while editing connection profile settings.';
|
||||
export const msgErrorOpeningConfigFile = 'Error: Unable to open connection profile settings file.';
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
'use strict';
|
||||
import vscode = require('vscode');
|
||||
import fs = require('fs');
|
||||
import Constants = require('../models/constants');
|
||||
import { ConnectionConfig } from '../connectionconfig/connectionconfig';
|
||||
import { ConnectionCredentials } from '../models/connectionCredentials';
|
||||
import ConnectionManager from '../controllers/connectionManager';
|
||||
import { ConnectionStore } from '../models/connectionStore';
|
||||
|
@ -9,6 +11,7 @@ import { IConnectionCredentials, IConnectionProfile, IConnectionCredentialsQuick
|
|||
import { IQuestion, IPrompter, QuestionTypes } from '../prompts/question';
|
||||
import Interfaces = require('../models/interfaces');
|
||||
import { Timer } from '../models/utils';
|
||||
import * as Utils from '../models/utils';
|
||||
import VscodeWrapper from '../controllers/vscodeWrapper';
|
||||
|
||||
export class ConnectionUI {
|
||||
|
@ -345,4 +348,48 @@ export class ConnectionUI {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the configuration file that stores the connection profiles.
|
||||
*/
|
||||
public openConnectionProfileConfigFile(): void {
|
||||
const self = this;
|
||||
this.vscodeWrapper.openTextDocument(this.vscodeWrapper.uriFile(ConnectionConfig.configFilePath))
|
||||
.then(doc => {
|
||||
self.vscodeWrapper.showTextDocument(doc);
|
||||
}, error => {
|
||||
// Check if the file doesn't exist
|
||||
let fileExists = true;
|
||||
try {
|
||||
fs.accessSync(ConnectionConfig.configFilePath);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
fileExists = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
// Open an untitled file to be saved at the proper path if it doesn't exist
|
||||
self.vscodeWrapper.openTextDocument(self.vscodeWrapper.uriParse(Constants.untitledScheme + ':' + ConnectionConfig.configFilePath))
|
||||
.then(doc => {
|
||||
self.vscodeWrapper.showTextDocument(doc).then(editor => {
|
||||
if (Utils.isEmpty(editor.document.getText())) {
|
||||
// Insert the template for a new connection into the file
|
||||
editor.edit(builder => {
|
||||
let position: vscode.Position = new vscode.Position(0, 0);
|
||||
builder.insert(position, JSON.stringify(Constants.defaultConnectionSettingsFileJson, undefined, 4));
|
||||
});
|
||||
}
|
||||
|
||||
// Remind the user to save if they would like auto-completion enabled while editing the new file
|
||||
self._vscodeWrapper.showInformationMessage(Constants.msgNewConfigFileHelpInfo);
|
||||
});
|
||||
}, err => {
|
||||
self._vscodeWrapper.showErrorMessage(Constants.msgErrorOpeningConfigFile);
|
||||
});
|
||||
} else { // Unable to access the file
|
||||
self._vscodeWrapper.showErrorMessage(Constants.msgErrorOpeningConfigFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import * as interfaces from '../src/models/interfaces';
|
|||
import { CredentialStore } from '../src/credentialstore/credentialstore';
|
||||
import { ConnectionProfile } from '../src/models/connectionProfile';
|
||||
import { ConnectionStore } from '../src/models/connectionStore';
|
||||
import { ConnectionConfig } from '../src/connectionconfig/connectionconfig';
|
||||
import VscodeWrapper from '../src/controllers/vscodeWrapper';
|
||||
|
||||
import assert = require('assert');
|
||||
|
@ -22,6 +23,7 @@ suite('ConnectionStore tests', () => {
|
|||
let globalstate: TypeMoq.Mock<vscode.Memento>;
|
||||
let credentialStore: TypeMoq.Mock<CredentialStore>;
|
||||
let vscodeWrapper: TypeMoq.Mock<VscodeWrapper>;
|
||||
let connectionConfig: TypeMoq.Mock<ConnectionConfig>;
|
||||
|
||||
setup(() => {
|
||||
defaultNamedProfile = Object.assign(new ConnectionProfile(), {
|
||||
|
@ -47,6 +49,7 @@ suite('ConnectionStore tests', () => {
|
|||
context.object.globalState = globalstate.object;
|
||||
credentialStore = TypeMoq.Mock.ofType(CredentialStore);
|
||||
vscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
connectionConfig = TypeMoq.Mock.ofType(ConnectionConfig);
|
||||
|
||||
// setup default behavior for vscodeWrapper
|
||||
// setup configuration to return maxRecent for the #MRU items
|
||||
|
@ -121,13 +124,13 @@ suite('ConnectionStore tests', () => {
|
|||
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
|
||||
|
||||
let credsToSave: interfaces.IConnectionProfile[];
|
||||
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
|
||||
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((profiles: interfaces.IConnectionProfile[]) => {
|
||||
credsToSave = profiles;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
|
||||
|
||||
// When SaveProfile is called with savePassword false
|
||||
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: false });
|
||||
|
@ -147,8 +150,8 @@ suite('ConnectionStore tests', () => {
|
|||
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
|
||||
|
||||
let credsToSave: interfaces.IConnectionProfile[];
|
||||
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
|
||||
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((profiles: interfaces.IConnectionProfile[]) => {
|
||||
credsToSave = profiles;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -165,7 +168,7 @@ suite('ConnectionStore tests', () => {
|
|||
|
||||
let expectedCredFormat: string = ConnectionStore.formatCredentialId(defaultNamedProfile.server, defaultNamedProfile.database, defaultNamedProfile.user);
|
||||
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
|
||||
|
||||
// When SaveProfile is called with savePassword true
|
||||
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: true });
|
||||
|
@ -191,11 +194,11 @@ suite('ConnectionStore tests', () => {
|
|||
server: 'otherServer',
|
||||
savePassword: true
|
||||
});
|
||||
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, profile]);
|
||||
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, profile]);
|
||||
|
||||
let updatedCredentials: interfaces.IConnectionProfile[];
|
||||
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
|
||||
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((profiles: interfaces.IConnectionProfile[]) => {
|
||||
updatedCredentials = profiles;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -211,7 +214,7 @@ suite('ConnectionStore tests', () => {
|
|||
|
||||
let expectedCredFormat: string = ConnectionStore.formatCredentialId(profile.server, profile.database, profile.user);
|
||||
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
|
||||
|
||||
// When RemoveProfile is called for once profile
|
||||
|
||||
|
@ -239,11 +242,11 @@ suite('ConnectionStore tests', () => {
|
|||
let namedProfile = Object.assign(new ConnectionProfile(), unnamedProfile, {
|
||||
profileName: 'named'
|
||||
});
|
||||
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
|
||||
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, unnamedProfile, namedProfile]);
|
||||
|
||||
let updatedCredentials: interfaces.IConnectionProfile[];
|
||||
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
|
||||
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((profiles: interfaces.IConnectionProfile[]) => {
|
||||
updatedCredentials = profiles;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -251,7 +254,7 @@ suite('ConnectionStore tests', () => {
|
|||
credentialStore.setup(x => x.deleteCredential(TypeMoq.It.isAny()))
|
||||
.returns(() => Promise.resolve(true));
|
||||
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
|
||||
// When RemoveProfile is called for the profile
|
||||
connectionStore.removeProfile(unnamedProfile)
|
||||
.then(success => {
|
||||
|
@ -274,11 +277,11 @@ suite('ConnectionStore tests', () => {
|
|||
let namedProfile = Object.assign(new ConnectionProfile(), unnamedProfile, {
|
||||
profileName: 'named'
|
||||
});
|
||||
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
|
||||
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, unnamedProfile, namedProfile]);
|
||||
|
||||
let updatedCredentials: interfaces.IConnectionProfile[];
|
||||
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
|
||||
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
|
||||
.returns((profiles: interfaces.IConnectionProfile[]) => {
|
||||
updatedCredentials = profiles;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -286,7 +289,7 @@ suite('ConnectionStore tests', () => {
|
|||
credentialStore.setup(x => x.deleteCredential(TypeMoq.It.isAny()))
|
||||
.returns(() => Promise.resolve(true));
|
||||
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
|
||||
// When RemoveProfile is called for the profile
|
||||
connectionStore.removeProfile(namedProfile)
|
||||
.then(success => {
|
||||
|
@ -328,7 +331,7 @@ suite('ConnectionStore tests', () => {
|
|||
|
||||
// When saving 4 connections
|
||||
// Then expect the only the 3 most recently saved connections to be returned as size is limited to 3
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
|
||||
|
||||
let promise = Promise.resolve();
|
||||
for (let i = 0; i < numCreds; i++) {
|
||||
|
@ -371,7 +374,7 @@ suite('ConnectionStore tests', () => {
|
|||
|
||||
// Given we save the same connection twice
|
||||
// Then expect the only 1 instance of that connection to be listed in the MRU
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
|
||||
|
||||
let promise = Promise.resolve();
|
||||
let cred = Object.assign({}, defaultNamedProfile, { server: defaultNamedProfile.server + 1});
|
||||
|
@ -410,7 +413,7 @@ suite('ConnectionStore tests', () => {
|
|||
.returns(() => Promise.resolve(true));
|
||||
|
||||
// Given we save 1 connection with password and multiple other connections without
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
|
||||
let integratedCred = Object.assign({}, defaultNamedProfile, {
|
||||
server: defaultNamedProfile.server + 'Integrated',
|
||||
authenticationType: interfaces.AuthenticationTypes[interfaces.AuthenticationTypes.Integrated],
|
||||
|
@ -462,12 +465,12 @@ suite('ConnectionStore tests', () => {
|
|||
globalstate.setup(x => x.get(Constants.configRecentConnections)).returns(key => recentlyUsed);
|
||||
|
||||
let profiles: interfaces.IConnectionProfile[] = [defaultNamedProfile, defaultUnnamedProfile];
|
||||
globalstate.setup(x => x.get(Constants.configMyConnections)).returns(key => profiles);
|
||||
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => profiles);
|
||||
|
||||
// When we get the list of available connection items
|
||||
|
||||
// Then expect MRU items first, then profile items, then a new connection item
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
|
||||
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object, vscodeWrapper.object);
|
||||
|
||||
let items: interfaces.IConnectionCredentialsQuickPickItem[] = connectionStore.getPickListItems();
|
||||
let expectedCount = recentlyUsed.length + profiles.length + 1;
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import * as TypeMoq from 'typemoq';
|
||||
|
||||
import { ConnectionConfig } from '../src/connectionconfig/connectionconfig';
|
||||
import { IConnectionProfile } from '../src/models/interfaces';
|
||||
import VscodeWrapper from '../src/controllers/vscodeWrapper';
|
||||
|
||||
import assert = require('assert');
|
||||
import fs = require('fs');
|
||||
|
||||
const corruptJson =
|
||||
`{
|
||||
vscode-mssql.connections: [
|
||||
{}
|
||||
corrupt!@#$%
|
||||
]
|
||||
}`;
|
||||
|
||||
const validJson =
|
||||
`
|
||||
{
|
||||
"vscode-mssql.connections": [
|
||||
{
|
||||
"server": "my-server",
|
||||
"database": "my_db",
|
||||
"user": "sa",
|
||||
"password": "12345678"
|
||||
},
|
||||
{
|
||||
"server": "my-other-server",
|
||||
"database": "my_other_db",
|
||||
"user": "sa",
|
||||
"password": "qwertyuiop"
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
const arrayTitleMissingJson =
|
||||
`
|
||||
[
|
||||
{
|
||||
"server": "my-server",
|
||||
"database": "my_db",
|
||||
"user": "sa",
|
||||
"password": "12345678"
|
||||
}
|
||||
]
|
||||
`;
|
||||
|
||||
suite('ConnectionConfig tests', () => {
|
||||
test('error message is shown when reading corrupt config file', () => {
|
||||
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
|
||||
bufferMock.setup(x => x.toString()).returns(() => corruptJson);
|
||||
|
||||
let fsMock = TypeMoq.Mock.ofInstance(fs);
|
||||
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
|
||||
|
||||
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
|
||||
// Given a connection config object that reads a corrupt json file
|
||||
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
|
||||
config.readConnectionsFromConfigFile();
|
||||
|
||||
// Verify that an error message was displayed to the user
|
||||
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.once());
|
||||
});
|
||||
|
||||
test('no error message is shown when reading valid config file', () => {
|
||||
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
|
||||
bufferMock.setup(x => x.toString()).returns(() => validJson);
|
||||
|
||||
let fsMock = TypeMoq.Mock.ofInstance(fs);
|
||||
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
|
||||
|
||||
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
|
||||
// Given a connection config object that reads a valid json file
|
||||
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
|
||||
let profiles: IConnectionProfile[] = config.readConnectionsFromConfigFile();
|
||||
|
||||
// Verify that the profiles were read correctly
|
||||
assert.strictEqual(profiles.length, 2);
|
||||
assert.strictEqual(profiles[0].server, 'my-server');
|
||||
assert.strictEqual(profiles[0].database, 'my_db');
|
||||
assert.strictEqual(profiles[0].user, 'sa');
|
||||
assert.strictEqual(profiles[0].password, '12345678');
|
||||
assert.strictEqual(profiles[1].server, 'my-other-server');
|
||||
assert.strictEqual(profiles[1].database, 'my_other_db');
|
||||
assert.strictEqual(profiles[1].user, 'sa');
|
||||
assert.strictEqual(profiles[1].password, 'qwertyuiop');
|
||||
|
||||
// Verify that no error message was displayed to the user
|
||||
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.never());
|
||||
});
|
||||
|
||||
test('error message is shown when config file is missing array title', () => {
|
||||
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
|
||||
bufferMock.setup(x => x.toString()).returns(() => arrayTitleMissingJson);
|
||||
|
||||
let fsMock = TypeMoq.Mock.ofInstance(fs);
|
||||
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
|
||||
|
||||
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
|
||||
// Given a connection config object that reads a json file with the array title missing
|
||||
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
|
||||
config.readConnectionsFromConfigFile();
|
||||
|
||||
// Verify that an error message was shown to the user
|
||||
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.once());
|
||||
});
|
||||
|
||||
test('error is thrown when config directory cannot be created', done => {
|
||||
let fsMock = TypeMoq.Mock.ofInstance(fs);
|
||||
fsMock.setup(x => x.mkdir(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
|
||||
.returns((path, errorHandler) => {
|
||||
let error = {
|
||||
code: 'EACCES'
|
||||
};
|
||||
errorHandler(error);
|
||||
});
|
||||
|
||||
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
|
||||
// Given a connection config object that tries to create a config directory without appropriate permissions
|
||||
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
|
||||
config.createConfigFileDirectory().then(() => {
|
||||
done('Promise should not resolve successfully');
|
||||
}).catch(err => {
|
||||
// Expect an error to be thrown
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('error is not thrown when config directory already exists', done => {
|
||||
let fsMock = TypeMoq.Mock.ofInstance(fs);
|
||||
fsMock.setup(x => x.mkdir(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
|
||||
.returns((path, errorHandler) => {
|
||||
let error = {
|
||||
code: 'EEXIST'
|
||||
};
|
||||
errorHandler(error);
|
||||
});
|
||||
|
||||
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
|
||||
|
||||
// Given a connection config object that tries to create a config directory when it already exists
|
||||
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
|
||||
config.createConfigFileDirectory().then(() => {
|
||||
// Expect no error to be thrown
|
||||
done();
|
||||
}).catch(err => {
|
||||
done(err);
|
||||
});
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче