appcenter-cli
Version:
Command line tool for Visual Studio App Center
248 lines (212 loc) • 8.54 kB
text/typescript
import { AppCenterClient, models, clientCall, clientRequest } from "../../../util/apis";
import { progressWithResult } from "./interaction";
import { TestManifest, TestRunFile } from "./test-manifest";
import { TestManifestReader } from "./test-manifest-reader";
import { AppValidator } from "./app-validator";
import { getOrgsNamesList } from "../../orgs/lib/org-users-helper";
import * as PortalHelper from "../../../util/portal/portal-helper";
import * as _ from "lodash";
import * as fs from "fs";
import * as http from "http";
import * as path from "path";
import * as request from "request";
const debug = require("debug")("appcenter-cli:commands:test:lib:test-cloud-uploader");
const pLimit = require("p-limit");
const paralleRequests = 10;
export interface StartedTestRun {
testRunId: string;
testRunUrl: string;
acceptedDevices: string[];
rejectedDevices: string[];
}
export class TestCloudUploader {
private readonly _client: AppCenterClient;
private readonly _userName: string;
private readonly _appName: string;
private readonly _manifestPath: string;
private readonly _devices: string;
private readonly _portalBaseUrl : string;
public appPath: string;
public testParameters: { [key: string]: any };
public testSeries: string;
public language: string;
public locale: string;
constructor(client: AppCenterClient, userName: string, appName: string, manifestPath: string, devices: string, portalBaseUrl: string) {
if (!client) {
throw new Error("Argument client is required");
}
if (!userName) {
throw new Error("Argument userName is required");
}
if (!appName) {
throw new Error("Argument appName is required");
}
if (!manifestPath) {
throw new Error("Argument manifestPath is required");
}
if (!devices) {
throw new Error("Argument devices is required");
}
if (!portalBaseUrl) {
throw new Error("Argument portalBaseUrl is required");
}
this._client = client;
this._manifestPath = manifestPath;
this._devices = devices;
this._userName = userName;
this._appName = appName;
this._portalBaseUrl = portalBaseUrl;
}
public async uploadAndStart(): Promise<StartedTestRun> {
const orgs = await getOrgsNamesList(this._client);
let isOrg = false;
for (const org of orgs) {
if (org.name === this._userName) {
isOrg = true;
}
}
const manifest = await progressWithResult<TestManifest>(
"Validating arguments",
this.validateAndParseManifest());
const testRun = await progressWithResult("Creating new test run", this.createTestRun(isOrg));
debug(`Test run id: ${testRun.testRunId}`);
const appFile = await progressWithResult("Validating application file", this.validateAndCreateAppFile(manifest));
const allFiles = _.concat(manifest.testFiles, [appFile]);
await progressWithResult("Uploading files", this.uploadFilesUsingBatch(testRun.testRunId, allFiles));
const startResult = await progressWithResult("Starting test run", this.startTestRun(testRun.testRunId, manifest));
testRun.acceptedDevices = startResult.acceptedDevices || [];
testRun.rejectedDevices = startResult.rejectedDevices || [];
return testRun;
}
private async validateAndParseManifest(): Promise<TestManifest> {
return await TestManifestReader.readFromFile(this._manifestPath);
}
private async validateAndCreateAppFile(manifest: TestManifest): Promise<TestRunFile> {
const result = manifest.applicationFile ?
manifest.applicationFile
: await TestRunFile.create(this.appPath, path.basename(this.appPath), "app-file");
if (!result) {
throw new Error("If test manifest doesn't contain path to application file, it must be provided using --app-path option");
}
await AppValidator.validate(result.sourcePath);
return result;
}
private createTestRun(isOrg: boolean): Promise<StartedTestRun> {
return new Promise<StartedTestRun>((resolve, reject) => {
this._client.test.createTestRun(
this._userName,
this._appName,
(err: Error, _result: any, _request: any, response: http.IncomingMessage) => {
if (err) {
if ((err as any).statusCode === 404) {
err.message = `The app named ${this._appName} does not exist in the organization or user: ${this._userName}`;
}
reject(err);
} else {
const location: string = response.headers["location"];
const testRunId = _.last(location.split("/"));
resolve({
acceptedDevices: [],
rejectedDevices: [],
testRunId: testRunId,
testRunUrl: PortalHelper.getPortalTestLink(this._portalBaseUrl, isOrg, this._userName, this._appName, this.testSeries, testRunId)
});
}
});
});
}
private async uploadFilesUsingBatch(testRunId: string, files: TestRunFile[]): Promise<void> {
const checkHashesResult = await this.uploadHashesBatch(testRunId, files.map((f) => { return { file: f }; }));
const limit = pLimit(paralleRequests);
const uploadNewFilesTasks = checkHashesResult
.filter((r) => r.response.uploadStatus.statusCode === 412)
.map((r) => limit(() => this.uploadFile(testRunId, r.file)));
await Promise.all(uploadNewFilesTasks);
}
private async uploadHashesBatch(testRunId: string, files: { file: TestRunFile, byteRange?: string }[]): Promise<{ file: TestRunFile, response: models.TestCloudFileHashResponse }[]> {
const mappedFiles = files.map((f) => this.testRunFileToFileHash(f.file, f.byteRange));
const clientResponse = await clientRequest<models.TestCloudFileHashResponse[]>((cb) => {
this._client.test.uploadHashesBatch(
testRunId,
mappedFiles,
this._userName,
this._appName,
cb);
});
return _.zip<any>(files, clientResponse.result).map((fr: any) => { return { file: fr[0].file, response: fr[1] }; });
}
private testRunFileToFileHash(file: TestRunFile, byteRange: string = null): models.TestCloudFileHash {
return {
checksum: file.sha256,
fileType: file.fileType,
relativePath: file.targetRelativePath
};
}
private async uploadFile(testRunId: string, file: TestRunFile): Promise<void> {
const directUrl = await this.getDirectUploadUrl(this._client, testRunId, file);
await this.makeDirectUpload(directUrl, file);
}
private getDirectUploadUrl(client: AppCenterClient, testRunId: string, file: TestRunFile): Promise<string> {
return new Promise<string>((resolve, reject) => {
client.test.startUploadingFile(
testRunId,
this._userName,
this._appName,
(err, _result, _request, response) => {
if (err) {
reject(err);
} else {
const location: string = response.headers["location"];
resolve(location);
}
}
);
});
}
private async makeDirectUpload(directUrl: string, file: TestRunFile): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
const formData = {
relative_path: file.targetRelativePath,
file: fs.createReadStream(file.sourcePath),
file_type: file.fileType
};
request.post({
url: directUrl,
formData: formData
},
(err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode >= 400) {
reject(new Error(`Cannot upload file. Response: ${response.statusCode}; Message: ${body}`));
} else {
resolve();
}
}
);
} catch (err) {
reject(err);
}
});
}
private startTestRun(testRunId: string, manifest: TestManifest): Promise<models.TestCloudStartTestRunResult> {
const allTestParameters = _.merge(manifest.testFramework.data || { }, this.testParameters || { });
const startOptions: models.TestCloudStartTestRunOptions = {
testFramework: manifest.testFramework.name,
deviceSelection: this._devices,
locale: this.locale,
language: this.language,
testSeries: this.testSeries,
testParameters: allTestParameters
};
return clientCall((cb) => {
this._client.test.startTestRun(
testRunId,
startOptions,
this._userName,
this._appName,
cb);
});
}
}