diff --git a/packages/fx-core/src/common/deps-checker/constant/telemetry.ts b/packages/fx-core/src/common/deps-checker/constant/telemetry.ts index 33893352b9b..cb7c44e7bf6 100644 --- a/packages/fx-core/src/common/deps-checker/constant/telemetry.ts +++ b/packages/fx-core/src/common/deps-checker/constant/telemetry.ts @@ -44,6 +44,9 @@ export enum TelemetryProperties { VersioningTestToolVersionError = "versioning-test-tool-version-error", GlobalTestToolVersion = "global-test-tool-version", GlobalTestToolVersionError = "global-test-tool-version-error", + TestToolLastUpdateTimestamp = "test-tool-last-update-timestamp", + TestToolUpdatePreviousVersion = "test-tool-update-previous-version", + TestToolUpdateError = "test-tool-update-error", } export enum TelemetryMessurement { diff --git a/packages/fx-core/src/common/deps-checker/internal/testToolChecker.ts b/packages/fx-core/src/common/deps-checker/internal/testToolChecker.ts index 351d7fd2a33..481a8e5f1c4 100644 --- a/packages/fx-core/src/common/deps-checker/internal/testToolChecker.ts +++ b/packages/fx-core/src/common/deps-checker/internal/testToolChecker.ts @@ -18,13 +18,28 @@ import { isWindows } from "../util/system"; import { TelemetryProperties } from "../constant/telemetry"; import { cpUtils } from "../util"; +enum InstallType { + Global = "global", + Portable = "portable", +} + +type TestToolDependencyStatus = Omit & + ({ isInstalled: true; installType: InstallType } | { isInstalled: false }); + +interface InstallationInfoFile { + lastCheckTimestamp: number; +} + export class TestToolChecker implements DepsChecker { private telemetryProperties: { [key: string]: string }; private readonly name = "Teams App Test Tool"; private readonly npmPackageName = "@microsoft/teams-app-test-tool-cli"; private readonly timeout = 5 * 60 * 1000; + private readonly checkUpdateTimeout = 10 * 1000; private readonly commandName = isWindows() ? "teamsapptester.cmd" : "teamsapptester"; private readonly portableDirName = "testTool"; + // 7 days + private readonly defaultUpdateInterval = 7 * 24 * 60 * 60 * 1000; constructor() { this.telemetryProperties = {}; @@ -32,7 +47,7 @@ export class TestToolChecker implements DepsChecker { public async getInstallationInfo( installOptions: TestToolInstallOptions - ): Promise { + ): Promise { const symlinkDir = path.resolve(installOptions.projectPath, installOptions.symlinkDir); // check version in project devTools @@ -73,7 +88,7 @@ export class TestToolChecker implements DepsChecker { } public async resolve(installOptions: TestToolInstallOptions): Promise { - let installationInfo: DependencyStatus; + let installationInfo: TestToolDependencyStatus; try { installationInfo = await this.getInstallationInfo(installOptions); if (!installationInfo.isInstalled) { @@ -83,10 +98,15 @@ export class TestToolChecker implements DepsChecker { installOptions.versionRange, symlinkDir ); + } else { + if (installationInfo.installType === InstallType.Portable) { + const updateInstallationInfo = await this.autoUpdate(installOptions); + if (updateInstallationInfo) { + installationInfo = updateInstallationInfo; + } + } } - // TODO: auto upgrade if already installed - return installationInfo; } catch (error: any) { if (error instanceof DepsCheckerError) { @@ -103,7 +123,7 @@ export class TestToolChecker implements DepsChecker { projectPath: string, versionRange: string, symlinkDir: string - ): Promise { + ): Promise { // TODO: check npm installed const tmpVersion = `tmp-${uuid.v4().slice(0, 6)}`; @@ -126,7 +146,120 @@ export class TestToolChecker implements DepsChecker { await createSymlink(actualPath, symlinkDir); - return await this.getSuccessDepsInfo(versionRange, symlinkDir); + await this.writeInstallInfoFile(projectPath); + + return await this.getSuccessDepsInfo(versionRes.value, symlinkDir); + } + + private async hasNewVersionReleasedInRange( + latestInstalledVersion: string, + versionRange: string + ): Promise { + try { + const result = await cpUtils.executeCommand( + undefined, + undefined, + // avoid powershell execution policy issue. + { shell: isWindows() ? "cmd.exe" : true, timeout: this.checkUpdateTimeout }, + "npm", + "view", + `"${this.npmPackageName}@${versionRange}"`, + "version", + "--json" + ); + const versionList: string[] = JSON.parse(result); + if (!Array.isArray(versionList)) { + return true; + } + return versionList.filter((v) => semver.gt(v, latestInstalledVersion)).length > 0; + } catch { + // just a best effort optimization to save one download if no recent version has been released + // do update if check failed + return true; + } + } + + // return undefined if not updated or update failure + private async autoUpdate( + installOptions: TestToolInstallOptions + ): Promise { + const installInfo = await this.readInstallInfoFile(installOptions.projectPath); + const now = new Date().getTime(); + const updateExpired = + !installInfo || now > installInfo.lastCheckTimestamp + this.defaultUpdateInterval; + + if (!updateExpired) { + return undefined; + } + + const latestInstalledVersion = await this.findLatestInstalledPortableVersion( + installOptions.versionRange + ); + if ( + latestInstalledVersion !== undefined && + !(await this.hasNewVersionReleasedInRange( + latestInstalledVersion, + installOptions.versionRange + )) + ) { + return undefined; + } + + this.telemetryProperties[TelemetryProperties.TestToolLastUpdateTimestamp] = + installInfo?.lastCheckTimestamp?.toString() || ""; + this.telemetryProperties[TelemetryProperties.TestToolUpdatePreviousVersion] = + latestInstalledVersion || ""; + const symlinkDir = path.resolve(installOptions.projectPath, installOptions.symlinkDir); + + try { + return await this.install( + installOptions.projectPath, + installOptions.versionRange, + symlinkDir + ); + } catch (e: unknown) { + // ignore update failure and use existing version + if (e instanceof Error) { + this.telemetryProperties[TelemetryProperties.TestToolUpdateError] = e.message; + } + await this.writeInstallInfoFile(installOptions.projectPath); + return undefined; + } + } + + private validateInstallInfoFile(data: unknown): data is InstallationInfoFile { + if ("lastCheckTimestamp" in (data as InstallationInfoFile)) { + if (typeof (data as InstallationInfoFile).lastCheckTimestamp === "number") { + return true; + } + } + + return false; + } + + private async readInstallInfoFile( + projectPath: string + ): Promise { + const installInfoPath = this.getInstallInfoPath(projectPath); + try { + const data: unknown = await fs.readJson(installInfoPath); + if (this.validateInstallInfoFile(data)) { + return data; + } + } catch { + // ignore invalid installation info file + } + await cleanup(installInfoPath); + return undefined; + } + + private async writeInstallInfoFile(projectPath: string) { + const projectInfoPath = this.getInstallInfoPath(projectPath); + const installInfo: InstallationInfoFile = { + lastCheckTimestamp: new Date().getTime(), + }; + await fs.ensureDir(path.dirname(projectInfoPath)); + await fs.writeJson(projectInfoPath, installInfo); } private async findLatestInstalledPortableVersion( @@ -221,7 +354,7 @@ export class TestToolChecker implements DepsChecker { { shell: isWindows() ? "cmd.exe" : true, timeout: this.timeout }, `npm`, "install", - pkg, + `"${pkg}"`, "--prefix", `"${prefix}"`, "--no-audit" @@ -272,7 +405,13 @@ export class TestToolChecker implements DepsChecker { private getPortableInstallPath(version: string): string { return path.join(this.getPortableVersionsDir(), version); } - private async getSuccessDepsInfo(version: string, binFolder?: string): Promise { + private getInstallInfoPath(projectDir: string): string { + return path.join(projectDir, "devTools", ".testTool.installInfo.json"); + } + private async getSuccessDepsInfo( + version: string, + binFolder?: string + ): Promise { return Promise.resolve({ name: this.name, type: DepsType.TestTool, @@ -286,12 +425,13 @@ export class TestToolChecker implements DepsChecker { }, telemetryProperties: this.telemetryProperties, error: undefined, + installType: binFolder ? InstallType.Portable : InstallType.Global, }); } private async createFailureDepsInfo( version: string, error?: DepsCheckerError - ): Promise { + ): Promise { return Promise.resolve({ name: this.name, type: DepsType.TestTool, diff --git a/packages/fx-core/src/component/driver/devTool/installDriver.ts b/packages/fx-core/src/component/driver/devTool/installDriver.ts index be2265fe663..88c909dd5ae 100644 --- a/packages/fx-core/src/component/driver/devTool/installDriver.ts +++ b/packages/fx-core/src/component/driver/devTool/installDriver.ts @@ -260,7 +260,11 @@ export class ToolsInstallDriverImpl { async resolveTestTool(versionRange: string, symlinkDir: string): Promise { const checker = new TestToolChecker(); const projectPath = this.context.projectPath; - const status = await checker.resolve({ versionRange, symlinkDir, projectPath }); + const status = await checker.resolve({ + versionRange, + symlinkDir, + projectPath, + }); this.context.logProvider.debug( `Teams App Test Tool result: ${JSON.stringify({ isInstalled: status.isInstalled, diff --git a/packages/fx-core/tests/common/deps-checker/testToolChecker.test.ts b/packages/fx-core/tests/common/deps-checker/testToolChecker.test.ts index b992fa50e59..74631f28f9f 100644 --- a/packages/fx-core/tests/common/deps-checker/testToolChecker.test.ts +++ b/packages/fx-core/tests/common/deps-checker/testToolChecker.test.ts @@ -8,6 +8,7 @@ import * as sinon from "sinon"; import * as path from "path"; import * as url from "url"; import * as os from "os"; +import fs from "fs-extra"; import mockfs from "mock-fs"; import cp from "child_process"; import { cpUtils } from "../../../src/common/deps-checker/util/cpUtils"; @@ -24,11 +25,24 @@ function pathSplit(p: string) { return p.split(/[\/\\]+/); } +function trimQuotes(s: string) { + return s.replace(/^"|'/, "").replace(/"|'$/, ""); +} + +function mockInstallInfoFile(projectPath: string) { + return { + [path.join(projectPath, "devTools", ".testTool.installInfo.json")]: JSON.stringify({ + lastCheckTimestamp: new Date().getTime(), + }), + }; +} + describe("Test Tool Checker Test", () => { const sandbox = sinon.createSandbox(); const projectPath = "projectPath"; const homePortablesDir = path.join(os.homedir(), ".fx", "bin", "testTool"); + beforeEach(() => {}); afterEach(async () => { sandbox.restore(); mockfs.restore(); @@ -40,6 +54,11 @@ describe("Test Tool Checker Test", () => { const symlinkDir = "symlinkDir"; const versionRange = "~1.2.3"; let npmInstalled = false; + mockfs({}); + const writtenFiles: string[] = []; + sandbox.stub(fs, "writeJson").callsFake((path) => { + writtenFiles.push(path); + }); sandbox.stub(fileHelper, "rename").resolves(); sandbox.stub(fileHelper, "createSymlink").resolves(); sandbox @@ -65,6 +84,9 @@ describe("Test Tool Checker Test", () => { expect(status.details.binFolders).not.empty; expect(status.error).to.be.undefined; expect(npmInstalled).to.be.true; + expect(writtenFiles.map((f) => path.resolve(f))).to.include( + path.resolve(path.join(projectPath, "devTools", ".testTool.installInfo.json")) + ); }); }); @@ -74,6 +96,9 @@ describe("Test Tool Checker Test", () => { const symlinkDir = "symlinkDir"; const versionRange = "~1.2.3"; let npmInstalled = false; + mockfs({ + ...mockInstallInfoFile(projectPath), + }); sandbox .stub(cpUtils, "executeCommand") .callsFake(async (_cwd, _logger, _options, command, ...args) => { @@ -104,6 +129,7 @@ describe("Test Tool Checker Test", () => { const homePortableExec = path.join(homePortableDir, "node_modules", ".bin", "teamsapptester"); mockfs({ [homePortableExec]: "", + ...mockInstallInfoFile(projectPath), }); let linkTarget = ""; @@ -158,6 +184,7 @@ describe("Test Tool Checker Test", () => { mockfs({ [homePortableExec123]: "", [homePortableExec124]: "", + ...mockInstallInfoFile(projectPath), }); let linkTarget = ""; @@ -193,12 +220,14 @@ describe("Test Tool Checker Test", () => { expect(path.resolve(linkTarget)).to.equal(path.resolve(homePortableDir124)); }); - it("Already installed globally", async () => { + it("Already installed globally. Should not check for update", async () => { const checker = new TestToolChecker(); const versionRange = "~1.2.3"; const symlinkDir = "symlinkDir"; const createSymlinkStub = sandbox.stub(fileHelper, "createSymlink"); + let checkedUpdate = false; + mockfs({}); sandbox .stub(cpUtils, "executeCommand") .callsFake(async (_cwd, _logger, _options, command, ...args) => { @@ -217,6 +246,8 @@ describe("Test Tool Checker Test", () => { } } else if (args.includes("install")) { throw new Error("Should not install"); + } else if (args.includes("view")) { + checkedUpdate = true; } return ""; }); @@ -229,6 +260,7 @@ describe("Test Tool Checker Test", () => { expect(status.details.binFolders).to.be.empty; expect(status.error).to.be.undefined; expect(createSymlinkStub.notCalled); + expect(checkedUpdate).to.be.false; }); }); @@ -289,7 +321,7 @@ describe("Test Tool Checker Test", () => { sandbox .stub(cpUtils, "executeCommand") .callsFake(async (_cwd, _logger, _options, command, ...args) => { - command = command.replace(/^"|'/, "").replace(/"|'$/, ""); // trim quotes + command = trimQuotes(command); if (args.includes("--version")) { if (command.includes(projectPath)) { if (npmInstalled) { @@ -381,7 +413,7 @@ describe("Test Tool Checker Test", () => { expect(fileArg).not.empty; let parsed: url.URL | undefined; expect(() => { - parsed = new url.URL(fileArg); + parsed = new url.URL(trimQuotes(fileArg)); }).not.throw(); expect(parsed).not.undefined; expect(parsed?.protocol).equals("file:"); @@ -433,4 +465,168 @@ describe("Test Tool Checker Test", () => { expect(status.error).instanceOf(DepsCheckerError); }); }); + + describe("Auto update", () => { + it("Already installed, symlink created, needs to check update but no recent versions", async () => { + const checker = new TestToolChecker(); + const symlinkDir = "symlinkDir"; + const versionRange = "~1.2.3"; + let npmInstalled = false; + let checkedUpdate = false; + const homePortableDir = path.join(homePortablesDir, "1.2.3"); + const homePortableExec = path.join(homePortableDir, "node_modules", ".bin", "teamsapptester"); + mockfs({ + [path.join(projectPath, "devTools", ".testTool.installInfo.json")]: "", + [homePortableExec]: "", + }); + sandbox + .stub(cpUtils, "executeCommand") + .callsFake(async (_cwd, _logger, _options, command, ...args) => { + if (args.includes("--version")) { + return "1.2.3"; + } else if (args.includes("install")) { + npmInstalled = true; + } else if (args.includes("view")) { + checkedUpdate = true; + return '["1.2.3"]'; + } + return ""; + }); + // Act + const status = await checker.resolve({ projectPath, symlinkDir, versionRange }); + // Assert + expect(status.isInstalled).to.be.true; + expect(status.details.binFolders).not.empty; + expect(status.error).to.be.undefined; + expect(npmInstalled).to.be.false; + expect(checkedUpdate).to.be.true; + }); + it("Already installed, symlink created, needs to check update but has more recent versions", async () => { + const checker = new TestToolChecker(); + const symlinkDir = "symlinkDir"; + const versionRange = "~1.2.3"; + let npmInstalled = false; + let checkedUpdate = false; + const homePortableDir = path.join(homePortablesDir, "1.2.3"); + const homePortableExec = path.join(homePortableDir, "node_modules", ".bin", "teamsapptester"); + sandbox.stub(fileHelper, "rename").resolves(); + sandbox.stub(fileHelper, "createSymlink").resolves(); + mockfs({ + [path.join(projectPath, "devTools", ".testTool.installInfo.json")]: "", + [homePortableExec]: "", + }); + sandbox + .stub(cpUtils, "executeCommand") + .callsFake(async (_cwd, _logger, _options, command, ...args) => { + if (args.includes("--version")) { + if (checkedUpdate) { + // after update + return "1.2.4"; + } else { + return "1.2.3"; + } + } else if (args.includes("install")) { + npmInstalled = true; + } else if (args.includes("view")) { + checkedUpdate = true; + return '["1.2.4"]'; + } + return ""; + }); + // Act + const status = await checker.resolve({ projectPath, symlinkDir, versionRange }); + // Assert + expect(status.isInstalled).to.be.true; + expect(status.details.binFolders).not.empty; + expect(status.error).to.be.undefined; + expect(status.details.installVersion).to.eq("1.2.4"); + expect(npmInstalled).to.be.true; + expect(checkedUpdate).to.be.true; + }); + it("Already installed, symlink created, needs to check update but update failed", async () => { + const checker = new TestToolChecker(); + const symlinkDir = "symlinkDir"; + const versionRange = "~1.2.3"; + let npmInstalled = false; + let checkedUpdate = false; + const homePortableDir = path.join(homePortablesDir, "1.2.3"); + const homePortableExec = path.join(homePortableDir, "node_modules", ".bin", "teamsapptester"); + sandbox.stub(fileHelper, "rename").resolves(); + const linkTargets: string[] = []; + sandbox.stub(fileHelper, "createSymlink").callsFake(async (target) => { + linkTargets.push(target); + }); + mockfs({ + [path.join(projectPath, "devTools", ".testTool.installInfo.json")]: "", + [homePortableExec]: "", + }); + sandbox + .stub(cpUtils, "executeCommand") + .callsFake(async (_cwd, _logger, _options, command, ...args) => { + if (args.includes("--version")) { + if (checkedUpdate) { + // after update + throw new Error("Update failed"); + } else { + return "1.2.3"; + } + } else if (args.includes("install")) { + npmInstalled = true; + } else if (args.includes("view")) { + // npm view package version + checkedUpdate = true; + return '["1.2.4"]'; + } + return ""; + }); + // Act + const status = await checker.resolve({ projectPath, symlinkDir, versionRange }); + // Assert + expect(status.isInstalled).to.be.true; + expect(status.details.binFolders).not.empty; + expect(status.details.installVersion).to.eq("1.2.3"); + expect(status.error).to.be.undefined; + expect(npmInstalled).to.be.true; + expect(checkedUpdate).to.be.true; + }); + it("Already installed, symlink created, but skip update", async () => { + const checker = new TestToolChecker(); + const symlinkDir = "symlinkDir"; + const versionRange = "1.2.3"; + let npmInstalled = false; + let checkedUpdate = false; + const homePortableDir = path.join(homePortablesDir, "1.2.3"); + const homePortableExec = path.join(homePortableDir, "node_modules", ".bin", "teamsapptester"); + mockfs({ + [path.join(projectPath, "devTools", ".testTool.installInfo.json")]: "", + [homePortableExec]: "", + }); + sandbox + .stub(cpUtils, "executeCommand") + .callsFake(async (_cwd, _logger, _options, command, ...args) => { + if (args.includes("--version")) { + return "1.2.3"; + } else if (args.includes("install")) { + npmInstalled = true; + } else if (args.includes("view")) { + checkedUpdate = true; + return '["1.2.3"]'; + } + return ""; + }); + // Act + const status = await checker.resolve({ + projectPath, + symlinkDir, + versionRange, + }); + // Assert + expect(status.isInstalled).to.be.true; + expect(status.details.binFolders).not.empty; + expect(status.error).to.be.undefined; + expect(npmInstalled).to.be.false; + expect(checkedUpdate).to.be.true; + expect(status.details.installVersion).to.eq("1.2.3"); + }); + }); });