diff --git a/projects/yeoman-ui/packages/backend/package.json b/projects/yeoman-ui/packages/backend/package.json index 1593bed9..bc854b7c 100644 --- a/projects/yeoman-ui/packages/backend/package.json +++ b/projects/yeoman-ui/packages/backend/package.json @@ -1,7 +1,7 @@ { "name": "yeoman-ui", "displayName": "Application Wizard", - "version": "1.26.1", + "version": "1.27.0-local", "private": true, "description": "Provides rich user experience for Yeoman generators.", "categories": [ @@ -140,6 +140,18 @@ "default": true, "description": "Automatically update installed generators." }, + "ApplicationWizard.showGeneratorProgress": { + "type": "boolean", + "default": true, + "description": "Show progress notification during project generation with project name and phase updates.", + "scope": "resource" + }, + "ApplicationWizard.autoOpenApplicationInfoPage": { + "type": "boolean", + "default": true, + "description": "Launch 'Application Info Page' as soon as the application has been generated.", + "scope": "resource" + }, "ApplicationWizard.searchQuery": { "type": "array", "default": [ @@ -177,7 +189,9 @@ ] } }, - "activationEvents": [], + "activationEvents": [ + "*" + ], "dependencies": { "@sap-devx/webview-rpc": "0.4.1", "@sap-devx/yeoman-ui-types": "workspace:^", diff --git a/projects/yeoman-ui/packages/backend/src/messages.ts b/projects/yeoman-ui/packages/backend/src/messages.ts index cf90a557..2975ba05 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 9a0c4bff..9280eca7 100644 --- a/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts +++ b/projects/yeoman-ui/packages/backend/src/vscode-youi-events.ts @@ -25,6 +25,7 @@ import { getFileSchemeWorkspaceFolders } from "./utils/workspaceFolders.js"; const { isEmpty, isNil, set } = lodash; +// App Wizard wrapper that delegates to VSCodeYouiEvents class YoUiAppWizard extends AppWizard { constructor(private readonly events: VSCodeYouiEvents) { super(); @@ -60,7 +61,13 @@ 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; public output: GeneratorOutput; private readonly logger: IChildLogger; private readonly appWizard: AppWizard; @@ -88,27 +95,99 @@ 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 { + // Show "Finalising..." before closing + if (this.progressReporter) { + 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(); + public doGeneratorProgress( + projectName: string | undefined, + phase: "writing" | "install" | "end", + showProgress: boolean = false + ): void { + // Check VS Code setting (default: true) + const config = vscode.workspace.getConfiguration(); + const settingEnabled = config.get( + "ApplicationWizard.showGeneratorProgress", + true + ); + + // Only show if both the setting is enabled AND the generator opts in + if (!settingEnabled || !showProgress) { + return; // Don't show progress notification if disabled or not opted in + } + + // Map phases to localized messages + const phaseMessages = { + writing: this.messages.progress_writing_files, + install: this.messages.progress_installing, + end: this.messages.progress_finalising, + }; + + // Minimum duration for each phase (milliseconds) + const MIN_DURATIONS = { + writing: 2000, // 2 seconds + install: 0, // No minimum - let npm install take as long as it needs + end: 1000, // 1 second + }; + + const message = phaseMessages[phase]; + + // If this is the first phase (writing) AND no progress notification exists yet + if (phase === "writing" && !this.progressReporter) { + // Close the webview panel (showing the question form) before showing progress + this.doClose(); + this.currentPhase = phase; + this.phaseStartTime = Date.now(); + this.showInstallMessage(projectName, message); + } else if (this.progressReporter) { + // 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 + setTimeout(() => { + if (this.progressReporter) { + 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 +261,36 @@ 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; + + // Use "Generating {projectName}" as the title + const title = projectName + ? `Generating ${projectName}` + : "Application Generator"; + 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 + this.progressReporter = progress; + progress.report({ message: initialMessage }); + + // Keep the notification open until generation completes + await new Promise((resolve) => { this.resolveFunc = resolve; }); + + // Clean up the progress reporter + this.progressReporter = null; } ); } @@ -207,9 +306,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 +421,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 a8416cfe..5d286163 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,18 +30,27 @@ 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 { + // Only invoke if generator opts in (WebSocket doesn't have VS Code settings) + if (!showProgress) { + return; + } + // WebSocket implementation - invoke RPC method with progress info + void this.rpc.invoke("generatorProgress", [projectName, phase]); } public showProgress(): void { diff --git a/projects/yeoman-ui/packages/backend/src/yeomanui.ts b/projects/yeoman-ui/packages/backend/src/yeomanui.ts index f7b82f59..f092437a 100644 --- a/projects/yeoman-ui/packages/backend/src/yeomanui.ts +++ b/projects/yeoman-ui/packages/backend/src/yeomanui.ts @@ -545,7 +545,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( + void this.youiEvents.doGeneratorDone( true, message, selectedWorkspace, @@ -567,7 +567,7 @@ export class YeomanUI { const messagePrefix = `${generatorName} generator failed`; const errorMsg = error?.message || error; this.logError(error, messagePrefix); - this.youiEvents.doGeneratorDone( + void this.youiEvents.doGeneratorDone( false, `${messagePrefix} - ${errorMsg}`, "", @@ -579,8 +579,49 @@ export class YeomanUI { } private onGenInstall(gen: any) { + // Extract project name + 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); + + // 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 5c73f2f9..4f8faa70 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 fa134c23..41942415 100644 --- a/projects/yeoman-ui/packages/backend/src/youi-events.ts +++ b/projects/yeoman-ui/packages/backend/src/youi-events.ts @@ -7,8 +7,12 @@ export interface YouiEvents { selectedWorkspace: string, type: string, targetFolderPath?: string + ): Thenable; + doGeneratorProgress( + projectName: string | undefined, + phase: "writing" | "install" | "end", + showProgress?: boolean ): void; - doGeneratorInstall(): 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 804cbdc3..2e0ba730 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 @@ -1,8 +1,8 @@ import { vscode } from "./mockUtil.js"; import { expect } from "chai"; import { createSandbox, SinonSandbox, SinonMock } from "sinon"; -import _ from "lodash"; -import type { +import lodash from "lodash"; +import { IMethod, IPromiseCallbacks, IRpc, @@ -14,7 +14,10 @@ import { Constants } from "../src/utils/constants.js"; import * as loggerWrapper from "../src/logger/logger-wrapper.js"; import { VSCodeYouiEvents } from "../src/vscode-youi-events.js"; import { WorkspaceFile } from "../src/utils/workspaceFile.js"; -import * as fs from "fs"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const fs = require("fs"); describe("vscode-youi-events unit test", () => { let events: VSCodeYouiEvents; @@ -28,7 +31,6 @@ describe("vscode-youi-events unit test", () => { let loggerMock: SinonMock; let uriMock: SinonMock; let fsMock: SinonMock; - let wsFileMockUri: any; const testLogger = { debug: () => true, @@ -81,7 +83,6 @@ describe("vscode-youi-events unit test", () => { const generatorOutput = new GeneratorOutput(); before(() => { - sandbox = createSandbox(); loggerWrapper.internalApi.setLogger(testLogger); }); @@ -90,6 +91,7 @@ describe("vscode-youi-events unit test", () => { }); beforeEach(() => { + sandbox = createSandbox(); const webViewPanel: any = { dispose: () => true }; events = new VSCodeYouiEvents( rpc, @@ -106,9 +108,6 @@ describe("vscode-youi-events unit test", () => { rpcMock = sandbox.mock(rpc); uriMock = sandbox.mock(vscode.Uri); fsMock = sandbox.mock(fs); - wsFileMockUri = vscode.Uri.file("/tmp/workspace.code-workspace"); - sandbox.stub(WorkspaceFile, "createWsWithPath").returns(wsFileMockUri); - sandbox.stub(WorkspaceFile, "createWsWithUri").returns(wsFileMockUri); }); afterEach(() => { @@ -122,8 +121,6 @@ describe("vscode-youi-events unit test", () => { uriMock.verify(); fsMock.verify(); sandbox.restore(); - sandbox = createSandbox(); - loggerWrapper.internalApi.setLogger(testLogger); }); describe("getAppWizard", () => { @@ -249,6 +246,29 @@ describe("vscode-youi-events unit test", () => { ]); appWizard.showInformation(message, MessageType.prompt); }); + + it("setHeaderTitle via AppWizard", () => { + const appWizard = events.getAppWizard(); + rpcMock + .expects("invoke") + .withExactArgs("setHeaderTitle", ["Test Title", "Test Info"]); + appWizard.setHeaderTitle("Test Title", "Test Info"); + }); + + it("setBanner via AppWizard", () => { + const appWizard = events.getAppWizard(); + const bannerProps: IBannerProps = { + text: "Test Banner", + ariaLabel: "Test Label", + action: { + text: "Learn More", + url: "https://example.com", + }, + triggerActionFrom: "link", + }; + rpcMock.expects("invoke").withExactArgs("setBanner", [bannerProps]); + appWizard.setBanner(bannerProps); + }); }); it("executeCommand", () => { @@ -261,16 +281,269 @@ describe("vscode-youi-events unit test", () => { return events.executeCommand(commandId, commandArgs); }); - it("doGeneratorInstall", () => { - _.set(vscode, "ProgressLocation.Notification", 15); - windowMock - .expects("withProgress") - .withArgs({ - location: 15, - title: "Installing dependencies...", - }) - .resolves(); - events.doGeneratorInstall(); + describe("doGeneratorProgress", () => { + it("writing phase - initializes notification with project name", () => { + const projectName = "testProject"; + lodash.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose"); + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Generating testProject", + cancellable: false, + }) + .resolves(); + events.doGeneratorProgress(projectName, "writing", true); + }); + + it("writing phase - uses default title when no project name", () => { + lodash.set(vscode, "ProgressLocation.Notification", 15); + eventsMock.expects("doClose"); + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + windowMock + .expects("withProgress") + .withArgs({ + location: 15, + title: "Application Generator", + cancellable: false, + }) + .resolves(); + events.doGeneratorProgress(undefined, "writing", true); + }); + + it("install phase - updates progress message", () => { + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + const mockProgressReporter = { + report: sandbox.stub(), + }; + events["progressReporter"] = mockProgressReporter; + + events.doGeneratorProgress("testProject", "install", true); + + // Should be called with the install message after delay + expect(mockProgressReporter.report.called).to.be.true; + expect(mockProgressReporter.report.firstCall.args[0]).to.deep.equal({ + message: messages.default.progress_installing, + }); + + events["progressReporter"] = null; + }); + + it("end phase - updates progress message", () => { + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + const mockProgressReporter = { + report: sandbox.stub(), + }; + events["progressReporter"] = mockProgressReporter; + + events.doGeneratorProgress("testProject", "end", true); + + // Should be called with the end message + expect(mockProgressReporter.report.called).to.be.true; + expect(mockProgressReporter.report.firstCall.args[0]).to.deep.equal({ + message: messages.default.progress_finalising, + }); + + events["progressReporter"] = null; + }); + + it("install/end phases - does nothing when progressReporter is null", () => { + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + events["progressReporter"] = null; + + // Should not throw when progressReporter is null + events.doGeneratorProgress("testProject", "install", true); + events.doGeneratorProgress("testProject", "end", true); + }); + + it("does nothing when setting is disabled", () => { + // Stub getConfiguration to return false + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(false), + } as any); + + // Should not call doClose or showInstallMessage + eventsMock.expects("doClose").never(); + windowMock.expects("withProgress").never(); + + events.doGeneratorProgress("testProject", "writing", true); + events.doGeneratorProgress("testProject", "install", true); + events.doGeneratorProgress("testProject", "end", true); + }); + + it("does nothing when showProgress parameter is false", () => { + // Even if setting is enabled, showProgress=false should skip everything + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + + // Should not call doClose or showInstallMessage + eventsMock.expects("doClose").never(); + windowMock.expects("withProgress").never(); + + events.doGeneratorProgress("testProject", "writing", false); + events.doGeneratorProgress("testProject", "install", false); + events.doGeneratorProgress("testProject", "end", false); + }); + + it("writing phase with existing progressReporter - does not call doClose", () => { + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + + const mockProgressReporter = { + report: sandbox.stub(), + }; + events["progressReporter"] = mockProgressReporter; + + // Should not call doClose when progressReporter already exists + eventsMock.expects("doClose").never(); + + events.doGeneratorProgress("testProject", "writing", true); + + // Should update the progress reporter + expect(mockProgressReporter.report.called).to.be.true; + expect(mockProgressReporter.report.firstCall.args[0]).to.deep.equal({ + message: messages.default.progress_writing_files, + }); + + events["progressReporter"] = null; + }); + + it("phases fire in correct order without race condition", async () => { + // Stub getConfiguration to enable progress notification + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + + // Stub WorkspaceFile methods + sandbox + .stub(WorkspaceFile, "createWsWithPath") + .returns(vscode.Uri.file("mocked")); + sandbox + .stub(WorkspaceFile, "createWsWithUri") + .returns(vscode.Uri.file("mocked")); + + const reportCalls: string[] = []; + const mockProgressReporter = { + report: sandbox.stub().callsFake((args: any) => { + reportCalls.push(args.message); + }), + }; + + // Stub withProgress to track all report calls + windowMock + .expects("withProgress") + .callsFake((_options: any, callback: any) => { + callback(mockProgressReporter); + return Promise.resolve(); + }); + + // Fire writing phase (creates progress notification) + events.doGeneratorProgress("testProject", "writing", true); + + // Simulate rapid install and end phases (as yeoman emits them) + events["progressReporter"] = mockProgressReporter; + events["currentPhase"] = "writing"; + events["phaseStartTime"] = Date.now(); + + events.doGeneratorProgress("testProject", "install", true); + events.doGeneratorProgress("testProject", "end", true); + + // Wait for all setTimeout delays to complete (2000ms for writing + 0ms for install + 1000ms for end) + await new Promise((resolve) => setTimeout(resolve, 3500)); + + // Verify messages appear in correct order + expect(reportCalls).to.have.lengthOf(3); + expect(reportCalls[0]).to.equal(messages.default.progress_writing_files); + expect(reportCalls[1]).to.equal(messages.default.progress_installing); + expect(reportCalls[2]).to.equal(messages.default.progress_finalising); + + events["progressReporter"] = null; + }); + + it("resolves progress notification properly when generation completes", async () => { + // Stub getConfiguration + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: sandbox + .stub() + .withArgs("ApplicationWizard.showGeneratorProgress", true) + .returns(true), + } as any); + + // Stub WorkspaceFile methods + sandbox + .stub(WorkspaceFile, "createWsWithPath") + .returns(vscode.Uri.file("mocked")); + + windowMock + .expects("withProgress") + .callsFake((_options: any, callback: any) => { + return callback({ + report: sandbox.stub(), + }); + }); + + // Start progress notification + events.doGeneratorProgress("testProject", "writing", true); + + // Verify resolveFunc was set + expect(events["resolveFunc"]).to.not.be.undefined; + + // Call resolveInstallingProgress (simulating doGeneratorDone) + events["resolveInstallingProgress"](); + + // Wait a tick for the promise to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + // progressReporter should be cleaned up after resolution + expect(events["progressReporter"]).to.be.null; + }); }); it("setAppWizardHeaderTitle", () => { @@ -489,6 +762,10 @@ describe("vscode-youi-events unit test", () => { .withArgs("vscode.openFolder") .resolves(); workspaceMock.expects("updateWorkspaceFolders").withArgs(0, null); + // Stub WorkspaceFile.createWsWithPath to prevent filesystem writes in CI + sandbox + .stub(WorkspaceFile, "createWsWithPath") + .returns(vscode.Uri.file("mocked")); uriMock.expects("file").once().returns({ fsPath: "testFsPath" }); return events.doGeneratorDone( true, @@ -515,7 +792,12 @@ describe("vscode-youi-events unit test", () => { .resolves(); workspaceMock.expects("updateWorkspaceFolders").withArgs(0, null); - events.doGeneratorDone( + // Stub WorkspaceFile.createWsWithUri to prevent filesystem writes in CI + sandbox + .stub(WorkspaceFile, "createWsWithUri") + .returns(vscode.Uri.file("mocked")); + + return events.doGeneratorDone( true, "success message", "Open the project in a multi-root workspace", @@ -539,7 +821,12 @@ describe("vscode-youi-events unit test", () => { .withArgs("vscode.openFolder") .resolves(); - events.doGeneratorDone( + // Stub WorkspaceFile.createWsWithUri to prevent filesystem writes in CI + sandbox + .stub(WorkspaceFile, "createWsWithUri") + .returns(vscode.Uri.file("mocked")); + + return events.doGeneratorDone( true, "success message", "Open the project in a stand-alone", @@ -559,7 +846,12 @@ describe("vscode-youi-events unit test", () => { ) .resolves(); - events.doGeneratorDone( + // Stub WorkspaceFile.createWsWithUri to prevent filesystem writes in CI + sandbox + .stub(WorkspaceFile, "createWsWithUri") + .returns(vscode.Uri.file("mocked")); + + return events.doGeneratorDone( true, "success message", "Create the project and close it for future use", @@ -641,6 +933,142 @@ describe("vscode-youi-events unit test", () => { "files" ); }); + + it("shows finalising message when progressReporter is active", async () => { + // Set up a mock progress reporter + const mockProgressReporter = { + report: sandbox.stub(), + }; + events["progressReporter"] = mockProgressReporter; + + eventsMock.expects("doClose"); + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + + await events.doGeneratorDone( + true, + "success message", + createAndClose, + "files", + null + ); + + // Verify progressReporter.report was called with finalising message + expect(mockProgressReporter.report.called).to.be.true; + expect(mockProgressReporter.report.firstCall.args[0]).to.deep.equal({ + message: messages.default.progress_finalising, + }); + + events["progressReporter"] = null; + }); + + describe("with project name in notification", () => { + beforeEach(() => { + // Set currentProjectName by calling doGeneratorInstall + events["currentProjectName"] = "myTestProject"; + }); + + afterEach(() => { + events["currentProjectName"] = undefined; + }); + + it("shows project name in success message for add to workspace", () => { + eventsMock.expects("doClose"); + sandbox.stub(vscode.workspace, "workspaceFolders").value([]); + sandbox.stub(vscode.workspace, "workspaceFile").value(undefined); + // Stub WorkspaceFile.createWsWithPath to prevent filesystem writes in CI + sandbox + .stub(WorkspaceFile, "createWsWithPath") + .returns(vscode.Uri.file("mocked")); + windowMock + .expects("showInformationMessage") + .withExactArgs( + "Project myTestProject has been generated. The project has been added to workspace." + ) + .resolves(); + commandsMock + .expects("executeCommand") + .withArgs("vscode.openFolder") + .resolves(); + workspaceMock.expects("updateWorkspaceFolders").withArgs(0, null); + return events.doGeneratorDone( + true, + "success message", + addToWorkspace, + "project", + "testDestinationRoot" + ); + }); + + it("shows project name in success message for open in new workspace", () => { + eventsMock.expects("doClose"); + sandbox.stub(vscode.workspace, "workspaceFolders").value([]); + 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") + .resolves(); + return events.doGeneratorDone( + true, + "success message", + openNewWorkspace, + "project", + "testDestinationRoot" + ); + }); + + it("shows project name in success message for save for future use", () => { + eventsMock.expects("doClose"); + sandbox.stub(vscode.workspace, "workspaceFolders").value([]); + windowMock + .expects("showInformationMessage") + .withExactArgs("Project myTestProject has been generated.") + .resolves(); + return events.doGeneratorDone( + true, + "success message", + createAndClose, + "project", + "testDestinationRoot" + ); + }); + }); + + it("sets GENERATOR_COMPLETED flag before disposal", async () => { + // Setup: verify flag is set before doClose is called + let flagSetBeforeClose = false; + + eventsMock.expects("doClose").callsFake(() => { + // Check if flag was already set when doClose is called + const flagValue = (events["webviewPanel"] as any)?.[ + Constants.GENERATOR_COMPLETED + ]; + flagSetBeforeClose = flagValue === true; + }); + + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + + await events.doGeneratorDone( + true, + "success message", + createAndClose, + "files", + null + ); + + // Verify flag was set before doClose was called + expect(flagSetBeforeClose).to.be.true; + }); }); describe("getUniqueProjectName", () => { @@ -678,4 +1106,21 @@ describe("vscode-youi-events unit test", () => { expect(result).to.equal("UniqueProject"); }); }); + + describe("edge cases for coverage", () => { + it("showDoneMessage with skipResolve=false should call resolveInstallingProgress", async () => { + windowMock + .expects("showInformationMessage") + .withExactArgs(messages.default.artifact_generated_files) + .resolves(); + + // Call showDoneMessage directly with skipResolve=false (default) + await events["showDoneMessage"](true, "success", "", "files"); + }); + + it("getSuccessInfoMessage with empty type returns empty string", () => { + const result = events["getSuccessInfoMessage"]("", ""); + expect(result).to.equal(""); + }); + }); }); diff --git a/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts b/projects/yeoman-ui/packages/backend/test/yeomanui.spec.ts index 780ea334..5f3b54eb 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(): Promise { + return Promise.resolve(); } public doGeneratorInstall(): void { return; } + public doGeneratorProgress(): Promise { + return Promise.resolve(); + } 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(); }); 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 01662d3f..5fb8d6b1 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(): Promise { + return Promise.resolve(); } public doGeneratorInstall(): void { return; } + public doGeneratorProgress(): Promise { + return Promise.resolve(); + } 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 075f29ea..5eb85aac 100644 --- a/projects/yeoman-ui/packages/frontend/src/youi/App.vue +++ b/projects/yeoman-ui/packages/frontend/src/youi/App.vue @@ -625,6 +625,29 @@ export default { this.doneStatus = true; this.isDone = true; }, + generatorProgress(projectName, phase) { + // Update UI to show progress notification + // Map phases to display messages + const phaseMessages = { + writing: "Creating project files...", + install: "Installing dependencies...", + end: "Finalising...", + }; + + const message = phaseMessages[phase] || "Generating..."; + + // Update the prompt name to show progress + if (this.currentPrompt) { + this.currentPrompt.name = projectName + ? `Generating ${projectName}` + : "Generating"; + } + + // Show status message + this.doneMessage = message; + this.isDone = true; + this.doneStatus = true; + }, generatorDone(succeeded, message, targetPath) { this.currentPrompt.name = "Summary"; this.doneMessage = message; @@ -666,7 +689,7 @@ export default { const functions = [ "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 ae61aa82..fa704a3b 100644 --- a/projects/yeoman-ui/packages/frontend/test/App.spec.js +++ b/projects/yeoman-ui/packages/frontend/test/App.spec.js @@ -560,7 +560,7 @@ describe("App.vue", () => { wrapper.vm.showPrompt = jest.fn(); wrapper.vm.setPrompts = jest.fn(); - wrapper.vm.generatorInstall = jest.fn(); + wrapper.vm.generatorProgress = jest.fn(); wrapper.vm.generatorDone = jest.fn(); wrapper.vm.log = jest.fn(); @@ -574,9 +574,9 @@ describe("App.vue", () => { name: "showPrompt", }); expect(registerMethodSpy).toHaveBeenCalledWith({ - func: wrapper.vm.generatorInstall, + func: wrapper.vm.generatorProgress, thisArg: wrapper.vm, - name: "generatorInstall", + name: "generatorProgress", }); expect(registerMethodSpy).toHaveBeenCalledWith({ func: wrapper.vm.generatorDone, @@ -968,6 +968,83 @@ describe("App.vue", () => { }); }); + describe("generatorProgress - method", () => { + it("updates notification with project name and phase message for writing phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + + wrapper.vm.prompts = [{}, {}]; + wrapper.vm.promptIndex = 1; + wrapper.vm.currentPrompt.status = "pending"; + + wrapper.vm.generatorProgress("testProject", "writing"); + + expect(wrapper.vm.currentPrompt.name).toBe("Generating testProject"); + expect(wrapper.vm.doneMessage).toBe("Creating project files..."); + expect(wrapper.vm.isDone).toBeTruthy(); + expect(wrapper.vm.doneStatus).toBeTruthy(); + }); + + it("updates notification with project name and phase message for install phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + + wrapper.vm.prompts = [{}, {}]; + wrapper.vm.promptIndex = 1; + wrapper.vm.currentPrompt.status = "pending"; + + wrapper.vm.generatorProgress("myApp", "install"); + + expect(wrapper.vm.currentPrompt.name).toBe("Generating myApp"); + expect(wrapper.vm.doneMessage).toBe("Installing dependencies..."); + expect(wrapper.vm.isDone).toBeTruthy(); + expect(wrapper.vm.doneStatus).toBeTruthy(); + }); + + it("updates notification with project name and phase message for end phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + + wrapper.vm.prompts = [{}, {}]; + wrapper.vm.promptIndex = 1; + wrapper.vm.currentPrompt.status = "pending"; + + wrapper.vm.generatorProgress("finalProject", "end"); + + expect(wrapper.vm.currentPrompt.name).toBe("Generating finalProject"); + expect(wrapper.vm.doneMessage).toBe("Finalising..."); + expect(wrapper.vm.isDone).toBeTruthy(); + expect(wrapper.vm.doneStatus).toBeTruthy(); + }); + + it("uses default title when no project name provided", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + + wrapper.vm.prompts = [{}, {}]; + wrapper.vm.promptIndex = 1; + wrapper.vm.currentPrompt.status = "pending"; + + wrapper.vm.generatorProgress(undefined, "install"); + + expect(wrapper.vm.currentPrompt.name).toBe("Generating"); + expect(wrapper.vm.doneMessage).toBe("Installing dependencies..."); + expect(wrapper.vm.isDone).toBeTruthy(); + expect(wrapper.vm.doneStatus).toBeTruthy(); + }); + + it("uses generic message for unknown phase", () => { + wrapper = initComponent(App, {}, false, ["vscode-textfield"]); + + wrapper.vm.prompts = [{}, {}]; + wrapper.vm.promptIndex = 1; + wrapper.vm.currentPrompt.status = "pending"; + + wrapper.vm.generatorProgress("testProject", "unknown"); + + expect(wrapper.vm.currentPrompt.name).toBe("Generating testProject"); + expect(wrapper.vm.doneMessage).toBe("Generating..."); + expect(wrapper.vm.isDone).toBeTruthy(); + expect(wrapper.vm.doneStatus).toBeTruthy(); + }); + }); + describe("generatorDone - method", () => { test("status is pending", () => { wrapper = initComponent(App, { donePath: "testDonePath" }); diff --git a/projects/yeoman-ui/packages/generator-foodq/generators/app/index.js b/projects/yeoman-ui/packages/generator-foodq/generators/app/index.js index 81a839a4..a38debc8 100644 --- a/projects/yeoman-ui/packages/generator-foodq/generators/app/index.js +++ b/projects/yeoman-ui/packages/generator-foodq/generators/app/index.js @@ -101,6 +101,13 @@ module.exports = class extends Generator { required: false, default: silent ? "password123" : undefined, }); + this.argument("dietaryPreferences", { + type: Object, + required: false, + default: silent + ? { allergies: "None", spiceLevel: "Medium", vegetarian: false } + : undefined, + }); this.setPromptsCallback = (fn) => { if (this.prompts) { @@ -276,6 +283,45 @@ module.exports = class extends Generator { ], default: "cat", }, + { + type: "input", + guiOptions: { + type: "questionnaire", + hint: "Answer a few quick questions to help us personalize your experience.", + }, + name: "dietaryPreferences", + message: "Dietary Preferences Survey", + questions: [ + { + name: "allergies", + message: "Do you have any food allergies?", + type: "list", + choices: ["None", "Nuts", "Dairy", "Gluten", "Shellfish"], + }, + { + name: "spiceLevel", + message: "Preferred spice level?", + type: "list", + choices: ["Mild", "Medium", "Hot", "Extra Hot"], + }, + { + name: "vegetarian", + message: "Are you vegetarian?", + type: "confirm", + }, + ], + default: { + allergies: "None", + spiceLevel: "Medium", + vegetarian: false, + }, + validate: (answers) => { + if (answers.vegetarian && answers.spiceLevel === "Extra Hot") { + return "Extra hot vegetarian dishes are limited. Consider Medium or Hot."; + } + return true; + }, + }, { type: "input", name: "favColor", @@ -403,6 +449,10 @@ module.exports = class extends Generator { this.answers.favColor = this._getAnswer("favColor", this.answers); this.answers.number = this._getAnswer("number", this.answers); this.answers.beers = this._getAnswer("beers", this.answers); + this.answers.dietaryPreferences = this._getAnswer( + "dietaryPreferences", + this.answers + ); prompts = [ {