diff --git a/extensions/sql-database-projects/src/common/apiWrapper.ts b/extensions/sql-database-projects/src/common/apiWrapper.ts new file mode 100644 index 0000000000..ed076477b4 --- /dev/null +++ b/extensions/sql-database-projects/src/common/apiWrapper.ts @@ -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 { + 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(command: string, ...rest: any[]): Thenable { + return vscode.commands.executeCommand(command, ...rest); + } + + public registerTaskHandler(taskId: string, handler: (profile: azdata.IConnectionProfile) => void): void { + azdata.tasks.registerTask(taskId, handler); + } + + public registerTreeDataProvider(viewId: string, treeDataProvider: vscode.TreeDataProvider): vscode.Disposable { + return vscode.window.registerTreeDataProvider(viewId, treeDataProvider); + } + + public getUriForConnection(connectionId: string): Thenable { + return azdata.connection.getUriForConnection(connectionId); + } + + public getProvider(providerId: string, providerType: azdata.DataProviderType): T { + return azdata.dataprotocol.getProvider(providerId, providerType); + } + + public showErrorMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showErrorMessage(message, ...items); + } + + public showInformationMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showInformationMessage(message, ...items); + } + + public showOpenDialog(options: vscode.OpenDialogOptions): Thenable { + return vscode.window.showOpenDialog(options); + } + + public startBackgroundOperation(operationInfo: azdata.BackgroundOperationInfo): void { + azdata.tasks.startBackgroundOperation(operationInfo); + } + + public openExternal(target: vscode.Uri): Thenable { + return vscode.env.openExternal(target); + } + + public getExtension(extensionId: string): vscode.Extension | 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 { + return azdata.accounts.getAllAccounts(); + } + + public getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Thenable<{ [key: string]: any }> { + return azdata.accounts.getSecurityToken(account, resource); + } + + public showQuickPick(items: T[] | Thenable, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable { + return vscode.window.showQuickPick(items, options, token); + } + + public showInputBox(options?: vscode.InputBoxOptions, token?: vscode.CancellationToken): Thenable { + return vscode.window.showInputBox(options, token); + } + + public listDatabases(connectionId: string): Thenable { + return azdata.connection.listDatabases(connectionId); + } + + public openTextDocument(options?: { language?: string; content?: string; }): Thenable { + return vscode.workspace.openTextDocument(options); + } + + public connect(fileUri: string, connectionId: string): Thenable { + return azdata.queryeditor.connect(fileUri, connectionId); + } + + public runQuery(fileUri: string, options?: Map, runCurrentQuery?: boolean): void { + azdata.queryeditor.runQuery(fileUri, options, runCurrentQuery); + } + + public showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable { + 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); + } +} diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index b4f08c9195..432ded0ba6 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -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 { // 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 { + public async createNewProject(): Promise { 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; } } diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 91e22b2393..1e971442a1 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -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 { + public async openProject(projectFile: Uri): Promise { 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 { + public async createNewProject(newProjName: string, folderUri: Uri, projectGuid?: string): Promise { 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 diff --git a/extensions/sql-database-projects/src/extension.ts b/extensions/sql-database-projects/src/extension.ts index dfd92eb254..2d55d3fa5e 100644 --- a/extensions/sql-database-projects/src/extension.ts +++ b/extensions/sql-database-projects/src/extension.ts @@ -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 { // Start the main controller - const mainController = new MainController(context); + const mainController = new MainController(context, new ApiWrapper()); controllers.push(mainController); context.subscriptions.push(mainController); diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 3bae437377..7270c515a9 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -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; diff --git a/extensions/sql-database-projects/src/test/mainController.test.ts b/extensions/sql-database-projects/src/test/mainController.test.ts new file mode 100644 index 0000000000..c43d972c1b --- /dev/null +++ b/extensions/sql-database-projects/src/test/mainController.test.ts @@ -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 { + testContext = createContext(); + await templates.loadTemplates(path.join(__dirname, '..', '..', 'resources', 'templates')); + await baselines.loadBaselines(); + }); + + beforeEach(async function (): Promise { + testContext.apiWrapper.reset(); + }); + + it('Should create new project through MainController', async function (): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 7f3e980cd5..caa675064e 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -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 { + before(async function (): Promise { + 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 { - 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 { + 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.'); + } + }); }); diff --git a/extensions/sql-database-projects/src/test/templates.test.ts b/extensions/sql-database-projects/src/test/templates.test.ts index 1b08e1e907..9229b6ae48 100644 --- a/extensions/sql-database-projects/src/test/templates.test.ts +++ b/extensions/sql-database-projects/src/test/templates.test.ts @@ -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 { - 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 { diff --git a/extensions/sql-database-projects/src/test/testContext.ts b/extensions/sql-database-projects/src/test/testContext.ts new file mode 100644 index 0000000000..fd637eef4f --- /dev/null +++ b/extensions/sql-database-projects/src/test/testContext.ts @@ -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; + 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('') + }, + }; +} diff --git a/extensions/sql-database-projects/src/test/testUtils.ts b/extensions/sql-database-projects/src/test/testUtils.ts index bb55c1a1ca..2ae53a5ec8 100644 --- a/extensions/sql-database-projects/src/test/testUtils.ts +++ b/extensions/sql-database-projects/src/test/testUtils.ts @@ -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 : ''}` }); } }