mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-16 09:35:36 -05:00
Swapping vscode calls for ApiWrapper for testability (#10267)
* swapping vscode calls for apiwrapper for testability * Adding mainController tests * Adding unit tests for input validation * Adding project controller tests, reorganizing error handling * Removing commented-out code
This commit is contained in:
149
extensions/sql-database-projects/src/common/apiWrapper.ts
Normal file
149
extensions/sql-database-projects/src/common/apiWrapper.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as azdata from 'azdata';
|
||||
|
||||
/**
|
||||
* Wrapper class to act as a facade over VSCode and Data APIs and allow us to test / mock callbacks into
|
||||
* this API from our code
|
||||
*/
|
||||
export class ApiWrapper {
|
||||
public createOutputChannel(name: string): vscode.OutputChannel {
|
||||
return vscode.window.createOutputChannel(name);
|
||||
}
|
||||
|
||||
public createTerminalWithOptions(options: vscode.TerminalOptions): vscode.Terminal {
|
||||
return vscode.window.createTerminal(options);
|
||||
}
|
||||
|
||||
public getCurrentConnection(): Thenable<azdata.connection.ConnectionProfile> {
|
||||
return azdata.connection.getCurrentConnection();
|
||||
}
|
||||
|
||||
public getCredentials(connectionId: string): Thenable<{ [name: string]: string }> {
|
||||
return azdata.connection.getCredentials(connectionId);
|
||||
}
|
||||
|
||||
public registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable {
|
||||
return vscode.commands.registerCommand(command, callback, thisArg);
|
||||
}
|
||||
|
||||
public executeCommand<T>(command: string, ...rest: any[]): Thenable<T | undefined> {
|
||||
return vscode.commands.executeCommand(command, ...rest);
|
||||
}
|
||||
|
||||
public registerTaskHandler(taskId: string, handler: (profile: azdata.IConnectionProfile) => void): void {
|
||||
azdata.tasks.registerTask(taskId, handler);
|
||||
}
|
||||
|
||||
public registerTreeDataProvider<T>(viewId: string, treeDataProvider: vscode.TreeDataProvider<T>): vscode.Disposable {
|
||||
return vscode.window.registerTreeDataProvider(viewId, treeDataProvider);
|
||||
}
|
||||
|
||||
public getUriForConnection(connectionId: string): Thenable<string> {
|
||||
return azdata.connection.getUriForConnection(connectionId);
|
||||
}
|
||||
|
||||
public getProvider<T extends azdata.DataProvider>(providerId: string, providerType: azdata.DataProviderType): T {
|
||||
return azdata.dataprotocol.getProvider<T>(providerId, providerType);
|
||||
}
|
||||
|
||||
public showErrorMessage(message: string, ...items: string[]): Thenable<string | undefined> {
|
||||
return vscode.window.showErrorMessage(message, ...items);
|
||||
}
|
||||
|
||||
public showInformationMessage(message: string, ...items: string[]): Thenable<string | undefined> {
|
||||
return vscode.window.showInformationMessage(message, ...items);
|
||||
}
|
||||
|
||||
public showOpenDialog(options: vscode.OpenDialogOptions): Thenable<vscode.Uri[] | undefined> {
|
||||
return vscode.window.showOpenDialog(options);
|
||||
}
|
||||
|
||||
public startBackgroundOperation(operationInfo: azdata.BackgroundOperationInfo): void {
|
||||
azdata.tasks.startBackgroundOperation(operationInfo);
|
||||
}
|
||||
|
||||
public openExternal(target: vscode.Uri): Thenable<boolean> {
|
||||
return vscode.env.openExternal(target);
|
||||
}
|
||||
|
||||
public getExtension(extensionId: string): vscode.Extension<any> | undefined {
|
||||
return vscode.extensions.getExtension(extensionId);
|
||||
}
|
||||
|
||||
public getConfiguration(section?: string, resource?: vscode.Uri | null): vscode.WorkspaceConfiguration {
|
||||
return vscode.workspace.getConfiguration(section, resource);
|
||||
}
|
||||
|
||||
public workspaceFolders(): readonly vscode.WorkspaceFolder[] | undefined {
|
||||
return vscode.workspace.workspaceFolders;
|
||||
}
|
||||
|
||||
public createTab(title: string): azdata.window.DialogTab {
|
||||
return azdata.window.createTab(title);
|
||||
}
|
||||
|
||||
public createModelViewDialog(title: string, dialogName?: string, isWide?: boolean): azdata.window.Dialog {
|
||||
return azdata.window.createModelViewDialog(title, dialogName, isWide);
|
||||
}
|
||||
|
||||
public createWizard(title: string): azdata.window.Wizard {
|
||||
return azdata.window.createWizard(title);
|
||||
}
|
||||
|
||||
public createWizardPage(title: string): azdata.window.WizardPage {
|
||||
return azdata.window.createWizardPage(title);
|
||||
}
|
||||
|
||||
public openDialog(dialog: azdata.window.Dialog): void {
|
||||
return azdata.window.openDialog(dialog);
|
||||
}
|
||||
|
||||
public getAllAccounts(): Thenable<azdata.Account[]> {
|
||||
return azdata.accounts.getAllAccounts();
|
||||
}
|
||||
|
||||
public getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Thenable<{ [key: string]: any }> {
|
||||
return azdata.accounts.getSecurityToken(account, resource);
|
||||
}
|
||||
|
||||
public showQuickPick<T extends vscode.QuickPickItem>(items: T[] | Thenable<T[]>, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable<T | undefined> {
|
||||
return vscode.window.showQuickPick(items, options, token);
|
||||
}
|
||||
|
||||
public showInputBox(options?: vscode.InputBoxOptions, token?: vscode.CancellationToken): Thenable<string | undefined> {
|
||||
return vscode.window.showInputBox(options, token);
|
||||
}
|
||||
|
||||
public listDatabases(connectionId: string): Thenable<string[]> {
|
||||
return azdata.connection.listDatabases(connectionId);
|
||||
}
|
||||
|
||||
public openTextDocument(options?: { language?: string; content?: string; }): Thenable<vscode.TextDocument> {
|
||||
return vscode.workspace.openTextDocument(options);
|
||||
}
|
||||
|
||||
public connect(fileUri: string, connectionId: string): Thenable<void> {
|
||||
return azdata.queryeditor.connect(fileUri, connectionId);
|
||||
}
|
||||
|
||||
public runQuery(fileUri: string, options?: Map<string, string>, runCurrentQuery?: boolean): void {
|
||||
azdata.queryeditor.runQuery(fileUri, options, runCurrentQuery);
|
||||
}
|
||||
|
||||
public showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable<vscode.TextEditor> {
|
||||
return vscode.window.showTextDocument(uri, options);
|
||||
}
|
||||
|
||||
public createButton(label: string, position?: azdata.window.DialogButtonPosition): azdata.window.Button {
|
||||
return azdata.window.createButton(label, position);
|
||||
}
|
||||
|
||||
public registerWidget(widgetId: string, handler: (view: azdata.ModelView) => void): void {
|
||||
azdata.ui.registerModelViewProvider(widgetId, handler);
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,36 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as templates from '../templates/templates';
|
||||
import * as constants from '../common/constants';
|
||||
import * as path from 'path';
|
||||
|
||||
import { Uri, Disposable, ExtensionContext, WorkspaceFolder } from 'vscode';
|
||||
import { ApiWrapper } from '../common/apiWrapper';
|
||||
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
|
||||
import { getErrorMessage } from '../common/utils';
|
||||
import { ProjectsController } from './projectController';
|
||||
import { BaseProjectTreeItem } from '../models/tree/baseTreeItem';
|
||||
import { NetCoreTool } from '../tools/netcoreTool';
|
||||
import { Project } from '../models/project';
|
||||
|
||||
const SQL_DATABASE_PROJECTS_VIEW_ID = 'sqlDatabaseProjectsView';
|
||||
|
||||
/**
|
||||
* The main controller class that initializes the extension
|
||||
*/
|
||||
export default class MainController implements vscode.Disposable {
|
||||
protected _context: vscode.ExtensionContext;
|
||||
export default class MainController implements Disposable {
|
||||
protected dbProjectTreeViewProvider: SqlDatabaseProjectTreeViewProvider = new SqlDatabaseProjectTreeViewProvider();
|
||||
protected projectsController: ProjectsController;
|
||||
protected netcoreTool: NetCoreTool;
|
||||
|
||||
public constructor(context: vscode.ExtensionContext) {
|
||||
this._context = context;
|
||||
this.projectsController = new ProjectsController(this.dbProjectTreeViewProvider);
|
||||
public constructor(private context: ExtensionContext, private apiWrapper: ApiWrapper) {
|
||||
this.projectsController = new ProjectsController(apiWrapper, this.dbProjectTreeViewProvider);
|
||||
this.netcoreTool = new NetCoreTool();
|
||||
}
|
||||
|
||||
public get extensionContext(): vscode.ExtensionContext {
|
||||
return this._context;
|
||||
public get extensionContext(): ExtensionContext {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
@@ -44,27 +44,26 @@ export default class MainController implements vscode.Disposable {
|
||||
|
||||
private async initializeDatabaseProjects(): Promise<void> {
|
||||
// init commands
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.new', async () => { await this.createNewProject(); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { await this.openProjectFromFile(); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.close', (node: BaseProjectTreeItem) => { this.projectsController.closeProject(node); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.properties', async (node: BaseProjectTreeItem) => { await vscode.window.showErrorMessage(`Properties not yet implemented: ${node.uri.path}`); }); // TODO
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.new', async () => { await this.createNewProject(); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.open', async () => { await this.openProjectFromFile(); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.close', (node: BaseProjectTreeItem) => { this.projectsController.closeProject(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.properties', async (node: BaseProjectTreeItem) => { await this.apiWrapper.showErrorMessage(`Properties not yet implemented: ${node.uri.path}`); }); // TODO
|
||||
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.build', async (node: BaseProjectTreeItem) => { await this.projectsController.build(node); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.deploy', async (node: BaseProjectTreeItem) => { await this.projectsController.deploy(node); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.import', async (node: BaseProjectTreeItem) => { await this.projectsController.import(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.build', async (node: BaseProjectTreeItem) => { await this.projectsController.build(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.deploy', async (node: BaseProjectTreeItem) => { await this.projectsController.deploy(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.import', async (node: BaseProjectTreeItem) => { await this.projectsController.import(node); });
|
||||
|
||||
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templates.script); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newTable', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templates.table); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newView', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templates.view); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node, templates.storedProcedure); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPrompt(node); });
|
||||
vscode.commands.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.script); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newTable', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.table); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newView', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.view); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.storedProcedure); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node); });
|
||||
this.apiWrapper.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); });
|
||||
|
||||
// init view
|
||||
this.extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider));
|
||||
this.extensionContext.subscriptions.push(this.apiWrapper.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider));
|
||||
|
||||
await templates.loadTemplates(path.join(this._context.extensionPath, 'resources', 'templates'));
|
||||
await templates.loadTemplates(path.join(this.context.extensionPath, 'resources', 'templates'));
|
||||
|
||||
// ensure .net core is installed
|
||||
this.netcoreTool.findOrInstallNetCore();
|
||||
@@ -80,7 +79,7 @@ export default class MainController implements vscode.Disposable {
|
||||
|
||||
filter[constants.sqlDatabaseProject] = ['sqlproj'];
|
||||
|
||||
let files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ filters: filter });
|
||||
let files: Uri[] | undefined = await this.apiWrapper.showOpenDialog({ filters: filter });
|
||||
|
||||
if (files) {
|
||||
for (const file of files) {
|
||||
@@ -89,48 +88,50 @@ export default class MainController implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
vscode.window.showErrorMessage(getErrorMessage(err));
|
||||
this.apiWrapper.showErrorMessage(getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SQL database project from a template, prompting the user for a name and location
|
||||
*/
|
||||
public async createNewProject(): Promise<void> {
|
||||
public async createNewProject(): Promise<Project | undefined> {
|
||||
try {
|
||||
let newProjName = await vscode.window.showInputBox({
|
||||
let newProjName = await this.apiWrapper.showInputBox({
|
||||
prompt: constants.newDatabaseProjectName,
|
||||
value: `DatabaseProject${this.projectsController.projects.length + 1}`
|
||||
// TODO: Smarter way to suggest a name. Easy if we prompt for location first, but that feels odd...
|
||||
});
|
||||
|
||||
newProjName = newProjName?.trim();
|
||||
|
||||
if (!newProjName) {
|
||||
// TODO: is this case considered an intentional cancellation (shouldn't warn) or an error case (should warn)?
|
||||
vscode.window.showErrorMessage(constants.projectNameRequired);
|
||||
return;
|
||||
this.apiWrapper.showErrorMessage(constants.projectNameRequired);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let selectionResult = await vscode.window.showOpenDialog({
|
||||
let selectionResult = await this.apiWrapper.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
defaultUri: vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0].uri : undefined
|
||||
defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined
|
||||
});
|
||||
|
||||
if (!selectionResult) {
|
||||
vscode.window.showErrorMessage(constants.projectLocationRequired);
|
||||
return;
|
||||
this.apiWrapper.showErrorMessage(constants.projectLocationRequired);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: what if the selected folder is outside the workspace?
|
||||
|
||||
const newProjFolderUri = (selectionResult as vscode.Uri[])[0];
|
||||
console.log(newProjFolderUri.fsPath);
|
||||
const newProjFilePath = await this.projectsController.createNewProject(newProjName as string, newProjFolderUri as vscode.Uri);
|
||||
await this.projectsController.openProject(vscode.Uri.file(newProjFilePath));
|
||||
const newProjFolderUri = (selectionResult as Uri[])[0];
|
||||
const newProjFilePath = await this.projectsController.createNewProject(newProjName as string, newProjFolderUri as Uri);
|
||||
return this.projectsController.openProject(Uri.file(newProjFilePath));
|
||||
}
|
||||
catch (err) {
|
||||
vscode.window.showErrorMessage(getErrorMessage(err));
|
||||
this.apiWrapper.showErrorMessage(getErrorMessage(err));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as constants from '../common/constants';
|
||||
import * as dataSources from '../models/dataSources/dataSources';
|
||||
@@ -11,6 +10,8 @@ import * as utils from '../common/utils';
|
||||
import * as UUID from 'vscode-languageclient/lib/utils/uuid';
|
||||
import * as templates from '../templates/templates';
|
||||
|
||||
import { Uri, QuickPickItem } from 'vscode';
|
||||
import { ApiWrapper } from '../common/apiWrapper';
|
||||
import { Project } from '../models/project';
|
||||
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
|
||||
import { promises as fs } from 'fs';
|
||||
@@ -26,7 +27,7 @@ export class ProjectsController {
|
||||
|
||||
projects: Project[] = [];
|
||||
|
||||
constructor(projTreeViewProvider: SqlDatabaseProjectTreeViewProvider) {
|
||||
constructor(private apiWrapper: ApiWrapper, projTreeViewProvider: SqlDatabaseProjectTreeViewProvider) {
|
||||
this.projectTreeViewProvider = projTreeViewProvider;
|
||||
}
|
||||
|
||||
@@ -35,29 +36,29 @@ export class ProjectsController {
|
||||
this.projectTreeViewProvider.load(this.projects);
|
||||
}
|
||||
|
||||
public async openProject(projectFile: vscode.Uri): Promise<Project> {
|
||||
public async openProject(projectFile: Uri): Promise<Project> {
|
||||
for (const proj of this.projects) {
|
||||
if (proj.projectFilePath === projectFile.fsPath) {
|
||||
vscode.window.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath));
|
||||
this.apiWrapper.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath));
|
||||
return proj;
|
||||
}
|
||||
}
|
||||
|
||||
// Read project file
|
||||
const newProject = new Project(projectFile.fsPath);
|
||||
await newProject.readProjFile();
|
||||
this.projects.push(newProject);
|
||||
|
||||
// Read datasources.json (if present)
|
||||
const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName);
|
||||
|
||||
try {
|
||||
// Read project file
|
||||
await newProject.readProjFile();
|
||||
this.projects.push(newProject);
|
||||
|
||||
// Read datasources.json (if present)
|
||||
const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName);
|
||||
|
||||
newProject.dataSources = await dataSources.load(dataSourcesFilePath);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof dataSources.NoDataSourcesFileError) {
|
||||
// TODO: prompt to create new datasources.json; for now, swallow
|
||||
console.log(`No ${constants.dataSourcesFileName} file found.`);
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
@@ -69,7 +70,7 @@ export class ProjectsController {
|
||||
return newProject;
|
||||
}
|
||||
|
||||
public async createNewProject(newProjName: string, folderUri: vscode.Uri, projectGuid?: string): Promise<string> {
|
||||
public async createNewProject(newProjName: string, folderUri: Uri, projectGuid?: string): Promise<string> {
|
||||
if (projectGuid && !UUID.isUUID(projectGuid)) {
|
||||
throw new Error(`Specified GUID is invalid: '${projectGuid}'`);
|
||||
}
|
||||
@@ -114,17 +115,17 @@ export class ProjectsController {
|
||||
|
||||
public async build(treeNode: BaseProjectTreeItem) {
|
||||
const project = this.getProjectContextFromTreeNode(treeNode);
|
||||
await vscode.window.showErrorMessage(`Build not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
await this.apiWrapper.showErrorMessage(`Build not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
}
|
||||
|
||||
public async deploy(treeNode: BaseProjectTreeItem) {
|
||||
const project = this.getProjectContextFromTreeNode(treeNode);
|
||||
await vscode.window.showErrorMessage(`Deploy not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
await this.apiWrapper.showErrorMessage(`Deploy not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
}
|
||||
|
||||
public async import(treeNode: BaseProjectTreeItem) {
|
||||
const project = this.getProjectContextFromTreeNode(treeNode);
|
||||
await vscode.window.showErrorMessage(`Import not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
await this.apiWrapper.showErrorMessage(`Import not yet implemented: ${project.projectFilePath}`); // TODO
|
||||
}
|
||||
|
||||
public async addFolderPrompt(treeNode: BaseProjectTreeItem) {
|
||||
@@ -135,26 +136,28 @@ export class ProjectsController {
|
||||
return; // user cancelled
|
||||
}
|
||||
|
||||
const relativeFolderPath = this.prependContextPath(treeNode, newFolderName);
|
||||
const relativeFolderPath = path.join(this.getRelativePath(treeNode), newFolderName);
|
||||
|
||||
await project.addFolderItem(relativeFolderPath);
|
||||
|
||||
this.refreshProjectsTree();
|
||||
}
|
||||
|
||||
public async addItemPrompt(treeNode: BaseProjectTreeItem, itemTypeName?: string) {
|
||||
const project = this.getProjectContextFromTreeNode(treeNode);
|
||||
public async addItemPromptFromNode(treeNode: BaseProjectTreeItem, itemTypeName?: string) {
|
||||
await this.addItemPrompt(this.getProjectContextFromTreeNode(treeNode), this.getRelativePath(treeNode), itemTypeName);
|
||||
}
|
||||
|
||||
public async addItemPrompt(project: Project, relativePath: string, itemTypeName?: string) {
|
||||
if (!itemTypeName) {
|
||||
let itemFriendlyNames: string[] = [];
|
||||
const items: QuickPickItem[] = [];
|
||||
|
||||
for (const itemType of templates.projectScriptTypes()) {
|
||||
itemFriendlyNames.push(itemType.friendlyName);
|
||||
items.push({ label: itemType.friendlyName });
|
||||
}
|
||||
|
||||
itemTypeName = await vscode.window.showQuickPick(itemFriendlyNames, {
|
||||
itemTypeName = (await this.apiWrapper.showQuickPick(items, {
|
||||
canPickMany: false
|
||||
});
|
||||
}))?.label;
|
||||
|
||||
if (!itemTypeName) {
|
||||
return; // user cancelled
|
||||
@@ -162,7 +165,9 @@ export class ProjectsController {
|
||||
}
|
||||
|
||||
const itemType = templates.projectScriptTypeMap()[itemTypeName.toLocaleLowerCase()];
|
||||
const itemObjectName = await this.promptForNewObjectName(itemType, project);
|
||||
let itemObjectName = await this.promptForNewObjectName(itemType, project);
|
||||
|
||||
itemObjectName = itemObjectName?.trim();
|
||||
|
||||
if (!itemObjectName) {
|
||||
return; // user cancelled
|
||||
@@ -171,11 +176,11 @@ export class ProjectsController {
|
||||
// TODO: file already exists?
|
||||
|
||||
const newFileText = this.macroExpansion(itemType.templateScript, { 'OBJECT_NAME': itemObjectName });
|
||||
const relativeFilePath = this.prependContextPath(treeNode, itemObjectName + '.sql');
|
||||
const relativeFilePath = path.join(relativePath, itemObjectName + '.sql');
|
||||
|
||||
const newEntry = await project.addScriptItem(relativeFilePath, newFileText);
|
||||
|
||||
vscode.commands.executeCommand('vscode.open', newEntry.fsUri);
|
||||
this.apiWrapper.executeCommand('vscode.open', newEntry.fsUri);
|
||||
|
||||
this.refreshProjectsTree();
|
||||
}
|
||||
@@ -216,7 +221,7 @@ export class ProjectsController {
|
||||
// TODO: ask project for suggested name that doesn't conflict
|
||||
const suggestedName = itemType.friendlyName.replace(new RegExp('\s', 'g'), '') + '1';
|
||||
|
||||
const itemObjectName = await vscode.window.showInputBox({
|
||||
const itemObjectName = await this.apiWrapper.showInputBox({
|
||||
prompt: constants.newObjectNamePrompt(itemType.friendlyName),
|
||||
value: suggestedName,
|
||||
});
|
||||
@@ -224,13 +229,8 @@ export class ProjectsController {
|
||||
return itemObjectName;
|
||||
}
|
||||
|
||||
private prependContextPath(treeNode: BaseProjectTreeItem, objectName: string): string {
|
||||
if (treeNode instanceof FolderNode) {
|
||||
return path.join(utils.trimUri(treeNode.root.uri, treeNode.uri), objectName);
|
||||
}
|
||||
else {
|
||||
return objectName;
|
||||
}
|
||||
private getRelativePath(treeNode: BaseProjectTreeItem): string {
|
||||
return treeNode instanceof FolderNode ? utils.trimUri(treeNode.root.uri, treeNode.uri) : '';
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import MainController from './controllers/mainController';
|
||||
import { ApiWrapper } from './common/apiWrapper';
|
||||
|
||||
let controllers: MainController[] = [];
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
||||
// Start the main controller
|
||||
const mainController = new MainController(context);
|
||||
const mainController = new MainController(context, new ApiWrapper());
|
||||
controllers.push(mainController);
|
||||
context.subscriptions.push(mainController);
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as xmldom from 'xmldom';
|
||||
import * as constants from '../common/constants';
|
||||
|
||||
import { Uri } from 'vscode';
|
||||
import { promises as fs } from 'fs';
|
||||
import { DataSource } from './dataSources/dataSources';
|
||||
import { getErrorMessage } from '../common/utils';
|
||||
|
||||
/**
|
||||
* Class representing a Project, and providing functions for operating on it
|
||||
@@ -35,14 +34,7 @@ export class Project {
|
||||
*/
|
||||
public async readProjFile() {
|
||||
const projFileText = await fs.readFile(this.projectFilePath);
|
||||
|
||||
try {
|
||||
this.projFileXmlDoc = new xmldom.DOMParser().parseFromString(projFileText.toString());
|
||||
}
|
||||
catch (err) {
|
||||
vscode.window.showErrorMessage(err);
|
||||
return;
|
||||
}
|
||||
this.projFileXmlDoc = new xmldom.DOMParser().parseFromString(projFileText.toString());
|
||||
|
||||
// find all folders and files to include
|
||||
|
||||
@@ -93,7 +85,7 @@ export class Project {
|
||||
}
|
||||
|
||||
private createProjectEntry(relativePath: string, entryType: EntryType): ProjectEntry {
|
||||
return new ProjectEntry(vscode.Uri.file(path.join(this.projectFolderPath, relativePath)), relativePath, entryType);
|
||||
return new ProjectEntry(Uri.file(path.join(this.projectFolderPath, relativePath)), relativePath, entryType);
|
||||
}
|
||||
|
||||
private findOrCreateItemGroup(containedTag?: string): any {
|
||||
@@ -134,21 +126,15 @@ export class Project {
|
||||
}
|
||||
|
||||
private async addToProjFile(entry: ProjectEntry) {
|
||||
try {
|
||||
switch (entry.type) {
|
||||
case EntryType.File:
|
||||
this.addFileToProjFile(entry.relativePath);
|
||||
break;
|
||||
case EntryType.Folder:
|
||||
this.addFolderToProjFile(entry.relativePath);
|
||||
}
|
||||
switch (entry.type) {
|
||||
case EntryType.File:
|
||||
this.addFileToProjFile(entry.relativePath);
|
||||
break;
|
||||
case EntryType.Folder:
|
||||
this.addFolderToProjFile(entry.relativePath);
|
||||
}
|
||||
|
||||
await this.serializeToProjFile(this.projFileXmlDoc);
|
||||
}
|
||||
catch (err) {
|
||||
vscode.window.showErrorMessage(getErrorMessage(err));
|
||||
return;
|
||||
}
|
||||
await this.serializeToProjFile(this.projFileXmlDoc);
|
||||
}
|
||||
|
||||
private async serializeToProjFile(projFileContents: any) {
|
||||
@@ -165,11 +151,11 @@ export class ProjectEntry {
|
||||
/**
|
||||
* Absolute file system URI
|
||||
*/
|
||||
fsUri: vscode.Uri;
|
||||
fsUri: Uri;
|
||||
relativePath: string;
|
||||
type: EntryType;
|
||||
|
||||
constructor(uri: vscode.Uri, relativePath: string, type: EntryType) {
|
||||
constructor(uri: Uri, relativePath: string, type: EntryType) {
|
||||
this.fsUri = uri;
|
||||
this.relativePath = relativePath;
|
||||
this.type = type;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as should from 'should';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as TypeMoq from 'typemoq';
|
||||
import * as vscode from 'vscode';
|
||||
import * as baselines from './baselines/baselines';
|
||||
import * as templates from '../templates/templates';
|
||||
import * as constants from '../common/constants';
|
||||
|
||||
import { createContext, TestContext } from './testContext';
|
||||
import MainController from '../controllers/mainController';
|
||||
import { shouldThrowSpecificError } from './testUtils';
|
||||
|
||||
let testContext: TestContext;
|
||||
|
||||
describe('MainController: main controller operations', function (): void {
|
||||
before(async function (): Promise<void> {
|
||||
testContext = createContext();
|
||||
await templates.loadTemplates(path.join(__dirname, '..', '..', 'resources', 'templates'));
|
||||
await baselines.loadBaselines();
|
||||
});
|
||||
|
||||
beforeEach(async function (): Promise<void> {
|
||||
testContext.apiWrapper.reset();
|
||||
});
|
||||
|
||||
it('Should create new project through MainController', async function (): Promise<void> {
|
||||
const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`);
|
||||
|
||||
testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('MyProjectName'));
|
||||
testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve([vscode.Uri.file(projFileDir)]));
|
||||
testContext.apiWrapper.setup(x => x.workspaceFolders()).returns(() => undefined);
|
||||
testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => {
|
||||
console.log(s);
|
||||
return Promise.resolve(s);
|
||||
});
|
||||
|
||||
const controller = new MainController(testContext.context, testContext.apiWrapper.object);
|
||||
const proj = await controller.createNewProject();
|
||||
|
||||
should(proj).not.equal(undefined);
|
||||
});
|
||||
|
||||
it('Should show error when no project name', async function (): Promise<void> {
|
||||
for (const name of ['', ' ', undefined]) {
|
||||
testContext.apiWrapper.reset();
|
||||
testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(name));
|
||||
testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); });
|
||||
|
||||
const controller = new MainController(testContext.context, testContext.apiWrapper.object);
|
||||
await shouldThrowSpecificError(async () => await controller.createNewProject(), constants.projectNameRequired, `case: '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Should show error when no location name', async function (): Promise<void> {
|
||||
testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('MyProjectName'));
|
||||
testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined));
|
||||
testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); });
|
||||
|
||||
const controller = new MainController(testContext.context, testContext.apiWrapper.object);
|
||||
await shouldThrowSpecificError(async () => await controller.createNewProject(), constants.projectLocationRequired);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import * as should from 'should';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as vscode from 'vscode';
|
||||
import * as TypeMoq from 'typemoq';
|
||||
import * as baselines from './baselines/baselines';
|
||||
import * as templates from '../templates/templates';
|
||||
import * as testUtils from './testUtils';
|
||||
@@ -14,15 +15,20 @@ import * as testUtils from './testUtils';
|
||||
import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider';
|
||||
import { ProjectsController } from '../controllers/projectController';
|
||||
import { promises as fs } from 'fs';
|
||||
import { createContext, TestContext } from './testContext';
|
||||
import { Project } from '../models/project';
|
||||
|
||||
let testContext: TestContext;
|
||||
|
||||
describe('ProjectsController: project controller operations', function (): void {
|
||||
before(async function () : Promise<void> {
|
||||
before(async function (): Promise<void> {
|
||||
testContext = createContext();
|
||||
await templates.loadTemplates(path.join(__dirname, '..', '..', 'resources', 'templates'));
|
||||
await baselines.loadBaselines();
|
||||
});
|
||||
|
||||
it('Should create new sqlproj file with correct values', async function (): Promise<void> {
|
||||
const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider());
|
||||
const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider());
|
||||
const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`);
|
||||
|
||||
const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575');
|
||||
@@ -38,11 +44,26 @@ describe('ProjectsController: project controller operations', function (): void
|
||||
const sqlProjPath = await testUtils.createTestSqlProj(baselines.openProjectFileBaseline, folderPath);
|
||||
await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath);
|
||||
|
||||
const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider());
|
||||
const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider());
|
||||
|
||||
const project = await projController.openProject(vscode.Uri.file(sqlProjPath));
|
||||
|
||||
should(project.files.length).equal(9); // detailed sqlproj tests in their own test file
|
||||
should(project.dataSources.length).equal(2); // detailed datasources tests in their own test file
|
||||
});
|
||||
|
||||
it('Should return silently when no object name provided', async function (): Promise<void> {
|
||||
for (const name of ['', ' ', undefined]) {
|
||||
testContext.apiWrapper.reset();
|
||||
testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(name));
|
||||
testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { console.log('we throwin'); throw new Error(s); });
|
||||
|
||||
const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider());
|
||||
const project = new Project('FakePath');
|
||||
|
||||
should(project.files.length).equal(0);
|
||||
await projController.addItemPrompt(new Project('FakePath'), '', templates.script);
|
||||
should(project.files.length).equal(0, 'Expected to return without throwing an exception or adding a file when an empty/undefined name is provided.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ describe('Templates: loading templates from disk', function (): void {
|
||||
});
|
||||
|
||||
it('Should throw error when attempting to use templates before loaded from file', async function (): Promise<void> {
|
||||
shouldThrowSpecificError(() => templates.projectScriptTypeMap(), 'Templates must be loaded from file before attempting to use.');
|
||||
shouldThrowSpecificError(() => templates.projectScriptTypes(), 'Templates must be loaded from file before attempting to use.');
|
||||
await shouldThrowSpecificError(() => templates.projectScriptTypeMap(), 'Templates must be loaded from file before attempting to use.');
|
||||
await shouldThrowSpecificError(() => templates.projectScriptTypes(), 'Templates must be loaded from file before attempting to use.');
|
||||
});
|
||||
|
||||
it('Should load all templates from files', async function (): Promise<void> {
|
||||
|
||||
39
extensions/sql-database-projects/src/test/testContext.ts
Normal file
39
extensions/sql-database-projects/src/test/testContext.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as TypeMoq from 'typemoq';
|
||||
import { ApiWrapper } from '../common/apiWrapper';
|
||||
|
||||
export interface TestContext {
|
||||
apiWrapper: TypeMoq.IMock<ApiWrapper>;
|
||||
context: vscode.ExtensionContext;
|
||||
}
|
||||
|
||||
export function createContext(): TestContext {
|
||||
let extensionPath = path.join(__dirname, '..', '..');
|
||||
|
||||
return {
|
||||
apiWrapper: TypeMoq.Mock.ofType(ApiWrapper),
|
||||
context: {
|
||||
subscriptions: [],
|
||||
workspaceState: {
|
||||
get: () => { return undefined; },
|
||||
update: () => { return Promise.resolve(); }
|
||||
},
|
||||
globalState: {
|
||||
get: () => { return Promise.resolve(); },
|
||||
update: () => { return Promise.resolve(); }
|
||||
},
|
||||
extensionPath: extensionPath,
|
||||
asAbsolutePath: () => { return ''; },
|
||||
storagePath: '',
|
||||
globalStoragePath: '',
|
||||
logPath: '',
|
||||
extensionUri: vscode.Uri.parse('')
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import { promises as fs } from 'fs';
|
||||
import should = require('should');
|
||||
import { AssertionError } from 'assert';
|
||||
|
||||
export function shouldThrowSpecificError(block: Function, expectedMessage: string) {
|
||||
export async function shouldThrowSpecificError(block: Function, expectedMessage: string, details?: string) {
|
||||
let succeeded = false;
|
||||
try {
|
||||
block();
|
||||
await block();
|
||||
succeeded = true;
|
||||
}
|
||||
catch (err) {
|
||||
@@ -22,7 +22,7 @@ export function shouldThrowSpecificError(block: Function, expectedMessage: strin
|
||||
}
|
||||
|
||||
if (succeeded) {
|
||||
throw new AssertionError({ message: 'Operation succeeded, but expected failure with exception: "' + expectedMessage + '"' });
|
||||
throw new AssertionError({ message: `Operation succeeded, but expected failure with exception: "${expectedMessage}".${details ? ' ' + details : ''}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user