diff --git a/projects/yeoman-ui/packages/backend/src/messages.ts b/projects/yeoman-ui/packages/backend/src/messages.ts index cf90a5576..2975ba05a 100644 --- a/projects/yeoman-ui/packages/backend/src/messages.ts +++ b/projects/yeoman-ui/packages/backend/src/messages.ts @@ -30,6 +30,10 @@ export default { `The '${artifactName}' project has been generated.`, show_progress_button: "Open Output View", show_progress_message: "Generating...", + progress_preparing: "Preparing...", + progress_writing_files: "Creating project files...", + progress_installing: "Installing dependencies...", + progress_finalising: "Finalising...", add_to_workspace: "Open the project in a multi-root workspace", open_in_a_new_workspace: "Open the project in a stand-alone", create_and_close: "Create the project and close it for future use", diff --git a/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts b/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts index 9a0c4bff9..0704e357a 100644 --- a/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts +++ b/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts @@ -60,7 +60,15 @@ export class VSCodeYouiEvents implements YouiEvents { private readonly rpc: IRpc; private webviewPanel: WebviewPanel; private readonly messages: any; - private resolveFunc: any; + private resolveFunc: (() => void) | undefined; + private progressReporter: { + report(value: { message?: string; increment?: number }): void; + } | null = null; + private currentProjectName: string | undefined; + private phaseStartTime: number = 0; + private currentPhase: "writing" | "install" | "end" | null = null; + private isEnhancedProgressMode: boolean = false; // Track if enhanced progress is active + private pendingPhaseTimer: NodeJS.Timeout | null = null; // Track pending timer to allow cancellation public output: GeneratorOutput; private readonly logger: IChildLogger; private readonly appWizard: AppWizard; @@ -88,27 +96,134 @@ export class VSCodeYouiEvents implements YouiEvents { void this.rpc.invoke("setBanner", [bannerProps]); } - public doGeneratorDone( + public async doGeneratorDone( success: boolean, message: string, selectedWorkspace: string, type: string, targetFolderPath?: string - ): void { + ): Promise { + // Cancel any pending phase transition timer + if (this.pendingPhaseTimer) { + clearTimeout(this.pendingPhaseTimer); + this.pendingPhaseTimer = null; + } + + // Show "Finalising..." before closing (only if enhanced progress mode is active) + if (this.progressReporter && this.isEnhancedProgressMode) { + this.progressReporter.report({ + message: this.messages.progress_finalising, + }); + // Add a brief delay so "Finalising..." is visible to users + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + this.resolveInstallingProgress(); set(this.webviewPanel, Constants.GENERATOR_COMPLETED, success); this.doClose(); - void this.showDoneMessage( + return this.showDoneMessage( success, message, selectedWorkspace, type, - targetFolderPath + targetFolderPath, + true // Skip resolving progress since we already did it ); } public doGeneratorInstall(): void { this.doClose(); - this.showInstallMessage(); + this.isEnhancedProgressMode = false; // Classic mode + // Classic mode: pass empty string to avoid duplicate message + this.showInstallMessage(undefined, ""); + } + + public doGeneratorProgress( + projectName: string | undefined, + phase: "writing" | "install" | "end", + showProgress: boolean = false + ): void { + // Check VS Code setting (default: true) + // Note: This setting is not declared in yeoman-ui's package.json - consumers + // (e.g., application-modeler, app-generator) declare it in their package.json + // if they want users to control it. The true default is safe - actual gating + // happens via the per-generator showProgress option passed by consumers. + const config = vscode.workspace.getConfiguration(); + const settingEnabled = config.get( + "ApplicationWizard.showGeneratorProgress", + true + ); + + // Backward compatibility: if setting is disabled OR generator doesn't opt in, + // fall back to classic behavior (only show toast on "install" phase) + if (!settingEnabled || !showProgress) { + if (phase === "install") { + // Show classic "Installing dependencies..." toast + this.doGeneratorInstall(); + } + return; + } + + // Enhanced mode: show multi-phase progress + const phaseMessages = { + writing: this.messages.progress_writing_files, + install: this.messages.progress_installing, + end: this.messages.progress_finalising, + }; + + const MIN_DURATIONS = { + writing: 2000, + install: 0, + end: 1000, + }; + + const message = phaseMessages[phase]; + + // If this is the first phase AND no progress notification exists yet + if (!this.progressReporter) { + // Close the webview panel (showing the question form) before showing progress + // Set GENERATOR_COMPLETED first to prevent false "manual close" telemetry + set(this.webviewPanel, Constants.GENERATOR_COMPLETED, true); + this.doClose(); + this.isEnhancedProgressMode = true; // Enhanced mode active + this.currentPhase = phase; + this.phaseStartTime = Date.now(); + this.showInstallMessage(projectName, message); + } else if (this.progressReporter) { + // Cancel any pending phase transition timer before scheduling a new one + if (this.pendingPhaseTimer) { + clearTimeout(this.pendingPhaseTimer); + this.pendingPhaseTimer = null; + } + + // Calculate time elapsed in current phase + const elapsed = Date.now() - this.phaseStartTime; + const minDuration = this.currentPhase + ? MIN_DURATIONS[this.currentPhase] + : 0; + const remainingTime = Math.max(0, minDuration - elapsed); + + if (remainingTime > 0) { + // Wait for minimum duration before showing next phase + // Capture current reporter instance to prevent race conditions + const currentReporter = this.progressReporter; + this.pendingPhaseTimer = setTimeout(() => { + // Clear the timer reference since it has fired + this.pendingPhaseTimer = null; + // Only update if reporter hasn't changed (generator still running) + if (this.progressReporter === currentReporter) { + this.progressReporter.report({ message }); + this.currentPhase = phase; + this.phaseStartTime = Date.now(); + } + }, remainingTime); + } else { + // Minimum duration already elapsed, update immediately + this.progressReporter.report({ message }); + this.currentPhase = phase; + this.phaseStartTime = Date.now(); + } + } } public getAppWizard(): AppWizard { @@ -182,16 +297,58 @@ export class VSCodeYouiEvents implements YouiEvents { } } - private showInstallMessage(): void { + private showInstallMessage( + projectName?: string, + initialMessage: string = this.messages.progress_preparing + ): void { + // Store project name for later use in success message + this.currentProjectName = projectName; + + // Determine if this is enhanced mode (any of the progress messages) + const isEnhancedMode = + initialMessage === this.messages.progress_preparing || + initialMessage === this.messages.progress_writing_files || + initialMessage === this.messages.progress_installing || + initialMessage === this.messages.progress_finalising; + + // Determine title: + // - Enhanced mode: "Generating {projectName}" or "Generating..." + // - Classic mode: "Installing dependencies..." + const title = isEnhancedMode + ? projectName + ? `Generating ${projectName}` + : "Generating..." + : "Installing dependencies..."; + void vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: "Installing dependencies...", + title: title, + cancellable: false, }, - async () => { - await new Promise((resolve) => { + async (progress) => { + // Store the progress reporter so we can update it (for new progress system) + this.progressReporter = progress; + // Classic mode: initialMessage is empty string, keep it empty to show title only + // Enhanced mode: initialMessage has a message, show it in body + progress.report({ message: initialMessage }); + + // Keep the notification open until generation completes + await new Promise((resolve) => { this.resolveFunc = resolve; }); + + // Clean up the progress reporter and reset state for next run + this.progressReporter = null; + this.isEnhancedProgressMode = false; + this.currentPhase = null; + this.phaseStartTime = 0; + this.currentProjectName = undefined; + // Clear any pending phase timer + if (this.pendingPhaseTimer) { + clearTimeout(this.pendingPhaseTimer); + this.pendingPhaseTimer = null; + } } ); } @@ -199,6 +356,7 @@ export class VSCodeYouiEvents implements YouiEvents { private resolveInstallingProgress() { if (this.resolveFunc) { this.resolveFunc(); + this.resolveFunc = undefined; // Clear to prevent reuse } } @@ -207,9 +365,12 @@ export class VSCodeYouiEvents implements YouiEvents { errorMmessage: string, selectedWorkspace: string, type: string, - targetFolderPath?: string + targetFolderPath?: string, + skipResolve: boolean = false ): Thenable { - this.resolveInstallingProgress(); + if (!skipResolve) { + this.resolveInstallingProgress(); + } if (success) { if (!isNil(targetFolderPath)) { @@ -319,17 +480,33 @@ export class VSCodeYouiEvents implements YouiEvents { selectedWorkspace: string, type: string ): string { - let successInfoMessage: string = this.messages.artifact_generated_files; + // Default message with project name if available + let successInfoMessage: string = this.currentProjectName + ? `Project ${this.currentProjectName} has been generated.` + : this.messages.artifact_generated_files; + if (type === "project") { - if (selectedWorkspace === this.messages.open_in_a_new_workspace) { - successInfoMessage = - this.messages.artifact_generated_project_open_in_a_new_workspace; - } else if (selectedWorkspace === this.messages.add_to_workspace) { - successInfoMessage = - this.messages.artifact_generated_project_add_to_workspace; + // For project type, use project name and add workspace-specific detail + if (this.currentProjectName) { + if (selectedWorkspace === this.messages.open_in_a_new_workspace) { + successInfoMessage = `Project ${this.currentProjectName} has been generated. The project will be opened in a new workspace.`; + } else if (selectedWorkspace === this.messages.add_to_workspace) { + successInfoMessage = `Project ${this.currentProjectName} has been generated. The project has been added to workspace.`; + } else { + successInfoMessage = `Project ${this.currentProjectName} has been generated.`; + } } else { - successInfoMessage = - this.messages.artifact_generated_project_saved_for_future; + // Fallback to original messages if no project name + if (selectedWorkspace === this.messages.open_in_a_new_workspace) { + successInfoMessage = + this.messages.artifact_generated_project_open_in_a_new_workspace; + } else if (selectedWorkspace === this.messages.add_to_workspace) { + successInfoMessage = + this.messages.artifact_generated_project_add_to_workspace; + } else { + successInfoMessage = + this.messages.artifact_generated_project_saved_for_future; + } } } else if (type === "module") { successInfoMessage = this.messages.artifact_generated_module; diff --git a/projects/yeoman-ui/packages/backend/src/webSocketServer/server-youi-events.ts b/projects/yeoman-ui/packages/backend/src/webSocketServer/server-youi-events.ts index a8416cfe5..ff8e349e7 100644 --- a/projects/yeoman-ui/packages/backend/src/webSocketServer/server-youi-events.ts +++ b/projects/yeoman-ui/packages/backend/src/webSocketServer/server-youi-events.ts @@ -30,20 +30,38 @@ export class ServerYouiEvents implements YouiEvents { selectedWorkspace: string, type: string, targetPath = "" - ): void { - void this.rpc.invoke("generatorDone", [ + ): Promise { + return this.rpc.invoke("generatorDone", [ suceeded, message, selectedWorkspace, type, targetPath, - ]); + ]) as Promise; } public doGeneratorInstall(): void { void this.rpc.invoke("generatorInstall"); } + public doGeneratorProgress( + projectName: string | undefined, + phase: "writing" | "install" | "end", + showProgress: boolean = false + ): void { + // Backward compatibility: if generator doesn't opt in, fall back to classic behavior + // (only show toast on "install" phase) + if (!showProgress) { + if (phase === "install") { + // Show classic "Installing dependencies..." event + this.doGeneratorInstall(); + } + return; + } + // WebSocket implementation - invoke RPC method with progress info + void this.rpc.invoke("generatorProgress", [projectName, phase]); + } + public showProgress(): void { void this.rpc.invoke("showProgress"); } diff --git a/projects/yeoman-ui/packages/backend/src/yeomanui.ts b/projects/yeoman-ui/packages/backend/src/yeomanui.ts index f7b82f595..365559c5f 100644 --- a/projects/yeoman-ui/packages/backend/src/yeomanui.ts +++ b/projects/yeoman-ui/packages/backend/src/yeomanui.ts @@ -268,13 +268,17 @@ export class YeomanUI { const destinationRoot = this.gen.destinationRoot(); const pathAfet = this.getGeneratorDestinationPath(destinationRoot); const dirsAfter = await this.getChildDirectories(pathAfet); - this.onGeneratorSuccess(generatorNamespace, dirsBefore, dirsAfter); + await this.onGeneratorSuccess( + generatorNamespace, + dirsBefore, + dirsAfter + ); } } catch (error) { if (error instanceof GeneratorNotFoundError) { vscode.window.showErrorMessage(error.message); } - this.onGeneratorFailure( + await this.onGeneratorFailure( generatorNamespace, this.getErrorWithAdditionalInfo(error, "runGenerator()") ); @@ -310,24 +314,24 @@ export class YeomanUI { const errorEventName = "error"; env.on(errorEventName, (error) => { env.removeAllListeners(errorEventName); - this.onGeneratorFailure( + void this.onGeneratorFailure( generatorName, this.getErrorWithAdditionalInfo(error, `env.on(${errorEventName})`) ); }); - gen.on(errorEventName, (error: any) => - this.onGeneratorFailure( + gen.on(errorEventName, (error: any): void => { + void this.onGeneratorFailure( generatorName, this.getErrorWithAdditionalInfo(error, `gen.on(${errorEventName})`) - ) - ); + ); + }); // when generator "restart" is selected, re-register the "uncaughtException" listener (with the updated context variables) this.onUncaughtException && process.removeListener("uncaughtException", this.onUncaughtException); this.onUncaughtException = (error) => { - this.onGeneratorFailure( + void this.onGeneratorFailure( generatorName, this.getErrorWithAdditionalInfo(error, "process.on(uncaughtException)") ); @@ -378,7 +382,7 @@ export class YeomanUI { this.getErrorWithAdditionalInfo(error, "evaluateMethod()"), questionInfo ); - this.onGeneratorFailure(this.generatorName, errorMessage); + void this.onGeneratorFailure(this.generatorName, errorMessage); } } @@ -484,7 +488,7 @@ export class YeomanUI { : `Step ${this.promptCount}`; } - private onGeneratorSuccess( + private async onGeneratorSuccess( generatorName: string, resourcesBeforeGen?: any, resourcesAfterGen?: any @@ -545,7 +549,7 @@ export class YeomanUI { ); AnalyticsWrapper.updateGeneratorEnded(generatorName); // when targetFolderPath is undefined and no files are generated, send type = '' to get the empty toast message - this.youiEvents.doGeneratorDone( + await this.youiEvents.doGeneratorDone( true, message, selectedWorkspace, @@ -557,7 +561,7 @@ export class YeomanUI { this.generatorName = ""; // reset generator name } - private onGeneratorFailure(generatorName: string, error: any) { + private async onGeneratorFailure(generatorName: string, error: any) { // avoid display the error multiple times due the same running if (this.errorThrown) { return; @@ -567,7 +571,7 @@ export class YeomanUI { const messagePrefix = `${generatorName} generator failed`; const errorMsg = error?.message || error; this.logError(error, messagePrefix); - this.youiEvents.doGeneratorDone( + await this.youiEvents.doGeneratorDone( false, `${messagePrefix} - ${errorMsg}`, "", @@ -579,8 +583,52 @@ export class YeomanUI { } private onGenInstall(gen: any) { + // Extract project name for progress notifications + const getProjectName = () => { + return ( + _.get(gen, "state.project.name") || + _.get(gen, "options.projectName") || + _.get(gen, "answers.projectName") || + _.get(gen, "answers.app.name") || + _.get(gen, "props.projectName") || + _.get(gen, "props.app.name") + ); + }; + + // Check if generator opts in to progress notifications + const showProgress = _.get(gen, "options.showGeneratorProgress", false); + + // ALWAYS register all three event handlers + // doGeneratorProgress handles backward compatibility internally + + // Listen to writing phase + gen.on("method:writing", () => { + const projectName = getProjectName(); + void this.youiEvents.doGeneratorProgress( + projectName, + "writing", + showProgress + ); + }); + + // Listen to install phase gen.on("method:install", () => { - this.youiEvents.doGeneratorInstall(); + const projectName = getProjectName(); + void this.youiEvents.doGeneratorProgress( + projectName, + "install", + showProgress + ); + }); + + // Listen to end phase + gen.on("method:end", () => { + const projectName = getProjectName(); + void this.youiEvents.doGeneratorProgress( + projectName, + "end", + showProgress + ); }); } diff --git a/projects/yeoman-ui/packages/backend/src/youi-adapter.ts b/projects/yeoman-ui/packages/backend/src/youi-adapter.ts index 5c73f2f92..4f8faa701 100644 --- a/projects/yeoman-ui/packages/backend/src/youi-adapter.ts +++ b/projects/yeoman-ui/packages/backend/src/youi-adapter.ts @@ -81,7 +81,7 @@ export class YouiAdapter { try { return await cb(result); // eslint-disable-line @typescript-eslint/await-thenable } catch (err) { - this.youiEvents.doGeneratorDone( + void this.youiEvents.doGeneratorDone( false, get(err, "message", "Template Wizard detected an error"), "", diff --git a/projects/yeoman-ui/packages/backend/src/youi-events.ts b/projects/yeoman-ui/packages/backend/src/youi-events.ts index fa134c236..97bfa9e5c 100644 --- a/projects/yeoman-ui/packages/backend/src/youi-events.ts +++ b/projects/yeoman-ui/packages/backend/src/youi-events.ts @@ -7,8 +7,13 @@ export interface YouiEvents { selectedWorkspace: string, type: string, targetFolderPath?: string - ): void; + ): Thenable; doGeneratorInstall(): void; + doGeneratorProgress( + projectName: string | undefined, + phase: "writing" | "install" | "end", + showProgress: boolean + ): void; showProgress(message?: string): void; getAppWizard(): AppWizard; executeCommand(id: string, ...args: any[]): Thenable; diff --git a/projects/yeoman-ui/packages/backend/test/vscode-youi-events.spec.ts b/projects/yeoman-ui/packages/backend/test/vscode-youi-events.spec.ts index 804cbdc35..beca55fcb 100644 --- a/projects/yeoman-ui/packages/backend/test/vscode-youi-events.spec.ts +++ b/projects/yeoman-ui/packages/backend/test/vscode-youi-events.spec.ts @@ -109,6 +109,10 @@ describe("vscode-youi-events unit test", () => { wsFileMockUri = vscode.Uri.file("/tmp/workspace.code-workspace"); sandbox.stub(WorkspaceFile, "createWsWithPath").returns(wsFileMockUri); sandbox.stub(WorkspaceFile, "createWsWithUri").returns(wsFileMockUri); + // Default stub for workspace.getConfiguration - returns true for showGeneratorProgress + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox.stub().returns(true), + } as any); }); afterEach(() => { @@ -263,14 +267,331 @@ describe("vscode-youi-events unit test", () => { it("doGeneratorInstall", () => { _.set(vscode, "ProgressLocation.Notification", 15); + let progressCallback: any; windowMock .expects("withProgress") .withArgs({ location: 15, title: "Installing dependencies...", + cancellable: false, }) - .resolves(); + .callsFake((options: any, callback: any) => { + progressCallback = callback; + return Promise.resolve(); + }); events.doGeneratorInstall(); + // Verify callback was captured + expect(progressCallback).to.not.be.undefined; + }); + + it("doGeneratorInstall - withProgress callback execution", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Installing dependencies...", + cancellable: false, + }) + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + const result = callback(progressReporter); + // Resolve after a brief delay to allow async code to run + setTimeout(() => { + events["resolveInstallingProgress"](); + }, 10); + return result; + }); + events.doGeneratorInstall(); + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + describe("doGeneratorProgress", () => { + it("backward compatibility: showProgress=false on install phase calls doGeneratorInstall", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Installing dependencies...", + cancellable: false, + }) + .resolves(); + + // Should fall back to classic behavior + events.doGeneratorProgress("testProject", "install", false); + }); + + it("backward compatibility: showProgress=false on writing phase does nothing", () => { + // Should not call doClose or withProgress + eventsMock.expects("doClose").never(); + windowMock.expects("withProgress").never(); + + events.doGeneratorProgress("testProject", "writing", false); + }); + + it("backward compatibility: showProgress=false on end phase does nothing", () => { + // Should not call doClose or withProgress + eventsMock.expects("doClose").never(); + windowMock.expects("withProgress").never(); + + events.doGeneratorProgress("testProject", "end", false); + }); + + it("backward compatibility: showProgress=true but setting disabled, falls back to classic on install", () => { + // Override the default stub to return false for the setting + (vscode.workspace.getConfiguration as any).returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(false), + }); + eventsMock.expects("doGeneratorInstall").once(); + + events.doGeneratorProgress("testProject", "install", true); + }); + + it("backward compatibility: showProgress=true but setting disabled, does nothing on writing", () => { + // Override the default stub to return false for the setting + (vscode.workspace.getConfiguration as any).returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(false), + }); + eventsMock.expects("doGeneratorInstall").never(); + windowMock.expects("withProgress").never(); + + events.doGeneratorProgress("testProject", "writing", true); + }); + + it("setting enabled: showProgress=true enters enhanced mode", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + // Default stub already returns true, so no need to override + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating testProject", + cancellable: false, + }) + .resolves(); + + events.doGeneratorProgress("testProject", "writing", true); + }); + + it("enhanced mode: showProgress=true on writing phase shows progress with project name", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating testProject", + cancellable: false, + }) + .resolves(); + + events.doGeneratorProgress("testProject", "writing", true); + }); + + it("enhanced mode: showProgress=true on writing phase with no project name", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating...", + cancellable: false, + }) + .resolves(); + + events.doGeneratorProgress(undefined, "writing", true); + }); + + it("enhanced mode: can start with install phase (not just writing)", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating testProject", + cancellable: false, + }) + .resolves(); + + // Starting with install phase should still initialize enhanced mode + events.doGeneratorProgress("testProject", "install", true); + }); + + it("enhanced mode: can start with end phase", () => { + _.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose").once(); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating testProject", + cancellable: false, + }) + .resolves(); + + // Starting with end phase should still initialize enhanced mode + events.doGeneratorProgress("testProject", "end", true); + }); + + it("enhanced mode: phase transition with remaining time - setTimeout path", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + let resolveProgress: any; + + windowMock + .expects("withProgress") + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + const result = callback(progressReporter); + resolveProgress = () => events["resolveInstallingProgress"](); + return result; + }); + + eventsMock.expects("doClose").once(); + + // Start with writing phase + events.doGeneratorProgress("testProject", "writing", true); + + // Wait for progress reporter to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Immediately transition to install phase (before minimum duration) + // This should trigger the setTimeout path (lines 180-191) + events.doGeneratorProgress("testProject", "install", true); + + // Wait for setTimeout to execute + await new Promise((resolve) => setTimeout(resolve, 2100)); + + // Verify progress.report was called with install message + expect(progressReporter.report.called).to.be.true; + + // Clean up + resolveProgress(); + }); + + it("enhanced mode: phase transition after minimum duration - immediate path", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + let resolveProgress: any; + + windowMock + .expects("withProgress") + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + const result = callback(progressReporter); + resolveProgress = () => events["resolveInstallingProgress"](); + return result; + }); + + eventsMock.expects("doClose").once(); + + // Start with install phase (minimum duration = 0) + events.doGeneratorProgress("testProject", "install", true); + + // Wait for progress reporter to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Transition to end phase - should take immediate path (lines 193-197) + events.doGeneratorProgress("testProject", "end", true); + + // Verify progress.report was called immediately + expect(progressReporter.report.called).to.be.true; + + // Clean up + resolveProgress(); + }); + + it("enhanced mode: cancels pending timer on new phase transition", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + let resolveProgress: any; + + windowMock + .expects("withProgress") + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + const result = callback(progressReporter); + resolveProgress = () => events["resolveInstallingProgress"](); + return result; + }); + + eventsMock.expects("doClose").once(); + + // Start with writing phase (minimum duration = 2000ms) + events.doGeneratorProgress("testProject", "writing", true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Transition to install (this schedules a timer) + events.doGeneratorProgress("testProject", "install", true); + + // Verify pendingPhaseTimer is set + expect(events["pendingPhaseTimer"]).to.not.be.null; + + // Immediately transition to end phase (should cancel previous timer) + events.doGeneratorProgress("testProject", "end", true); + + // Wait less than the original timer duration + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The timer should have been canceled and replaced + // Clean up + resolveProgress(); + }); + + it("enhanced mode: clears timer during cleanup in showInstallMessage", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + + windowMock + .expects("withProgress") + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + return callback(progressReporter).then(() => { + // Verify timer was cleared during cleanup + expect(events["pendingPhaseTimer"]).to.be.null; + }); + }); + + eventsMock.expects("doClose").once(); + + // Start with writing phase + events.doGeneratorProgress("testProject", "writing", true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Schedule a phase transition (creates a timer) + events.doGeneratorProgress("testProject", "install", true); + + // Verify timer exists + expect(events["pendingPhaseTimer"]).to.not.be.null; + + // Resolve the progress (triggers cleanup) + events["resolveInstallingProgress"](); + + await new Promise((resolve) => setTimeout(resolve, 10)); + }); }); it("setAppWizardHeaderTitle", () => { @@ -515,7 +836,7 @@ describe("vscode-youi-events unit test", () => { .resolves(); workspaceMock.expects("updateWorkspaceFolders").withArgs(0, null); - events.doGeneratorDone( + void events.doGeneratorDone( true, "success message", "Open the project in a multi-root workspace", @@ -539,7 +860,7 @@ describe("vscode-youi-events unit test", () => { .withArgs("vscode.openFolder") .resolves(); - events.doGeneratorDone( + void events.doGeneratorDone( true, "success message", "Open the project in a stand-alone", @@ -559,7 +880,7 @@ describe("vscode-youi-events unit test", () => { ) .resolves(); - events.doGeneratorDone( + void events.doGeneratorDone( true, "success message", "Create the project and close it for future use", @@ -641,6 +962,226 @@ describe("vscode-youi-events unit test", () => { "files" ); }); + + it("on success with currentProjectName - open in new workspace", () => { + // Simulate enhanced mode setting currentProjectName + events["currentProjectName"] = "MyTestProject"; + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs( + "Project MyTestProject has been generated. The project will be opened in a new workspace." + ) + .resolves(); + commandsMock.expects("executeCommand").withArgs("vscode.openFolder"); + uriMock.expects("file").once(); + return events.doGeneratorDone( + true, + "success message", + openNewWorkspace, + "project", + "testDestinationRoot" + ); + }); + + it("on success with currentProjectName - add to workspace", () => { + // Simulate enhanced mode setting currentProjectName + events["currentProjectName"] = "MyTestProject"; + eventsMock.expects("doClose"); + sandbox.stub(vscode.workspace, "workspaceFolders").value([]); + sandbox.stub(vscode.workspace, "workspaceFile").value(undefined); + windowMock + .expects("showInformationMessage") + .withExactArgs( + "Project MyTestProject has been generated. The project has been added to workspace." + ) + .resolves(); + workspaceMock.expects("updateWorkspaceFolders").withArgs(0, null); + commandsMock.expects("executeCommand").withArgs("vscode.openFolder"); + uriMock.expects("file").once(); + return events.doGeneratorDone( + true, + "success message", + addToWorkspace, + "project", + "testDestinationRoot" + ); + }); + + it("on success with currentProjectName - create and close", () => { + // Simulate enhanced mode setting currentProjectName + events["currentProjectName"] = "MyTestProject"; + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs("Project MyTestProject has been generated.") + .resolves(); + uriMock.expects("file").once(); + return events.doGeneratorDone( + true, + "success message", + createAndClose, + "project", + "testDestinationRoot" + ); + }); + + it("on success with type empty string - no message shown", () => { + eventsMock.expects("doClose"); + // When type is "", no information message should be shown + windowMock.expects("showInformationMessage").never(); + uriMock.expects("file").once(); + return events.doGeneratorDone( + true, + "success message", + createAndClose, + "", // Empty type + "testDestinationRoot" + ); + }); + + it("on success with type module", () => { + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_module) + .resolves(); + uriMock.expects("file").once(); + return events.doGeneratorDone( + true, + "success message", + createAndClose, + "module", + "testDestinationRoot" + ); + }); + + it("on failure - shows error message", () => { + eventsMock.expects("doClose"); + windowMock + .expects("showErrorMessage") + .withExactArgs("Error occurred during generation") + .resolves(); + return events.doGeneratorDone( + false, + "Error occurred during generation", + createAndClose, + "project", + "testDestinationRoot" + ); + }); + + it("on success with no targetFolderPath", () => { + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + // No targetFolderPath provided + return events.doGeneratorDone( + true, + "success message", + createAndClose, + "files", + undefined + ); + }); + + it("on success - enhanced mode shows Finalising message", async () => { + // Simulate enhanced mode by setting progressReporter and flag + const mockReporter = { report: sandbox.stub() }; + events["progressReporter"] = mockReporter; + events["isEnhancedProgressMode"] = true; + + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + + const result = events.doGeneratorDone( + true, + "success message", + createAndClose, + "files", + undefined + ); + + // Verify "Finalising..." was reported + await new Promise((resolve) => setTimeout(resolve, 50)); + expect( + mockReporter.report.calledWith({ + message: messages.default.progress_finalising, + }) + ).to.be.true; + + await result; + }); + + it("on success - cancels pending timer before showing Finalising", async () => { + _.set(vscode, "ProgressLocation.Notification", 15); + let progressReporter: any; + + windowMock + .expects("withProgress") + .callsFake((options: any, callback: any) => { + progressReporter = { + report: sandbox.stub(), + }; + return callback(progressReporter); + }); + + eventsMock.expects("doClose").twice(); // Once for initial progress, once for done + + // Start with writing phase (creates a long minimum duration) + events.doGeneratorProgress("testProject", "writing", true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Schedule a phase transition (creates a timer) + events.doGeneratorProgress("testProject", "install", true); + + // Verify timer exists + expect(events["pendingPhaseTimer"]).to.not.be.null; + + // Call doGeneratorDone - should cancel the timer + // Since currentProjectName is set to "testProject", expect that in the message + windowMock + .expects("showInformationMessage") + .withExactArgs("Project testProject has been generated.") + .resolves(); + uriMock.expects("file").once(); + + const result = events.doGeneratorDone( + true, + "success message", + createAndClose, + "project", + "testDestinationRoot" + ); + + // Verify timer was cleared + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events["pendingPhaseTimer"]).to.be.null; + + await result; + }); + + it("showDoneMessage with skipResolve=false calls resolveInstallingProgress", () => { + // Test private method directly to cover lines 339-340 + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + // Call private method with skipResolve=false (default) + return events["showDoneMessage"]( + true, + "success message", + createAndClose, + "files", + undefined, + false + ); + }); }); describe("getUniqueProjectName", () => { diff --git a/projects/yeoman-ui/packages/backend/test/webSocketServer/server-youi-events.spec.ts b/projects/yeoman-ui/packages/backend/test/webSocketServer/server-youi-events.spec.ts new file mode 100644 index 000000000..df6bab02b --- /dev/null +++ b/projects/yeoman-ui/packages/backend/test/webSocketServer/server-youi-events.spec.ts @@ -0,0 +1,158 @@ +import { expect } from "chai"; +import { createSandbox, SinonSandbox, SinonMock } from "sinon"; +import { RpcCommon } from "@sap-devx/webview-rpc/out.ext/rpc-common.js"; +import { ServerYouiEvents } from "../../src/webSocketServer/server-youi-events.js"; + +describe("ServerYouiEvents unit test", () => { + let events: ServerYouiEvents; + let sandbox: SinonSandbox; + let rpcMock: SinonMock; + + class TestRpc extends RpcCommon { + constructor() { + super({ + debug: () => {}, + error: () => {}, + fatal: () => {}, + warn: () => {}, + info: () => {}, + trace: () => {}, + getChildLogger: () => ({}), + } as any); + } + public sendRequest(): void { + return; + } + public sendResponse(): void { + return; + } + } + + const rpc = new TestRpc(); + + beforeEach(() => { + sandbox = createSandbox(); + events = new ServerYouiEvents(rpc); + rpcMock = sandbox.mock(rpc); + }); + + afterEach(() => { + rpcMock.verify(); + sandbox.restore(); + }); + + describe("doGeneratorProgress", () => { + it("backward compatibility: showProgress=false on install phase calls doGeneratorInstall", () => { + rpcMock.expects("invoke").withExactArgs("generatorInstall").once(); + + events.doGeneratorProgress("testProject", "install", false); + }); + + it("backward compatibility: showProgress=false on writing phase does nothing", () => { + rpcMock.expects("invoke").never(); + + events.doGeneratorProgress("testProject", "writing", false); + }); + + it("backward compatibility: showProgress=false on end phase does nothing", () => { + rpcMock.expects("invoke").never(); + + events.doGeneratorProgress("testProject", "end", false); + }); + + it("enhanced mode: showProgress=true on install phase invokes generatorProgress", () => { + rpcMock + .expects("invoke") + .withExactArgs("generatorProgress", ["testProject", "install"]) + .once(); + + events.doGeneratorProgress("testProject", "install", true); + }); + + it("enhanced mode: showProgress=true on writing phase invokes generatorProgress", () => { + rpcMock + .expects("invoke") + .withExactArgs("generatorProgress", ["testProject", "writing"]) + .once(); + + events.doGeneratorProgress("testProject", "writing", true); + }); + + it("enhanced mode: showProgress=true on end phase invokes generatorProgress", () => { + rpcMock + .expects("invoke") + .withExactArgs("generatorProgress", ["testProject", "end"]) + .once(); + + events.doGeneratorProgress("testProject", "end", true); + }); + + it("enhanced mode: handles undefined projectName", () => { + rpcMock + .expects("invoke") + .withExactArgs("generatorProgress", [undefined, "writing"]) + .once(); + + events.doGeneratorProgress(undefined, "writing", true); + }); + }); + + it("setAppWizardHeaderTitle", () => { + const title = "Test Title"; + const info = "Test Info"; + rpcMock.expects("invoke").withExactArgs("setHeaderTitle", [title, info]); + + events.setAppWizardHeaderTitle(title, info); + }); + + it("doGeneratorInstall", () => { + rpcMock.expects("invoke").withExactArgs("generatorInstall"); + + events.doGeneratorInstall(); + }); + + it("executeCommand", () => { + const result = events.executeCommand(); + expect(result).to.be.a("promise"); + }); + + it("getAppWizard", () => { + const result = events.getAppWizard(); + expect(result).to.be.null; + }); + + it("selectFolder", () => { + rpcMock.expects("invoke").withExactArgs("selectOutputFolder"); + + events.selectFolder(); + }); + + it("showProgress", () => { + rpcMock.expects("invoke").withExactArgs("showProgress"); + + events.showProgress(); + }); + + it("doGeneratorDone", async () => { + rpcMock + .expects("invoke") + .withExactArgs("generatorDone", [ + true, + "Success message", + "/workspace", + "project", + "/target", + ]) + .resolves(); + + const result = await events.doGeneratorDone( + true, + "Success message", + "/workspace", + "project", + "/target" + ); + + expect(result).to.be.undefined; + }); +}); diff --git a/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts b/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts index 780ea3345..4c8e63c29 100644 --- a/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts +++ b/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts @@ -62,12 +62,15 @@ describe("yeomanui unit test", () => { } const appWizard: AppWizard = new TestAppWizard(); class TestEvents implements YouiEvents { - public doGeneratorDone(): void { - return; + public doGeneratorDone(): Thenable { + return Promise.resolve(); } public doGeneratorInstall(): void { return; } + public doGeneratorProgress(): void { + return; + } public showProgress(): void { return; } @@ -1364,10 +1367,15 @@ describe("yeomanui unit test", () => { GeneratorFilter.create(), flowPromise.state ); - const gen: any = { on: () => "" }; + const gen: any = { + on: () => "", + state: { project: { name: "testProject" } }, + }; const genMock = sandbox.mock(gen); + genMock.expects("on").withArgs("method:writing"); genMock.expects("on").withArgs("method:install"); + genMock.expects("on").withArgs("method:end"); yeomanUiInstance["onGenInstall"](gen); genMock.verify(); }); @@ -1470,7 +1478,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("testGenName") .resolves(); - yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); + void yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); expect( doGeneratorDoneSpy.calledWith( true, @@ -1499,7 +1507,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("testGenName") .resolves(); - yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); + void yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); expect( doGeneratorDoneSpy.calledWith( true, @@ -1528,7 +1536,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("testGenName") .resolves(); - yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); + void yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); expect( doGeneratorDoneSpy.calledWith( true, @@ -1553,7 +1561,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("testGenName") .resolves(); - yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); + void yeomanUi["onGeneratorSuccess"]("testGenName", beforeGen, afterGen); expect( doGeneratorDoneSpy.calledWith( true, @@ -1570,7 +1578,7 @@ describe("yeomanui unit test", () => { }); it("onGeneratorFailure", () => { - yeomanUi["onGeneratorFailure"]("testGenName", "testError"); + void yeomanUi["onGeneratorFailure"]("testGenName", "testError"); expect( doGeneratorDoneSpy.calledWith( false, @@ -1596,7 +1604,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("foodq:app") .resolves(); - yeomanUi["onGeneratorSuccess"]("foodq:app", beforeGen, afterGen); + void yeomanUi["onGeneratorSuccess"]("foodq:app", beforeGen, afterGen); expect( doGeneratorDoneSpy.calledWith( true, @@ -1624,7 +1632,7 @@ describe("yeomanui unit test", () => { .expects("updateGeneratorEnded") .withArgs("fiori-generator:app") .resolves(); - yeomanUi["onGeneratorSuccess"]( + void yeomanUi["onGeneratorSuccess"]( "fiori-generator:app", beforeGen, afterGen diff --git a/projects/yeoman-ui/packages/backend/test/youi-adapter.spec.ts b/projects/yeoman-ui/packages/backend/test/youi-adapter.spec.ts index 01662d3f8..07f28ee68 100644 --- a/projects/yeoman-ui/packages/backend/test/youi-adapter.spec.ts +++ b/projects/yeoman-ui/packages/backend/test/youi-adapter.spec.ts @@ -16,12 +16,15 @@ import { createFlowPromise } from "../src/utils/promise.js"; describe("YouiAdapter", () => { class TestEvents implements YouiEvents { - public doGeneratorDone(): void { - return; + public doGeneratorDone(): Thenable { + return Promise.resolve(); } public doGeneratorInstall(): void { return; } + public doGeneratorProgress(): void { + return; + } public showProgress(): void { return; } diff --git a/projects/yeoman-ui/packages/frontend/src/youi/App.vue b/projects/yeoman-ui/packages/frontend/src/youi/App.vue index 075f29ea3..b0e5adba7 100644 --- a/projects/yeoman-ui/packages/frontend/src/youi/App.vue +++ b/projects/yeoman-ui/packages/frontend/src/youi/App.vue @@ -625,6 +625,19 @@ export default { this.doneStatus = true; this.isDone = true; }, + generatorProgress(projectName, phase) { + // Handle progress notifications for new multi-phase progress system + const phaseNames = { + writing: "Writing files", + install: "Installing", + end: "Finalising", + }; + this.currentPrompt.name = phaseNames[phase] || "Processing"; + this.doneMessage = phaseNames[phase] || "Processing"; + this.donePath = ""; + this.doneStatus = true; + this.isDone = true; + }, generatorDone(succeeded, message, targetPath) { this.currentPrompt.name = "Summary"; this.doneMessage = message; @@ -667,6 +680,7 @@ export default { "showPrompt", "setPromptList", "generatorInstall", + "generatorProgress", "generatorDone", "log", "updateGeneratorsPrompt", diff --git a/projects/yeoman-ui/packages/frontend/test/App.spec.js b/projects/yeoman-ui/packages/frontend/test/App.spec.js index ae61aa82d..7e0e28f1c 100644 --- a/projects/yeoman-ui/packages/frontend/test/App.spec.js +++ b/projects/yeoman-ui/packages/frontend/test/App.spec.js @@ -1148,6 +1148,57 @@ describe("App.vue", () => { }); }); + describe("generatorProgress", () => { + it("updates prompt with writing phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + wrapper.vm.rpc = { + invoke: jest.fn(), + }; + wrapper.vm.prompts = [{}, { questions: [] }]; + wrapper.vm.promptIndex = 1; + wrapper.vm.generatorProgress("testProject", "writing"); + expect(wrapper.vm.currentPrompt.name).toBe("Writing files"); + expect(wrapper.vm.doneMessage).toBe("Writing files"); + expect(wrapper.vm.doneStatus).toBe(true); + expect(wrapper.vm.isDone).toBe(true); + }); + + it("updates prompt with install phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + wrapper.vm.rpc = { + invoke: jest.fn(), + }; + wrapper.vm.prompts = [{}, { questions: [] }]; + wrapper.vm.promptIndex = 1; + wrapper.vm.generatorProgress("testProject", "install"); + expect(wrapper.vm.currentPrompt.name).toBe("Installing"); + expect(wrapper.vm.doneMessage).toBe("Installing"); + }); + + it("updates prompt with end phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + wrapper.vm.rpc = { + invoke: jest.fn(), + }; + wrapper.vm.prompts = [{}, { questions: [] }]; + wrapper.vm.promptIndex = 1; + wrapper.vm.generatorProgress("testProject", "end"); + expect(wrapper.vm.currentPrompt.name).toBe("Finalising"); + expect(wrapper.vm.doneMessage).toBe("Finalising"); + }); + + it("handles undefined project name", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + wrapper.vm.rpc = { + invoke: jest.fn(), + }; + wrapper.vm.prompts = [{}, { questions: [] }]; + wrapper.vm.promptIndex = 1; + wrapper.vm.generatorProgress(undefined, "writing"); + expect(wrapper.vm.currentPrompt.name).toBe("Writing files"); + }); + }); + describe("setGenInWriting", () => { // TODO - check the error it("in writing state", () => {