Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto update for test tool #9957

Merged
merged 4 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
158 changes: 149 additions & 9 deletions packages/fx-core/src/common/deps-checker/internal/testToolChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,36 @@ import { isWindows } from "../util/system";
import { TelemetryProperties } from "../constant/telemetry";
import { cpUtils } from "../util";

enum InstallType {
Global = "global",
Portable = "portable",
}

type TestToolDependencyStatus = Omit<DependencyStatus, "isInstalled"> &
({ 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 = {};
}

public async getInstallationInfo(
installOptions: TestToolInstallOptions
): Promise<DependencyStatus> {
): Promise<TestToolDependencyStatus> {
const symlinkDir = path.resolve(installOptions.projectPath, installOptions.symlinkDir);

// check version in project devTools
Expand Down Expand Up @@ -73,7 +88,7 @@ export class TestToolChecker implements DepsChecker {
}

public async resolve(installOptions: TestToolInstallOptions): Promise<DependencyStatus> {
let installationInfo: DependencyStatus;
let installationInfo: TestToolDependencyStatus;
try {
installationInfo = await this.getInstallationInfo(installOptions);
if (!installationInfo.isInstalled) {
Expand All @@ -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) {
Expand All @@ -103,7 +123,7 @@ export class TestToolChecker implements DepsChecker {
projectPath: string,
versionRange: string,
symlinkDir: string
): Promise<DependencyStatus> {
): Promise<TestToolDependencyStatus> {
// TODO: check npm installed

const tmpVersion = `tmp-${uuid.v4().slice(0, 6)}`;
Expand All @@ -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<boolean> {
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<TestToolDependencyStatus | undefined> {
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() || "<never>";
this.telemetryProperties[TelemetryProperties.TestToolUpdatePreviousVersion] =
latestInstalledVersion || "<undefined>";
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<InstallationInfoFile | undefined> {
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(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<DependencyStatus> {
private getInstallInfoPath(projectDir: string): string {
return path.join(projectDir, "devTools", ".testTool.installInfo.json");
}
private async getSuccessDepsInfo(
version: string,
binFolder?: string
): Promise<TestToolDependencyStatus> {
return Promise.resolve({
name: this.name,
type: DepsType.TestTool,
Expand All @@ -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<DependencyStatus> {
): Promise<TestToolDependencyStatus> {
return Promise.resolve({
name: this.name,
type: DepsType.TestTool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,11 @@ export class ToolsInstallDriverImpl {
async resolveTestTool(versionRange: string, symlinkDir: string): Promise<void> {
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,
Expand Down
Loading
Loading