From bc1eb6cf8c23e8333bfd3398a920c29fbd8d5201 Mon Sep 17 00:00:00 2001 From: Yuqi Zhou <86260893+yuqizhou77@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:56:31 +0800 Subject: [PATCH] perf: refresh add plugin treeview item (#12376) * perf: refresh add plugin treeview item * test: ut --- packages/vscode-extension/src/extension.ts | 2 + .../vscode-extension/src/globalVariables.ts | 8 +- .../vscode-extension/src/manifestListener.ts | 75 ++++++++ .../src/telemetry/extTelemetryEvents.ts | 3 + .../src/treeview/treeViewManager.ts | 2 +- .../src/utils/fileSystemWatcher.ts | 2 +- .../test/extension/globalVariables.test.ts | 19 ++ .../test/handlers/manifestListener.test.ts | 180 ++++++++++++++++++ .../test/treeview/treeViewManager.test.ts | 4 +- .../test/utils/fileSystemWatcher.test.ts | 8 +- 10 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 packages/vscode-extension/src/manifestListener.ts create mode 100644 packages/vscode-extension/test/handlers/manifestListener.test.ts diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index c26ba0d193..0497507f08 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -199,6 +199,7 @@ import { ReleaseNote } from "./utils/releaseNote"; import { ExtensionSurvey } from "./utils/survey"; import { getSettingsVersion, projectVersionCheck } from "./utils/telemetryUtils"; import { createPluginWithManifest } from "./handlers/createPluginWithManifestHandler"; +import { manifestListener } from "./manifestListener"; export async function activate(context: vscode.ExtensionContext) { const value = IsChatParticipantEnabled && semver.gte(vscode.version, "1.90.0"); @@ -317,6 +318,7 @@ function activateTeamsFxRegistration(context: vscode.ExtensionContext) { if (vscode.workspace.isTrusted) { registerLanguageFeatures(context); + context.subscriptions.push(manifestListener()); } registerDebugConfigProviders(context); diff --git a/packages/vscode-extension/src/globalVariables.ts b/packages/vscode-extension/src/globalVariables.ts index 6ed0e5472b..c80c4dc0e5 100644 --- a/packages/vscode-extension/src/globalVariables.ts +++ b/packages/vscode-extension/src/globalVariables.ts @@ -13,7 +13,7 @@ import { isManifestOnlyOfficeAddinProject, manifestUtils, } from "@microsoft/teamsfx-core"; -import { Tools } from "@microsoft/teamsfx-api"; +import { TeamsAppManifest, Tools } from "@microsoft/teamsfx-api"; /** * Common variables used throughout the extension. They must be initialized in the activate() method of extension.ts @@ -86,6 +86,12 @@ export function checkIsDeclarativeCopilotApp(directory: string): boolean { } } +export function updateIsDeclarativeCopilotApp(manifest: TeamsAppManifest): boolean { + const value = manifestUtils.getCapabilities(manifest).includes("copilotGpt"); + isDeclarativeCopilotApp = value; + return isDeclarativeCopilotApp; +} + export function setCommandIsRunning(isRunning: boolean) { commandIsRunning = isRunning; } diff --git a/packages/vscode-extension/src/manifestListener.ts b/packages/vscode-extension/src/manifestListener.ts new file mode 100644 index 0000000000..aa9e30b9cb --- /dev/null +++ b/packages/vscode-extension/src/manifestListener.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +"use strict"; + +import * as vscode from "vscode"; +import { + isDeclarativeCopilotApp, + updateIsDeclarativeCopilotApp, + workspaceUri, +} from "./globalVariables"; +import path from "path"; +import { + AppPackageFolderName, + ManifestTemplateFileName, + TeamsAppManifest, +} from "@microsoft/teamsfx-api"; +import TreeViewManagerInstance from "./treeview/treeViewManager"; +import { ExtTelemetry } from "./telemetry/extTelemetry"; +import { TelemetryEvent, TelemetryProperty } from "./telemetry/extTelemetryEvents"; +import { isValidProjectV3 } from "@microsoft/teamsfx-core"; + +function setAbortableTimeout(ms: number, signal: any) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + // Resolve the promise after 5 seconds + resolve("After timeout. Checking app."); + }, ms); + + // Listen for the abort event + signal.addEventListener("abort", () => { + // Clear the timeout and reject the promise if aborted + clearTimeout(timeoutId); + reject("resolved after clear"); + }); + }); +} + +export function manifestListener(): vscode.Disposable { + let abortController: undefined | AbortController; + const disposable = vscode.workspace.onDidSaveTextDocument( + async (event): Promise => { + try { + if ( + workspaceUri && + isValidProjectV3(workspaceUri.fsPath) && + event.fileName === + path.join(workspaceUri.fsPath, AppPackageFolderName, ManifestTemplateFileName) + ) { + if (abortController) { + abortController.abort(); + } + abortController = new AbortController(); + + await setAbortableTimeout(5000, abortController.signal); + if (!abortController.signal.aborted) { + const currValue = isDeclarativeCopilotApp; + const manifest: TeamsAppManifest = JSON.parse(event.getText()); + const newValue = updateIsDeclarativeCopilotApp(manifest); + if (currValue !== newValue) { + ExtTelemetry.sendTelemetryEvent(TelemetryEvent.UpdateAddPluginTreeview, { + [TelemetryProperty.ShowAddPluginTreeView]: newValue.toString(), + }); + TreeViewManagerInstance.updateDevelopmentTreeView(); + } + + return currValue !== newValue; + } + } + } catch (error) {} + } + ); + + return disposable; +} diff --git a/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts b/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts index ebc228a686..8c58543e37 100644 --- a/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts +++ b/packages/vscode-extension/src/telemetry/extTelemetryEvents.ts @@ -295,6 +295,8 @@ export enum TelemetryEvent { InstallKiota = "install-kiota", Configuration = "vsc-configuration", + + UpdateAddPluginTreeview = "update-add-plugin-tree-view", } export enum TelemetryProperty { @@ -415,6 +417,7 @@ export enum TelemetryProperty { CopilotChatRequestToken = "copilot-chat-request-token", CopilotChatResponseToken = "copilot-chat-response-token", KiotaInstalled = "kiota-installed", + ShowAddPluginTreeView = "show-add-plugin-tree-view", } export enum TelemetryMeasurements { diff --git a/packages/vscode-extension/src/treeview/treeViewManager.ts b/packages/vscode-extension/src/treeview/treeViewManager.ts index 7fe7583bff..63fdb4505c 100644 --- a/packages/vscode-extension/src/treeview/treeViewManager.ts +++ b/packages/vscode-extension/src/treeview/treeViewManager.ts @@ -115,7 +115,7 @@ class TreeViewManager { } } - public updateTreeViewsOnSPFxChanged(): void { + public updateDevelopmentTreeView(): void { const developmentTreeviewProvider = this.getTreeView( "teamsfx-development" ) as CommandsTreeViewProvider; diff --git a/packages/vscode-extension/src/utils/fileSystemWatcher.ts b/packages/vscode-extension/src/utils/fileSystemWatcher.ts index 9bc0a12d70..8bcdb3af9c 100644 --- a/packages/vscode-extension/src/utils/fileSystemWatcher.ts +++ b/packages/vscode-extension/src/utils/fileSystemWatcher.ts @@ -36,7 +36,7 @@ export function addFileSystemWatcher(workspacePath: string) { export function refreshSPFxTreeOnFileChanged() { initializeGlobalVariables(context); - TreeViewManagerInstance.updateTreeViewsOnSPFxChanged(); + TreeViewManagerInstance.updateDevelopmentTreeView(); } export async function sendSDKVersionTelemetry(filePath: string) { diff --git a/packages/vscode-extension/test/extension/globalVariables.test.ts b/packages/vscode-extension/test/extension/globalVariables.test.ts index b1db44dcf7..303e6b29e8 100644 --- a/packages/vscode-extension/test/extension/globalVariables.test.ts +++ b/packages/vscode-extension/test/extension/globalVariables.test.ts @@ -122,4 +122,23 @@ describe("Global Variables", () => { chai.expect(res).to.be.false; }); }); + + it("updateIsDeclarativeCopilotApp", () => { + const manifest = new TeamsAppManifest(); + let res = globalVariables.updateIsDeclarativeCopilotApp(manifest); + chai.assert.isFalse(res); + + res = globalVariables.updateIsDeclarativeCopilotApp({ + ...manifest, + copilotExtensions: { + declarativeCopilots: [ + { + id: "1", + file: "test", + }, + ], + }, + }); + chai.assert.isTrue(res); + }); }); diff --git a/packages/vscode-extension/test/handlers/manifestListener.test.ts b/packages/vscode-extension/test/handlers/manifestListener.test.ts new file mode 100644 index 0000000000..69eae2b026 --- /dev/null +++ b/packages/vscode-extension/test/handlers/manifestListener.test.ts @@ -0,0 +1,180 @@ +import * as chai from "chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import * as globalVariables from "../../src/globalVariables"; +import { manifestListener } from "../../src/manifestListener"; +import { TeamsAppManifest } from "@microsoft/teamsfx-api"; +import path from "path"; +import TreeViewManagerInstance from "../../src/treeview/treeViewManager"; +import * as projectSettingsHelper from "@microsoft/teamsfx-core/build/common/projectSettingsHelper"; +import { ExtTelemetry } from "../../src/telemetry/extTelemetry"; + +describe("registerManifestListener", () => { + const sandbox = sinon.createSandbox(); + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + sandbox.stub(ExtTelemetry, "sendTelemetryEvent").returns(); + }); + + afterEach(() => { + sandbox.restore(); + if (clock) { + clock.restore(); + } + }); + it("successfully refresh item", async () => { + clock = sandbox.useFakeTimers(); + let handler = async (event: any) => {}; + sandbox.stub(projectSettingsHelper, "isValidProjectV3").returns(true); + sandbox.stub(vscode.workspace, "onDidSaveTextDocument").callsFake((listener: any) => { + handler = listener; + return new vscode.Disposable(() => { + return; + }); + }); + sandbox.stub(globalVariables, "isDeclarativeCopilotApp").value(false); + sandbox.stub(globalVariables, "workspaceUri").value(vscode.Uri.file(".")); + sandbox + .stub(globalVariables, "updateIsDeclarativeCopilotApp") + .onFirstCall() + .returns(true) + .onSecondCall() + .returns(false); + sandbox.stub(TreeViewManagerInstance, "updateDevelopmentTreeView").returns(); + + const fakeDocument = { + fileName: path.join(vscode.Uri.file(".").fsPath, "appPackage", "manifest.json"), + getText: () => { + return JSON.stringify(new TeamsAppManifest()); + }, + }; + + manifestListener(); + let job = handler(fakeDocument); + + await clock.tickAsync(5000); + let res = await job; + chai.assert.isTrue(res); + + job = handler(fakeDocument); + await clock.tickAsync(5000); + res = await job; + chai.assert.isFalse(res); + }); + + it("abort previous one", async () => { + clock = sandbox.useFakeTimers(); + let handler = async (event: any) => {}; + sandbox.stub(projectSettingsHelper, "isValidProjectV3").returns(true); + sandbox.stub(vscode.workspace, "onDidSaveTextDocument").callsFake((listener: any) => { + handler = listener; + return new vscode.Disposable(() => { + return; + }); + }); + sandbox.stub(globalVariables, "isDeclarativeCopilotApp").value(false); + sandbox.stub(globalVariables, "workspaceUri").value(vscode.Uri.file(".")); + sandbox + .stub(globalVariables, "updateIsDeclarativeCopilotApp") + .onFirstCall() + .returns(true) + .onSecondCall() + .returns(false); + sandbox.stub(TreeViewManagerInstance, "updateDevelopmentTreeView").returns(); + + const fakeDocument = { + fileName: path.join(vscode.Uri.file(".").fsPath, "appPackage", "manifest.json"), + getText: () => { + return JSON.stringify(new TeamsAppManifest()); + }, + }; + + manifestListener(); + const job1 = handler(fakeDocument); + await clock.tickAsync(1000); + const job2 = handler(fakeDocument); + + await clock.tickAsync(5000); + const res1 = await job1; + const res2 = await job2; + + chai.assert.isUndefined(res1); + chai.assert.isTrue(res2); + }); + + it("not run if invalid project", async () => { + clock = sandbox.useFakeTimers(); + let handler = async (event: any) => {}; + sandbox.stub(projectSettingsHelper, "isValidProjectV3").returns(false); + sandbox.stub(globalVariables, "workspaceUri").value(vscode.Uri.file(".")); + sandbox.stub(vscode.workspace, "onDidSaveTextDocument").callsFake((listener: any) => { + handler = listener; + return new vscode.Disposable(() => { + return; + }); + }); + + const fakeDocument = { + fileName: path.join(vscode.Uri.file(".").fsPath, "appPackage", "manifest.json"), + getText: () => { + return JSON.stringify(new TeamsAppManifest()); + }, + }; + + manifestListener(); + const res = await handler(fakeDocument); + + chai.assert.isUndefined(res); + }); + + it("not run if empty workspace", async () => { + clock = sandbox.useFakeTimers(); + let handler = async (event: any) => {}; + sandbox.stub(globalVariables, "workspaceUri").value(""); + sandbox.stub(projectSettingsHelper, "isValidProjectV3").returns(false); + sandbox.stub(vscode.workspace, "onDidSaveTextDocument").callsFake((listener: any) => { + handler = listener; + return new vscode.Disposable(() => { + return; + }); + }); + + const fakeDocument = { + fileName: path.join(vscode.Uri.file(".").fsPath, "appPackage", "manifest.json"), + getText: () => { + return JSON.stringify(new TeamsAppManifest()); + }, + }; + + manifestListener(); + const res = await handler(fakeDocument); + + chai.assert.isUndefined(res); + }); + + it("not run if not default app manifest", async () => { + clock = sandbox.useFakeTimers(); + let handler = async (event: any) => {}; + sandbox.stub(globalVariables, "workspaceUri").value("."); + sandbox.stub(projectSettingsHelper, "isValidProjectV3").returns(false); + sandbox.stub(vscode.workspace, "onDidSaveTextDocument").callsFake((listener: any) => { + handler = listener; + return new vscode.Disposable(() => { + return; + }); + }); + + const fakeDocument = { + fileName: path.join(vscode.Uri.file(".").fsPath, "appPackage", "unknown.json"), + getText: () => { + return JSON.stringify(new TeamsAppManifest()); + }, + }; + + manifestListener(); + const res = await handler(fakeDocument); + + chai.assert.isUndefined(res); + }); +}); diff --git a/packages/vscode-extension/test/treeview/treeViewManager.test.ts b/packages/vscode-extension/test/treeview/treeViewManager.test.ts index 1b9ab2b379..c76ac14f06 100644 --- a/packages/vscode-extension/test/treeview/treeViewManager.test.ts +++ b/packages/vscode-extension/test/treeview/treeViewManager.test.ts @@ -67,7 +67,7 @@ describe("TreeViewManager", () => { chai.assert.equal(setStatusStub.callCount, 2); }); - it("updateTreeViewsOnSPFxChanged", () => { + it("updateDevelopmentTreeView", () => { sandbox.stub(globalVariables, "isSPFxProject").value(false); sandbox.stub(featureFlagManager, "getBooleanValue").returns(false); treeViewManager.registerTreeViews({ @@ -82,7 +82,7 @@ describe("TreeViewManager", () => { chai.assert.equal(commands.length, 4); sandbox.stub(globalVariables, "isSPFxProject").value(true); - treeViewManager.updateTreeViewsOnSPFxChanged(); + treeViewManager.updateDevelopmentTreeView(); chai.assert.equal(commands.length, 5); }); diff --git a/packages/vscode-extension/test/utils/fileSystemWatcher.test.ts b/packages/vscode-extension/test/utils/fileSystemWatcher.test.ts index 574a42d095..b5b18d335e 100644 --- a/packages/vscode-extension/test/utils/fileSystemWatcher.test.ts +++ b/packages/vscode-extension/test/utils/fileSystemWatcher.test.ts @@ -24,7 +24,7 @@ describe("FileSystemWatcher", function () { const workspacePath = "test"; sandbox.stub(projectSettingsHelper, "isValidProject").returns(true); sandbox.stub(globalVariables, "initializeGlobalVariables"); - sandbox.stub(TreeViewManagerInstance, "updateTreeViewsOnSPFxChanged"); + sandbox.stub(TreeViewManagerInstance, "updateDevelopmentTreeView"); const watcher = { onDidCreate: () => ({ dispose: () => undefined }), @@ -87,15 +87,15 @@ describe("FileSystemWatcher", function () { it("refreshSPFxTreeOnFileChanged", () => { const initGlobalVariables = sandbox.stub(globalVariables, "initializeGlobalVariables"); - const updateTreeViewsOnSPFxChanged = sandbox + const updateDevelopmentTreeView = sandbox // eslint-disable-next-line no-secrets/no-secrets - .stub(TreeViewManagerInstance, "updateTreeViewsOnSPFxChanged") + .stub(TreeViewManagerInstance, "updateDevelopmentTreeView") .resolves(); refreshSPFxTreeOnFileChanged(); chai.expect(initGlobalVariables.calledOnce).to.be.true; - chai.expect(updateTreeViewsOnSPFxChanged.calledOnce).to.be.true; + chai.expect(updateDevelopmentTreeView.calledOnce).to.be.true; }); });