diff --git a/extensions/mssql/src/constants/constants.ts b/extensions/mssql/src/constants/constants.ts index 12f80be1e6..1385d53cbe 100644 --- a/extensions/mssql/src/constants/constants.ts +++ b/extensions/mssql/src/constants/constants.ts @@ -336,3 +336,15 @@ export const build = "build"; export const sqlProjBuildTaskType = "sqlproj-build"; export const msBuildProblemMatcher = "$msCompile"; export const buildDirectory = "BuildDirectory"; + +// DacFx task names (as reported by SQL Tools Service) +export const taskNameExportBacpac = "Export bacpac"; +export const taskNameExtractDacpac = "Extract dacpac"; +export const taskNameImportBacpac = "Import bacpac"; +export const taskNameDeployDacpac = "Deploy dacpac"; + +// DacFx operation IDs (as reported by SQL Tools Service via taskOperation field) +export const operationIdExportBacpac = "ExportOperation"; +export const operationIdExtractDacpac = "ExtractOperation"; +export const operationIdImportBacpac = "ImportOperation"; +export const operationIdDeployDacpac = "DeployOperation"; diff --git a/extensions/mssql/src/controllers/dacpacDialogWebviewController.ts b/extensions/mssql/src/controllers/dacpacDialogWebviewController.ts index 6a89f905ed..cd5992fac3 100644 --- a/extensions/mssql/src/controllers/dacpacDialogWebviewController.ts +++ b/extensions/mssql/src/controllers/dacpacDialogWebviewController.ts @@ -27,7 +27,6 @@ import { DatabaseNameValidationError, ConnectionMatcher, } from "../models/utils"; -import { PlatformInformation } from "../models/platform"; import { UserSurvey } from "../nps/userSurvey"; import { getErrorMessage } from "../utils/utils"; @@ -35,29 +34,9 @@ import { getErrorMessage } from "../utils/utils"; export const DACPAC_EXTENSION = ".dacpac"; export const BACPAC_EXTENSION = ".bacpac"; -// VS Code command constants -const REVEAL_FILE_IN_OS_COMMAND = "revealFileInOS"; - // View ID constant for NPS survey const DACPAC_DIALOG_VIEW_ID = "dacpacDialog"; -/** - * Gets the OS-specific localized text for the "Reveal/Open" file button - * @returns The appropriate localized string based on the operating system - */ -function getRevealInOsButtonText(): string { - const platformInfo = new PlatformInformation(process.platform, process.arch, undefined); - - if (platformInfo.isMacOS) { - return LocConstants.DacpacDialog.RevealInFinder; - } else if (platformInfo.isLinux) { - return LocConstants.DacpacDialog.OpenContainingFolder; - } else { - // Windows or any other platform - return LocConstants.DacpacDialog.RevealInExplorer; - } -} - /** * Controller for the DacpacDialog webview. * Manages DACPAC and BACPAC operations (Deploy, Extract, Import, Export) using the Data-tier Application Framework (DacFx). @@ -352,10 +331,6 @@ export class DacpacDialogWebviewController extends ReactWebviewPanelController< if (result.success) { this.logger.verbose("Deploy DACPAC operation completed successfully"); activity.end(ActivityStatus.Succeeded); - // Show success notification for Deploy operation - void this.vscodeWrapper.showInformationMessage( - LocConstants.DacpacDialog.DeploySuccessWithDatabase(params.databaseName), - ); // Prompt user for NPS survey feedback UserSurvey.getInstance().promptUserForNPSFeedback( `${DACPAC_DIALOG_VIEW_ID}_deploy`, @@ -417,22 +392,6 @@ export class DacpacDialogWebviewController extends ReactWebviewPanelController< if (result.success) { this.logger.verbose("Extract DACPAC operation completed successfully"); activity.end(ActivityStatus.Succeeded); - // Show success notification with OS-specific "Reveal/Open" button for Extract operation - const fileName = path.basename(params.packageFilePath); - const revealButtonText = getRevealInOsButtonText(); - void this.vscodeWrapper - .showInformationMessage( - LocConstants.DacpacDialog.ExtractSuccessWithFile(fileName), - revealButtonText, - ) - .then((selection) => { - if (selection === revealButtonText) { - void vscode.commands.executeCommand( - REVEAL_FILE_IN_OS_COMMAND, - vscode.Uri.file(params.packageFilePath), - ); - } - }); // Prompt user for NPS survey feedback UserSurvey.getInstance().promptUserForNPSFeedback( `${DACPAC_DIALOG_VIEW_ID}_extract`, @@ -492,10 +451,6 @@ export class DacpacDialogWebviewController extends ReactWebviewPanelController< if (result.success) { this.logger.verbose("Import BACPAC operation completed successfully"); activity.end(ActivityStatus.Succeeded); - // Show success notification for Import operation - void this.vscodeWrapper.showInformationMessage( - LocConstants.DacpacDialog.ImportSuccessWithDatabase(params.databaseName), - ); // Prompt user for NPS survey feedback UserSurvey.getInstance().promptUserForNPSFeedback( `${DACPAC_DIALOG_VIEW_ID}_import`, @@ -555,22 +510,6 @@ export class DacpacDialogWebviewController extends ReactWebviewPanelController< if (result.success) { this.logger.verbose("Export BACPAC operation completed successfully"); activity.end(ActivityStatus.Succeeded); - // Show success notification with OS-specific "Reveal/Open" button for Export operation - const fileName = path.basename(params.packageFilePath); - const revealButtonText = getRevealInOsButtonText(); - void this.vscodeWrapper - .showInformationMessage( - LocConstants.DacpacDialog.ExportSuccessWithFile(fileName), - revealButtonText, - ) - .then((selection) => { - if (selection === revealButtonText) { - void vscode.commands.executeCommand( - REVEAL_FILE_IN_OS_COMMAND, - vscode.Uri.file(params.packageFilePath), - ); - } - }); // Prompt user for NPS survey feedback UserSurvey.getInstance().promptUserForNPSFeedback( `${DACPAC_DIALOG_VIEW_ID}_export`, diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 07e79042d8..c5e33360f7 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -564,8 +564,12 @@ export default class MainController implements vscode.Disposable { this.sqlTasksService = new SqlTasksService( SqlToolsServerClient.instance, this._sqlDocumentService, + this._vscodeWrapper, + ); + this.dacFxService = new DacFxService( + SqlToolsServerClient.instance, + this.sqlTasksService, ); - this.dacFxService = new DacFxService(SqlToolsServerClient.instance); this.sqlProjectsService = new SqlProjectsService(SqlToolsServerClient.instance); this.schemaCompareService = new SchemaCompareService(SqlToolsServerClient.instance); this.tableExplorerService = new TableExplorerService(SqlToolsServerClient.instance); diff --git a/extensions/mssql/src/services/dacFxService.ts b/extensions/mssql/src/services/dacFxService.ts index f0d551c76a..2d4ea34c9a 100644 --- a/extensions/mssql/src/services/dacFxService.ts +++ b/extensions/mssql/src/services/dacFxService.ts @@ -7,9 +7,84 @@ import SqlToolsServiceClient from "../languageservice/serviceclient"; import * as dacFxContracts from "../models/contracts/dacFx/dacFxContracts"; import * as mssql from "vscode-mssql"; import { ExtractTarget, TaskExecutionMode } from "../sharedInterfaces/schemaCompare"; +import { SqlTasksService } from "./sqlTasksService"; +import * as path from "path"; +import * as vscode from "vscode"; +import * as Constants from "../constants/constants"; +import * as LocalizedConstants from "../constants/locConstants"; +import { PlatformInformation } from "../models/platform"; export class DacFxService implements mssql.IDacFxService { - constructor(private _client: SqlToolsServiceClient) {} + constructor( + private _client: SqlToolsServiceClient, + sqlTasksService: SqlTasksService, + ) { + this.registerTaskCompletionHandlers(sqlTasksService); + } + + /** + * Register task completion handlers for dacpac operations + */ + private registerTaskCompletionHandlers(sqlTasksService: SqlTasksService): void { + const platformInfo = new PlatformInformation(process.platform, process.arch, undefined); + + // Determine the OS-specific reveal button text + let revealButtonText: string; + if (platformInfo.isMacOS) { + revealButtonText = LocalizedConstants.DacpacDialog.RevealInFinder; + } else if (platformInfo.isLinux) { + revealButtonText = LocalizedConstants.DacpacDialog.OpenContainingFolder; + } else { + // Windows or any other platform + revealButtonText = LocalizedConstants.DacpacDialog.RevealInExplorer; + } + + // Register handler for Export BACPAC operation + sqlTasksService.registerCompletionHandler({ + operationName: Constants.operationIdExportBacpac, + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => { + const fileName = path.basename(targetLocation); + return LocalizedConstants.DacpacDialog.ExportSuccessWithFile(fileName); + }, + getActionButtonText: () => revealButtonText, + getActionCommand: () => "revealFileInOS", + getActionCommandArgs: (_taskInfo, targetLocation) => [vscode.Uri.file(targetLocation)], + }); + + // Register handler for Extract DACPAC operation + sqlTasksService.registerCompletionHandler({ + operationName: Constants.operationIdExtractDacpac, + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => { + const fileName = path.basename(targetLocation); + return LocalizedConstants.DacpacDialog.ExtractSuccessWithFile(fileName); + }, + getActionButtonText: () => revealButtonText, + getActionCommand: () => "revealFileInOS", + getActionCommandArgs: (_taskInfo, targetLocation) => [vscode.Uri.file(targetLocation)], + }); + + // Register handler for Import BACPAC operation + sqlTasksService.registerCompletionHandler({ + operationName: Constants.operationIdImportBacpac, + getTargetLocation: (taskInfo) => taskInfo.databaseName, + getSuccessMessage: (_taskInfo, databaseName) => { + return LocalizedConstants.DacpacDialog.ImportSuccessWithDatabase(databaseName); + }, + // No action button for database operations + }); + + // Register handler for Deploy DACPAC operation + sqlTasksService.registerCompletionHandler({ + operationName: Constants.operationIdDeployDacpac, + getTargetLocation: (taskInfo) => taskInfo.databaseName, + getSuccessMessage: (_taskInfo, databaseName) => { + return LocalizedConstants.DacpacDialog.DeploySuccessWithDatabase(databaseName); + }, + // No action button for database operations + }); + } public exportBacpac( databaseName: string, diff --git a/extensions/mssql/src/services/sqlTasksService.ts b/extensions/mssql/src/services/sqlTasksService.ts index 04733e68c0..e951413e37 100644 --- a/extensions/mssql/src/services/sqlTasksService.ts +++ b/extensions/mssql/src/services/sqlTasksService.ts @@ -10,6 +10,7 @@ import { Deferred } from "../protocol"; import * as localizedConstants from "../constants/locConstants"; import SqlDocumentService, { ConnectionStrategy } from "../controllers/sqlDocumentService"; import { TaskExecutionMode } from "../sharedInterfaces/schemaCompare"; +import VscodeWrapper from "../controllers/vscodeWrapper"; export enum TaskStatus { NotStarted = 0, @@ -39,6 +40,8 @@ export interface TaskInfo { description: string; providerName: string; isCancelable: boolean; + targetLocation: string; + operationName?: string; } namespace TaskStatusChangedNotification { @@ -65,16 +68,68 @@ type ActiveTaskInfo = { }; type ProgressCallback = (value: { message?: string; increment?: number }) => void; +/** + * Configuration for a custom task completion handler that shows a notification with an action button + */ +export interface TaskCompletionHandler { + /** + * The operation ID to handle (must match taskInfo.taskOperation from SQL Tools Service) + */ + operationName: string; + + /** + * Resolves the target location from the task info. + * For file operations, this might return taskInfo.targetLocation. + * For database operations, this might return taskInfo.databaseName. + * @param taskInfo The task information + * @returns The target location string, or undefined if not available + */ + getTargetLocation: (taskInfo: TaskInfo) => string | undefined; + + /** + * Gets the success message to display when the task completes successfully + * @param taskInfo The task information + * @param targetLocation The resolved target location + * @returns The localized success message to display + */ + getSuccessMessage: (taskInfo: TaskInfo, targetLocation: string) => string; + + /** + * Gets the action button text (e.g., "Reveal in Explorer") + * Optional - if not provided, no action button will be shown + * @returns The localized button text + */ + getActionButtonText?: () => string; + + /** + * Gets the VS Code command to execute when the action button is clicked + * Optional - required only if getActionButtonText is provided + * @returns The VS Code command ID + */ + getActionCommand?: () => string; + + /** + * Gets the command arguments to pass when executing the action + * Optional - required only if getActionButtonText is provided + * @param taskInfo The task information + * @param targetLocation The resolved target location + * @returns The command arguments + */ + getActionCommandArgs?: (taskInfo: TaskInfo, targetLocation: string) => unknown[]; +} + /** * A simple service that hooks into the SQL Task Service feature provided by SQL Tools Service. This handles detecting when * new tasks are started and displaying a progress notification for those tasks while they're running. */ export class SqlTasksService { private _activeTasks = new Map(); + private _completionHandlers = new Map(); constructor( private _client: SqlToolsServiceClient, private _sqlDocumentService: SqlDocumentService, + private _vscodeWrapper: VscodeWrapper, ) { this._client.onNotification(TaskCreatedNotification.type, (taskInfo) => this.handleTaskCreatedNotification(taskInfo), @@ -84,6 +139,16 @@ export class SqlTasksService { ); } + /** + * Registers a custom completion handler for a specific task type. + * When a task with the specified operation ID completes successfully, the handler will be invoked + * to show a custom notification with an action button. + * @param handler The task completion handler configuration + */ + public registerCompletionHandler(handler: TaskCompletionHandler): void { + this._completionHandlers.set(handler.operationName, handler); + } + private cancelTask(taskId: string): Thenable { const params: CancelTaskParams = { taskId, @@ -147,6 +212,11 @@ export class SqlTasksService { } if (isTaskCompleted(taskProgressInfo.status)) { + // Check if there's a custom completion handler registered for this task + const handler = taskInfo.taskInfo.operationName + ? this._completionHandlers.get(taskInfo.taskInfo.operationName) + : undefined; + // Task is completed, complete the progress notification and display a final toast informing the // user of the final status. this._activeTasks.delete(taskProgressInfo.taskId); @@ -156,21 +226,51 @@ export class SqlTasksService { taskInfo.completionPromise.resolve(); } - // Get the message to display, if the last status doesn't have a valid message then get the last valid one - const lastMessage = - (taskProgressInfo.message && - taskProgressInfo.message.toLowerCase() !== taskStatusString.toLowerCase()) ?? - taskInfo.lastMessage; - // Only include the message if it isn't the same as the task status string we already have - some (but not all) task status - // notifications include this string as the message - const taskMessage = lastMessage - ? localizedConstants.taskStatusWithNameAndMessage( - taskInfo.taskInfo.name, - taskStatusString, - lastMessage.toString(), - ) - : localizedConstants.taskStatusWithName(taskInfo.taskInfo.name, taskStatusString); - showCompletionMessage(taskProgressInfo.status, taskMessage); + const targetLocation = handler + ? handler.getTargetLocation(taskInfo.taskInfo) + : undefined; + if (taskProgressInfo.status === TaskStatus.Succeeded && handler && targetLocation) { + // Show custom notification with optional action button + const successMessage = handler.getSuccessMessage(taskInfo.taskInfo, targetLocation); + const actionButtonText = handler.getActionButtonText?.(); + + if (actionButtonText && handler.getActionCommand && handler.getActionCommandArgs) { + // Show notification with action button + void this._vscodeWrapper + .showInformationMessage(successMessage, actionButtonText) + .then((selection) => { + if (selection === actionButtonText) { + const command = handler.getActionCommand!(); + const args = handler.getActionCommandArgs!( + taskInfo.taskInfo, + targetLocation, + ); + void this._vscodeWrapper.executeCommand(command, ...args); + } + }); + } else { + // Show notification without action button + void this._vscodeWrapper.showInformationMessage(successMessage); + } + } else { + // Show generic completion message for tasks without custom handlers + const lastMessage = + taskProgressInfo.message.toLowerCase() !== taskStatusString.toLowerCase() + ? taskProgressInfo.message + : taskInfo.lastMessage; + + const taskMessage = lastMessage + ? localizedConstants.taskStatusWithNameAndMessage( + taskInfo.taskInfo.name, + taskStatusString, + lastMessage.toString(), + ) + : localizedConstants.taskStatusWithName( + taskInfo.taskInfo.name, + taskStatusString, + ); + this.showCompletionMessage(taskProgressInfo.status, taskMessage); + } if ( taskInfo.taskInfo.taskExecutionMode === TaskExecutionMode.script && taskProgressInfo.script @@ -198,6 +298,28 @@ export class SqlTasksService { taskInfo.progressCallback({ message: taskMessage }); } } + + /** + * Shows a message for a task with a different type of toast notification being used for + * different status types. + * Failed - Error notification + * Canceled or SucceededWithWarning - Warning notification + * All others - Information notification + * @param taskStatus The status of the task we're showing the message for + * @param message The message to show + */ + private showCompletionMessage(taskStatus: TaskStatus, message: string): void { + if (taskStatus === TaskStatus.Failed) { + void this._vscodeWrapper.showErrorMessage(message); + } else if ( + taskStatus === TaskStatus.Canceled || + taskStatus === TaskStatus.SucceededWithWarning + ) { + void this._vscodeWrapper.showWarningMessage(message); + } else { + void this._vscodeWrapper.showInformationMessage(message); + } + } } /** @@ -214,28 +336,6 @@ function isTaskCompleted(taskStatus: TaskStatus): boolean { ); } -/** - * Shows a message for a task with a different type of toast notification being used for - * different status types. - * Failed - Error notification - * Canceled or SucceededWithWarning - Warning notification - * All others - Information notification - * @param taskStatus The status of the task we're showing the message for - * @param message The message to show - */ -function showCompletionMessage(taskStatus: TaskStatus, message: string): void { - if (taskStatus === TaskStatus.Failed) { - vscode.window.showErrorMessage(message); - } else if ( - taskStatus === TaskStatus.Canceled || - taskStatus === TaskStatus.SucceededWithWarning - ) { - vscode.window.showWarningMessage(message); - } else { - vscode.window.showInformationMessage(message); - } -} - /** * Gets the string to display for the specified task status * @param taskStatus The task status to get the display string for diff --git a/extensions/mssql/test/unit/AGENTS.md b/extensions/mssql/test/unit/AGENTS.md index 9c1be5463e..3b1c563742 100644 --- a/extensions/mssql/test/unit/AGENTS.md +++ b/extensions/mssql/test/unit/AGENTS.md @@ -6,6 +6,7 @@ - Use Sinon, not TypeMoq. If easily possible, replace TypeMoq mocks/stubs/helpers with Sinon equivalents. - Use a Sinon sandbox (setup/teardown with sinon.createSandbox()); keep helper closures (e.g., createServer) inside setup where the sandbox is created. +- Use sandbox.restore() in teardown() to handle ALL cleanup. Don't use conditional stub checks (e.g., checking `.restore` before creating stubs) or manual restore calls. The sandbox handles everything automatically. - Use chai's `expect` for assertions; when checking Sinon interactions, use sinon-chai. Avoid `sinon.assert` and Node's `assert` in favor of `expect(...).to.have.been...` helpers. - Avoid Object.defineProperty hacks and (if possible) fake/partial plain objects; use sandbox.createStubInstance(type) and sandbox.stub(obj, 'prop').value(...). - Add shared Sinon helpers to test/unit/utils.ts when they’ll be reused. diff --git a/extensions/mssql/test/unit/dacFxService.test.ts b/extensions/mssql/test/unit/dacFxService.test.ts new file mode 100644 index 0000000000..a77d024b24 --- /dev/null +++ b/extensions/mssql/test/unit/dacFxService.test.ts @@ -0,0 +1,416 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from "sinon"; +import { expect } from "chai"; +import { DacFxService } from "../../src/services/dacFxService"; +import { SqlTasksService, TaskCompletionHandler } from "../../src/services/sqlTasksService"; +import SqlToolsServiceClient from "../../src/languageservice/serviceclient"; +import * as Constants from "../../src/constants/constants"; + +suite("DacFxService Tests", () => { + let sandbox: sinon.SinonSandbox; + let sqlToolsClientStub: sinon.SinonStubbedInstance; + let sqlTasksServiceStub: sinon.SinonStubbedInstance; + let registeredHandlers: Map; + + setup(() => { + sandbox = sinon.createSandbox(); + sqlToolsClientStub = sandbox.createStubInstance(SqlToolsServiceClient); + sqlTasksServiceStub = sandbox.createStubInstance(SqlTasksService); + registeredHandlers = new Map(); + + // Capture registered handlers + sqlTasksServiceStub.registerCompletionHandler.callsFake( + (handler: TaskCompletionHandler) => { + registeredHandlers.set(handler.operationName, handler); + }, + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite("Constructor and Handler Registration", () => { + test("should register all four task completion handlers during construction", () => { + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + + // Assert + expect(sqlTasksServiceStub.registerCompletionHandler).to.have.callCount(4); + expect(registeredHandlers.size).to.equal(4); + }); + + test("should register Export BACPAC handler with correct operation ID", () => { + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + + // Assert + expect(registeredHandlers.has(Constants.operationIdExportBacpac)).to.be.true; + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + expect(handler.operationName).to.equal(Constants.operationIdExportBacpac); + }); + + test("should register Extract DACPAC handler with correct operation ID", () => { + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + + // Assert + expect(registeredHandlers.has(Constants.operationIdExtractDacpac)).to.be.true; + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + expect(handler.operationName).to.equal(Constants.operationIdExtractDacpac); + }); + + test("should register Import BACPAC handler with correct operation ID", () => { + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + + // Assert + expect(registeredHandlers.has(Constants.operationIdImportBacpac)).to.be.true; + const handler = registeredHandlers.get(Constants.operationIdImportBacpac)!; + expect(handler.operationName).to.equal(Constants.operationIdImportBacpac); + }); + + test("should register Deploy DACPAC handler with correct operation ID", () => { + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + + // Assert + expect(registeredHandlers.has(Constants.operationIdDeployDacpac)).to.be.true; + const handler = registeredHandlers.get(Constants.operationIdDeployDacpac)!; + expect(handler.operationName).to.equal(Constants.operationIdDeployDacpac); + }); + }); + + suite("Export BACPAC Handler Configuration", () => { + test("should configure handler to get target location from taskInfo.targetLocation", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const mockTaskInfo: any = { + targetLocation: "/path/to/export.bacpac", + databaseName: "testDb", + }; + + // Act + const targetLocation = handler.getTargetLocation(mockTaskInfo); + + // Assert + expect(targetLocation).to.equal("/path/to/export.bacpac"); + }); + + test("should provide success message with file name for Export BACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + + // Act + const message = handler.getSuccessMessage({} as any, "C:\\exports\\database.bacpac"); + + // Assert + expect(message).to.include("database.bacpac"); + }); + + test("should provide action button text for Export BACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + + // Act + const buttonText = handler.getActionButtonText?.(); + + // Assert + expect(buttonText).to.exist; + expect(buttonText).to.be.a("string"); + }); + + test("should provide action command for Export BACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + + // Act + const command = handler.getActionCommand?.(); + + // Assert + expect(command).to.equal("revealFileInOS"); + }); + + test("should provide action command args with file URI for Export BACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + + // Act + const args = handler.getActionCommandArgs?.({} as any, "C:\\exports\\test.bacpac"); + + // Assert + expect(args).to.exist; + expect(args).to.be.an("array").with.lengthOf(1); + expect(args![0]).to.have.property("fsPath"); + }); + }); + + suite("Extract DACPAC Handler Configuration", () => { + test("should configure handler to get target location from taskInfo.targetLocation", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + const mockTaskInfo: any = { + targetLocation: "/path/to/extract.dacpac", + databaseName: "testDb", + }; + + // Act + const targetLocation = handler.getTargetLocation(mockTaskInfo); + + // Assert + expect(targetLocation).to.equal("/path/to/extract.dacpac"); + }); + + test("should provide success message with file name for Extract DACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Act + const message = handler.getSuccessMessage({} as any, "C:\\extracts\\database.dacpac"); + + // Assert + expect(message).to.include("database.dacpac"); + }); + + test("should provide action button text for Extract DACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Act + const buttonText = handler.getActionButtonText?.(); + + // Assert + expect(buttonText).to.exist; + expect(buttonText).to.be.a("string"); + }); + + test("should provide action command for Extract DACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Act + const command = handler.getActionCommand?.(); + + // Assert + expect(command).to.equal("revealFileInOS"); + }); + + test("should provide action command args with file URI for Extract DACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Act + const args = handler.getActionCommandArgs?.({} as any, "C:\\extracts\\test.dacpac"); + + // Assert + expect(args).to.exist; + expect(args).to.be.an("array").with.lengthOf(1); + expect(args![0]).to.have.property("fsPath"); + }); + }); + + suite("Import BACPAC Handler Configuration", () => { + test("should configure handler to get target location from taskInfo.databaseName", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdImportBacpac)!; + const mockTaskInfo: any = { + targetLocation: "/path/to/import.bacpac", + databaseName: "ImportedDatabase", + }; + + // Act + const targetLocation = handler.getTargetLocation(mockTaskInfo); + + // Assert + expect(targetLocation).to.equal("ImportedDatabase"); + }); + + test("should provide success message with database name for Import BACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdImportBacpac)!; + + // Act + const message = handler.getSuccessMessage({} as any, "MyDatabase"); + + // Assert + expect(message).to.include("MyDatabase"); + }); + + test("should not provide action button for Import BACPAC (database operation)", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdImportBacpac)!; + + // Act & Assert + expect(handler.getActionButtonText).to.be.undefined; + expect(handler.getActionCommand).to.be.undefined; + expect(handler.getActionCommandArgs).to.be.undefined; + }); + }); + + suite("Deploy DACPAC Handler Configuration", () => { + test("should configure handler to get target location from taskInfo.databaseName", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdDeployDacpac)!; + const mockTaskInfo: any = { + targetLocation: "/path/to/deploy.dacpac", + databaseName: "DeployedDatabase", + }; + + // Act + const targetLocation = handler.getTargetLocation(mockTaskInfo); + + // Assert + expect(targetLocation).to.equal("DeployedDatabase"); + }); + + test("should provide success message with database name for Deploy DACPAC", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdDeployDacpac)!; + + // Act + const message = handler.getSuccessMessage({} as any, "ProductionDB"); + + // Assert + expect(message).to.include("ProductionDB"); + }); + + test("should not provide action button for Deploy DACPAC (database operation)", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdDeployDacpac)!; + + // Act & Assert + expect(handler.getActionButtonText).to.be.undefined; + expect(handler.getActionCommand).to.be.undefined; + expect(handler.getActionCommandArgs).to.be.undefined; + }); + }); + + suite("Platform-Specific Reveal Button Text", () => { + let originalPlatform: string; + + setup(() => { + originalPlatform = process.platform; + }); + + teardown(() => { + // Restore original platform + Object.defineProperty(process, "platform", { + value: originalPlatform, + }); + }); + + test("should use 'Reveal in Explorer' text on Windows", () => { + // Arrange + Object.defineProperty(process, "platform", { + value: "win32", + }); + + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const buttonText = handler.getActionButtonText?.(); + + // Assert - on Windows, should contain "Explorer" + expect(buttonText).to.exist; + }); + + test("should use 'Reveal in Finder' text on macOS", () => { + // Arrange + Object.defineProperty(process, "platform", { + value: "darwin", + }); + + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const buttonText = handler.getActionButtonText?.(); + + // Assert - on macOS, should contain "Finder" + expect(buttonText).to.exist; + }); + + test("should use 'Open Containing Folder' text on Linux", () => { + // Arrange + Object.defineProperty(process, "platform", { + value: "linux", + }); + + // Act + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const handler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const buttonText = handler.getActionButtonText?.(); + + // Assert - on Linux, should contain "Folder" + expect(buttonText).to.exist; + }); + }); + + suite("Handler Consistency", () => { + test("file operation handlers should all have action buttons", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const exportHandler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const extractHandler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Assert + expect(exportHandler.getActionButtonText).to.exist; + expect(exportHandler.getActionCommand).to.exist; + expect(exportHandler.getActionCommandArgs).to.exist; + + expect(extractHandler.getActionButtonText).to.exist; + expect(extractHandler.getActionCommand).to.exist; + expect(extractHandler.getActionCommandArgs).to.exist; + }); + + test("database operation handlers should not have action buttons", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const importHandler = registeredHandlers.get(Constants.operationIdImportBacpac)!; + const deployHandler = registeredHandlers.get(Constants.operationIdDeployDacpac)!; + + // Assert + expect(importHandler.getActionButtonText).to.be.undefined; + expect(importHandler.getActionCommand).to.be.undefined; + expect(importHandler.getActionCommandArgs).to.be.undefined; + + expect(deployHandler.getActionButtonText).to.be.undefined; + expect(deployHandler.getActionCommand).to.be.undefined; + expect(deployHandler.getActionCommandArgs).to.be.undefined; + }); + + test("all file operation handlers should use same action command", () => { + // Arrange + new DacFxService(sqlToolsClientStub, sqlTasksServiceStub); + const exportHandler = registeredHandlers.get(Constants.operationIdExportBacpac)!; + const extractHandler = registeredHandlers.get(Constants.operationIdExtractDacpac)!; + + // Act + const exportCommand = exportHandler.getActionCommand?.(); + const extractCommand = extractHandler.getActionCommand?.(); + + // Assert + expect(exportCommand).to.equal(extractCommand); + expect(exportCommand).to.equal("revealFileInOS"); + }); + }); +}); diff --git a/extensions/mssql/test/unit/sqlTasksService.test.ts b/extensions/mssql/test/unit/sqlTasksService.test.ts new file mode 100644 index 0000000000..1ea3b6ecee --- /dev/null +++ b/extensions/mssql/test/unit/sqlTasksService.test.ts @@ -0,0 +1,484 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; +import * as sinon from "sinon"; +import { expect } from "chai"; +import { + SqlTasksService, + TaskStatus, + TaskInfo, + TaskProgressInfo, + TaskCompletionHandler, +} from "../../src/services/sqlTasksService"; +import SqlToolsServiceClient from "../../src/languageservice/serviceclient"; +import SqlDocumentService from "../../src/controllers/sqlDocumentService"; +import VscodeWrapper from "../../src/controllers/vscodeWrapper"; +import { TaskExecutionMode } from "../../src/sharedInterfaces/schemaCompare"; + +suite("SqlTasksService Tests", () => { + let sandbox: sinon.SinonSandbox; + let sqlTasksService: SqlTasksService; + let sqlToolsClientStub: sinon.SinonStubbedInstance; + let sqlDocumentServiceStub: sinon.SinonStubbedInstance; + let vscodeWrapperStub: sinon.SinonStubbedInstance; + let showInformationMessageStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let showWarningMessageStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + sqlToolsClientStub = sandbox.createStubInstance(SqlToolsServiceClient); + sqlDocumentServiceStub = sandbox.createStubInstance(SqlDocumentService); + vscodeWrapperStub = sandbox.createStubInstance(VscodeWrapper); + + showInformationMessageStub = vscodeWrapperStub.showInformationMessage; + showErrorMessageStub = vscodeWrapperStub.showErrorMessage; + showWarningMessageStub = vscodeWrapperStub.showWarningMessage; + executeCommandStub = vscodeWrapperStub.executeCommand; + sqlTasksService = new SqlTasksService( + sqlToolsClientStub, + sqlDocumentServiceStub, + vscodeWrapperStub, + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite("registerCompletionHandler", () => { + test("should register a completion handler", () => { + const handler: TaskCompletionHandler = { + operationName: "TestOperation", + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => `Success: ${targetLocation}`, + }; + + sqlTasksService.registerCompletionHandler(handler); + + // Verify handler is registered by triggering a task completion + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Test task", + description: "Test description", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "/path/to/file.bacpac", + operationName: "TestOperation", + }; + + // Simulate task created notification + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + taskCreatedHandler(taskInfo); + + // Simulate task completed notification + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Task completed", + }; + taskStatusChangedHandler(progressInfo); + + expect(showInformationMessageStub).to.have.been.calledOnce; + expect(showInformationMessageStub).to.have.been.calledWith( + "Success: /path/to/file.bacpac", + ); + }); + + test("should support multiple handlers for different operation IDs", async () => { + const handler1: TaskCompletionHandler = { + operationName: "ExportBacpac", + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => `Exported: ${targetLocation}`, + }; + + const handler2: TaskCompletionHandler = { + operationName: "DeployDacpac", + getTargetLocation: (taskInfo) => taskInfo.databaseName, + getSuccessMessage: (_taskInfo, databaseName) => `Deployed to: ${databaseName}`, + }; + + sqlTasksService.registerCompletionHandler(handler1); + sqlTasksService.registerCompletionHandler(handler2); + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + // Test handler 1 - Export bacpac + const exportTask: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Export bacpac", + description: "Export operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "/path/to/export.bacpac", + operationName: "ExportBacpac", + }; + + taskCreatedHandler(exportTask); + + const exportProgress: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Export completed", + }; + + await taskStatusChangedHandler(exportProgress); + + expect(showInformationMessageStub).to.have.been.calledWith( + "Exported: /path/to/export.bacpac", + ); + + // Reset stubs for second test + showInformationMessageStub.resetHistory(); + + // Test handler 2 - Deploy dacpac + const deployTask: TaskInfo = { + taskId: "task-2", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "target-database", + name: "Deploy dacpac", + description: "Deploy operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "/path/to/deploy.dacpac", + operationName: "DeployDacpac", + }; + + taskCreatedHandler(deployTask); + + const deployProgress: TaskProgressInfo = { + taskId: "task-2", + status: TaskStatus.Succeeded, + message: "Deploy completed", + }; + + await taskStatusChangedHandler(deployProgress); + + expect(showInformationMessageStub).to.have.been.calledWith( + "Deployed to: target-database", + ); + }); + }); + + suite("Task completion with action button", () => { + test("should show notification with action button when handler provides it", async () => { + const actionButtonText = "Reveal in Explorer"; + const targetFile = "/path/to/file.bacpac"; + + const handler: TaskCompletionHandler = { + operationName: "ExportBacpac", + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => `Exported to ${targetLocation}`, + getActionButtonText: () => "Reveal in Explorer", + getActionCommand: () => "revealFileInOS", + getActionCommandArgs: (_taskInfo, targetLocation) => [ + vscode.Uri.file(targetLocation), + ], + }; + + sqlTasksService.registerCompletionHandler(handler); + + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Export bacpac", + description: "Export operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: targetFile, + operationName: "ExportBacpac", + }; + + // Simulate task created and completed + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + showInformationMessageStub.resolves(actionButtonText); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Completed", + }; + + await taskStatusChangedHandler(progressInfo); + + expect(showInformationMessageStub).to.have.been.calledWith( + `Exported to ${targetFile}`, + actionButtonText, + ); + }); + + test("should execute command when action button is clicked", async () => { + const actionButtonText = "Reveal in Explorer"; + const targetFile = "/path/to/file.bacpac"; + + const handler: TaskCompletionHandler = { + operationName: "ExportBacpac", + getTargetLocation: (taskInfo) => taskInfo.targetLocation, + getSuccessMessage: (_taskInfo, targetLocation) => `Exported to ${targetLocation}`, + getActionButtonText: () => actionButtonText, + getActionCommand: () => "revealFileInOS", + getActionCommandArgs: (_taskInfo, targetLocation) => [ + vscode.Uri.file(targetLocation), + ], + }; + + sqlTasksService.registerCompletionHandler(handler); + + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Export bacpac", + description: "Export operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: targetFile, + operationName: "ExportBacpac", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + // Simulate user clicking the action button + showInformationMessageStub.callsFake(async (_message, ...items) => { + return items[0]; // Return the button that was clicked + }); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Completed", + }; + + await taskStatusChangedHandler(progressInfo); + + // Wait for promise chain to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandStub).to.have.been.calledWith( + "revealFileInOS", + sinon.match.instanceOf(vscode.Uri), + ); + }); + }); + + suite("Task completion without action button", () => { + test("should show notification without action button when handler doesn't provide it", async () => { + const handler: TaskCompletionHandler = { + operationName: "DeployDacpac", + getTargetLocation: (taskInfo) => taskInfo.databaseName, + getSuccessMessage: (_taskInfo, databaseName) => `Deployed to ${databaseName}`, + // No action button methods + }; + + sqlTasksService.registerCompletionHandler(handler); + + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "my-database", + name: "Deploy dacpac", + description: "Deploy operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "", + operationName: "DeployDacpac", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Completed", + }; + + await taskStatusChangedHandler(progressInfo); + + expect(showInformationMessageStub).to.have.been.calledWith("Deployed to my-database"); + expect(showInformationMessageStub).to.not.have.been.calledWith( + sinon.match.string, + sinon.match.string, + ); + }); + }); + + suite("Task completion without handler", () => { + test("should show generic completion message when no handler is registered", async () => { + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Some other task", + description: "Other task", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Completed", + }; + + await taskStatusChangedHandler(progressInfo); + + expect(showInformationMessageStub).to.have.been.calledOnce; + // Should show generic message with task name + expect(showInformationMessageStub.firstCall.args[0]).to.include("Some other task"); + }); + + test("should show error message for failed tasks", async () => { + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Failed task", + description: "Task that fails", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Failed, + message: "Task failed", + }; + + await taskStatusChangedHandler(progressInfo); + + expect(showErrorMessageStub).to.have.been.calledOnce; + expect(showErrorMessageStub.firstCall.args[0]).to.include("Failed task"); + }); + + test("should show warning message for canceled tasks", async () => { + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Canceled task", + description: "Task that is canceled", + providerName: "MSSQL", + isCancelable: true, + targetLocation: "", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Canceled, + message: "Task canceled", + }; + + await taskStatusChangedHandler(progressInfo); + + expect(showWarningMessageStub).to.have.been.calledOnce; + }); + }); + + suite("Task with undefined target location", () => { + test("should show generic message when handler returns undefined target location", async () => { + const handler: TaskCompletionHandler = { + operationName: "ExportBacpac", + getTargetLocation: (_taskInfo) => undefined, + getSuccessMessage: (_taskInfo, targetLocation) => `Exported to ${targetLocation}`, + }; + + sqlTasksService.registerCompletionHandler(handler); + + const taskInfo: TaskInfo = { + taskId: "task-1", + status: TaskStatus.InProgress, + taskExecutionMode: TaskExecutionMode.execute, + serverName: "test-server", + databaseName: "test-db", + name: "Export bacpac", + description: "Export operation", + providerName: "MSSQL", + isCancelable: false, + targetLocation: "", + operationName: "ExportBacpac", + }; + + const onNotificationStub = sqlToolsClientStub.onNotification as sinon.SinonStub; + const taskCreatedHandler = onNotificationStub.getCalls()[0].args[1]; + const taskStatusChangedHandler = onNotificationStub.getCalls()[1].args[1]; + + taskCreatedHandler(taskInfo); + + const progressInfo: TaskProgressInfo = { + taskId: "task-1", + status: TaskStatus.Succeeded, + message: "Completed", + }; + + await taskStatusChangedHandler(progressInfo); + + // Should fall back to generic message + expect(showInformationMessageStub).to.have.been.calledOnce; + expect(showInformationMessageStub.firstCall.args[0]).to.include("Export bacpac"); + }); + }); +});