Skip to content

Support for custom reporters and build artifacts #151

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

Merged
merged 22 commits into from
Sep 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
38 changes: 38 additions & 0 deletions bin/commands/generateDownloads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';

const logger = require("../helpers/logger").winstonLogger,
Constants = require("../helpers/constants"),
utils = require("../helpers/utils"),
downloadBuildArtifacts = require('../helpers/buildArtifacts').downloadBuildArtifacts;


module.exports = async function generateDownloads(args) {
let bsConfigPath = utils.getConfigPath(args.cf);

return utils.validateBstackJson(bsConfigPath).then(async function (bsConfig) {
// setting setDefaults to {} if not present and set via env variables or via args.
utils.setDefaults(bsConfig, args);

// accept the username from command line if provided
utils.setUsername(bsConfig, args);

// accept the access key from command line if provided
utils.setAccessKey(bsConfig, args);

utils.setUsageReportingFlag(bsConfig, args.disableUsageReporting);

// set cypress config filename
utils.setCypressConfigFilename(bsConfig, args);

let messageType = Constants.messageTypes.INFO;
let errorCode = null;
let buildId = args._[1];

await downloadBuildArtifacts(bsConfig, buildId, args);
utils.sendUsageReport(bsConfig, args, Constants.usageReportingConstants.GENERATE_DOWNLOADS, messageType, errorCode);
}).catch(function (err) {
logger.error(err);
utils.setUsageReportingFlag(null, args.disableUsageReporting);
utils.sendUsageReport(null, args, err.message, Constants.messageTypes.ERROR, utils.getErrorCodeFromErr(err));
});
};
18 changes: 16 additions & 2 deletions bin/commands/runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const archiver = require("../helpers/archiver"),
syncRunner = require("../helpers/syncRunner"),
checkUploaded = require("../helpers/checkUploaded"),
reportGenerator = require('../helpers/reporterHTML').reportGenerator,
{initTimeComponents, markBlockStart, markBlockEnd, getTimeComponents} = require('../helpers/timeComponents');
{initTimeComponents, markBlockStart, markBlockEnd, getTimeComponents} = require('../helpers/timeComponents'),
downloadBuildArtifacts = require('../helpers/buildArtifacts').downloadBuildArtifacts;

module.exports = function run(args) {
let bsConfigPath = utils.getConfigPath(args.cf);
Expand Down Expand Up @@ -67,6 +68,9 @@ module.exports = function run(args) {

// set the no-wrap
utils.setNoWrap(bsConfig, args);

// set other cypress configs e.g. reporter and reporter-options
utils.setOtherConfigs(bsConfig, args);
markBlockEnd('setConfig');

// Validate browserstack.json values and parallels specified via arguments
Expand Down Expand Up @@ -136,17 +140,27 @@ module.exports = function run(args) {
// stop the Local instance
await utils.stopLocalBinary(bsConfig, bs_local, args);

// waiting for 5 secs for upload to complete (as a safety measure)
await new Promise(resolve => setTimeout(resolve, 5000));

// download build artifacts
if (utils.nonEmptyArray(bsConfig.run_settings.downloads)) {
await downloadBuildArtifacts(bsConfig, data.build_id, args);
}

// Generate custom report!
reportGenerator(bsConfig, data.build_id, args, function(){
utils.sendUsageReport(bsConfig, args, `${message}\n${dashboardLink}`, Constants.messageTypes.SUCCESS, null);
utils.handleSyncExit(exitCode, data.dashboard_url);
});
});
} else if (utils.nonEmptyArray(bsConfig.run_settings.downloads)) {
logger.info(Constants.userMessages.ASYNC_DOWNLOADS.replace('<build-id>', data.build_id));
}

logger.info(message);
logger.info(dashboardLink);
if(!args.sync) logger.info(Constants.userMessages.EXIT_SYNC_CLI_MESSAGE.replace("<build-id>",data.build_id));
if(!args.sync) logger.info(Constants.userMessages.EXIT_SYNC_CLI_MESSAGE.replace("<build-id>", data.build_id));
let dataToSend = {
time_components: getTimeComponents(),
build_id: data.build_id,
Expand Down
218 changes: 218 additions & 0 deletions bin/helpers/buildArtifacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
'use strict';

const fs = require('fs'),
path = require('path');

const axios = require('axios'),
unzipper = require('unzipper');

const logger = require('./logger').winstonLogger,
utils = require("./utils"),
Constants = require("./constants"),
config = require("./config");


let BUILD_ARTIFACTS_TOTAL_COUNT = 0;
let BUILD_ARTIFACTS_FAIL_COUNT = 0;

const parseAndDownloadArtifacts = async (buildId, data) => {
return new Promise(async (resolve, reject) => {
let all_promises = [];
let combs = Object.keys(data);
for(let i = 0; i < combs.length; i++) {
let comb = combs[i];
let sessions = Object.keys(data[comb]);
for(let j = 0; j < sessions.length; j++) {
let sessionId = sessions[j];
let filePath = path.join('./', 'build_artifacts', buildId, comb, sessionId);
let fileName = 'build_artifacts.zip';
BUILD_ARTIFACTS_TOTAL_COUNT += 1;
all_promises.push(downloadAndUnzip(filePath, fileName, data[comb][sessionId]).catch((error) => {
BUILD_ARTIFACTS_FAIL_COUNT += 1;
// delete malformed zip if present
let tmpFilePath = path.join(filePath, fileName);
if(fs.existsSync(tmpFilePath)){
fs.unlinkSync(tmpFilePath);
}
}));
}
}
await Promise.all(all_promises);
resolve();
});
}

const createDirIfNotPresent = async (dir) => {
return new Promise((resolve) => {
if (!fs.existsSync(dir)){
fs.mkdirSync(dir);
}
resolve();
});
}

const createDirectories = async (buildId, data) => {
// create dir for build_artifacts if not already present
let artifactsDir = path.join('./', 'build_artifacts');
if (!fs.existsSync(artifactsDir)){
fs.mkdirSync(artifactsDir);
}

// create dir for buildId if not already present
let buildDir = path.join('./', 'build_artifacts', buildId);
if (fs.existsSync(buildDir)){
// remove dir in case already exists
fs.rmdirSync(buildDir, { recursive: true, force: true });
}
fs.mkdirSync(buildDir);

let combDirs = [];
let sessionDirs = [];
let combs = Object.keys(data);

for(let i = 0; i < combs.length; i++) {
let comb = combs[i];
let combDir = path.join('./', 'build_artifacts', buildId, comb);
combDirs.push(createDirIfNotPresent(combDir));
let sessions = Object.keys(data[comb]);
for(let j = 0; j < sessions.length; j++) {
let sessionId = sessions[j];
let sessionDir = path.join('./', 'build_artifacts', buildId, comb, sessionId);
sessionDirs.push(createDirIfNotPresent(sessionDir));
}
}

return new Promise(async (resolve) => {
// create sub dirs for each combination in build
await Promise.all(combDirs);
// create sub dirs for each machine id in combination
await Promise.all(sessionDirs);
resolve();
});
}

const downloadAndUnzip = async (filePath, fileName, url) => {
let tmpFilePath = path.join(filePath, fileName);
const writer = fs.createWriteStream(tmpFilePath);

return axios({
method: 'get',
url: url,
responseType: 'stream',
}).then(response => {

//ensure that the user can call `then()` only when the file has
//been downloaded entirely.

return new Promise(async (resolve, reject) => {
response.data.pipe(writer);
let error = null;
writer.on('error', err => {
error = err;
writer.close();
reject(err);
});
writer.on('close', async () => {
if (!error) {
await unzipFile(filePath, fileName);
fs.unlinkSync(tmpFilePath);
resolve(true);
}
//no need to call the reject here, as it will have been called in the
//'error' stream;
});
});
});
}

const unzipFile = async (filePath, fileName) => {
return new Promise( async (resolve, reject) => {
await unzipper.Open.file(path.join(filePath, fileName))
.then(d => d.extract({path: filePath, concurrency: 5}))
.catch((err) => reject(err));
resolve();
});
}

const sendUpdatesToBstack = async (bsConfig, buildId, args, options) => {
let url = `${config.buildUrl}${buildId}/build_artifacts/status`;

let cypressJSON = utils.getCypressJSON(bsConfig);

let reporter = null;
if(!utils.isUndefined(args.reporter)) {
reporter = args.reporter;
} else if(cypressJSON !== undefined){
reporter = cypressJSON.reporter;
}

let data = {
feature_usage: {
downloads: {
eligible_download_folders: BUILD_ARTIFACTS_TOTAL_COUNT,
successfully_downloaded_folders: BUILD_ARTIFACTS_TOTAL_COUNT - BUILD_ARTIFACTS_FAIL_COUNT
},
reporter: reporter
}
}

try {
await axios.post(url, data, options);
} catch (err) {
utils.sendUsageReport(bsConfig, args, err, Constants.messageTypes.ERROR, 'api_failed_build_artifacts_status_update');
}
}

exports.downloadBuildArtifacts = async (bsConfig, buildId, args) => {
BUILD_ARTIFACTS_FAIL_COUNT = 0;
BUILD_ARTIFACTS_TOTAL_COUNT = 0;

let url = `${config.buildUrl}${buildId}/build_artifacts`;
let options = {
auth: {
username: bsConfig.auth.username,
password: bsConfig.auth.access_key,
},
headers: {
'User-Agent': utils.getUserAgent(),
},
};

let message = null;
let messageType = null;
let errorCode = null;

try {
const res = await axios.get(url, options);
let buildDetails = res.data;

await createDirectories(buildId, buildDetails);
await parseAndDownloadArtifacts(buildId, buildDetails);

if (BUILD_ARTIFACTS_FAIL_COUNT > 0) {
messageType = Constants.messageTypes.ERROR;
message = Constants.userMessages.DOWNLOAD_BUILD_ARTIFACTS_FAILED.replace('<build-id>', buildId).replace('<machine-count>', BUILD_ARTIFACTS_FAIL_COUNT);
logger.error(message);
} else {
messageType = Constants.messageTypes.SUCCESS;
message = Constants.userMessages.DOWNLOAD_BUILD_ARTIFACTS_SUCCESS.replace('<build-id>', buildId).replace('<user-path>', process.cwd());
logger.info(message);
}

await sendUpdatesToBstack(bsConfig, buildId, args, options);
utils.sendUsageReport(bsConfig, args, message, messageType, null);
} catch (err) {
messageType = Constants.messageTypes.ERROR;
errorCode = 'api_failed_build_artifacts';

if (BUILD_ARTIFACTS_FAIL_COUNT > 0) {
messageType = Constants.messageTypes.ERROR;
message = Constants.userMessages.DOWNLOAD_BUILD_ARTIFACTS_FAILED.replace('<build-id>', buildId).replace('<machine-count>', BUILD_ARTIFACTS_FAIL_COUNT);
logger.error(message);
} else {
logger.error('Downloading the build artifacts failed.');
}

utils.sendUsageReport(bsConfig, args, err, messageType, errorCode);
}
};
22 changes: 17 additions & 5 deletions bin/helpers/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const userMessages = {
LOCAL_STOP_FAILED: "Local Binary stop failed.",
INVALID_LOCAL_MODE_WARNING: "Invalid value specified for local_mode. local_mode: (\"always-on\" | \"on-demand\"). For more info, check out https://www.browserstack.com/docs/automate/cypress/cli-reference",
SPEC_LIMIT_WARNING: "You might not see all your results on the dashboard because of high spec count, please consider reducing the number of spec files in this folder.",
DOWNLOAD_BUILD_ARTIFACTS_FAILED: "Downloading build artifacts for the build <build-id> failed for <machine-count> machines.",
ASYNC_DOWNLOADS: "Test artifacts as specified under 'downloads' can be downloaded after the build has completed its run, using 'browserstack-cypress generate-downloads <build-id>'",
DOWNLOAD_BUILD_ARTIFACTS_SUCCESS: "Your build artifact(s) have been successfully downloaded in '<user-path>/build_artifacts/<build-id>' directory",
LATEST_SYNTAX_TO_ACTUAL_VERSION_MESSAGE: "Your build will run using Cypress <actualVersion> as you had specified <latestSyntaxVersion>. Read more about supported versions here: http://browserstack.com/docs/automate/cypress/supported-versions"
};

Expand Down Expand Up @@ -86,17 +89,13 @@ const cliMessages = {
INFO: "Check status of your build.",
STOP: "Stop your build.",
DEMAND: "Requires a build id.",
DESC: "Path to BrowserStack config",
CONFIG_DEMAND: "config file is required",
INFO_MESSAGE: "Getting information for buildId ",
STOP_MESSAGE: "Stopping build with given buildId ",
},
RUN: {
PARALLEL_DESC: "The maximum number of parallels to use to run your test suite",
INFO: "Run your tests on BrowserStack.",
DESC: "Path to BrowserStack config",
CYPRESS_DESC: "Path to Cypress config file",
CONFIG_DEMAND: "config file is required",
CYPRESS_CONFIG_DEMAND: "Cypress config file is required",
BUILD_NAME: "The build name you want to use to name your test runs",
EXCLUDE: "Exclude files matching a pattern from zipping and uploading",
Expand All @@ -110,18 +109,25 @@ const cliMessages = {
LOCAL_MODE: 'Accepted values: ("always-on" | "on-demand") - if you choose to keep the binary "always-on", it will speed up your tests by keeping the Local connection warmed up in the background; otherwise, you can choose to have it spawn and killed for every build',
LOCAL_IDENTIFIER: "Accepted values: String - assign an identifier to your Local process instance",
LOCAL_CONFIG_FILE: "Accepted values: String - path to local config-file to your Local process instance. Learn more at https://www.browserstack.com/local-testing/binary-params",
SYNC_NO_WRAP: "Wrap the spec names in --sync mode in case of smaller terminal window size pass --no-wrap"
SYNC_NO_WRAP: "Wrap the spec names in --sync mode in case of smaller terminal window size pass --no-wrap",
REPORTER: "Specify the custom reporter to use",
REPORTER_OPTIONS: "Specify reporter options for custom reporter",
},
COMMON: {
DISABLE_USAGE_REPORTING: "Disable usage reporting",
FORCE_UPLOAD: "Force the upload of your test files even if BrowserStack has detected no changes in your suite since you last ran",
USERNAME: "Your BrowserStack username",
ACCESS_KEY: "Your BrowserStack access key",
NO_NPM_WARNING: "No NPM warning if npm_dependencies is empty",
CONFIG_DEMAND: "config file is required",
CONFIG_FILE_PATH: "Path to BrowserStack config",
},
GENERATE_REPORT: {
INFO: "Generates the build report"
},
GENERATE_DOWNLOADS: {
INFO: "Downloads the build artifacts"
},
};

const messageTypes = {
Expand All @@ -147,6 +153,7 @@ const filesToIgnoreWhileUploading = [
'.vscode/**',
'.npm/**',
'.yarn/**',
'build_artifacts/**'
];

const readDirOptions = {
Expand All @@ -171,6 +178,10 @@ const DEFAULT_CYPRESS_SPEC_PATH = "cypress/integration"
const SPEC_TOTAL_CHAR_LIMIT = 32243;
const METADATA_CHAR_BUFFER_PER_SPEC = 175;

const usageReportingConstants = {
GENERATE_DOWNLOADS: 'generate-downloads called',
}

const LATEST_VERSION_SYNTAX_REGEX = /\d*.latest(.\d*)?/gm

module.exports = Object.freeze({
Expand All @@ -187,5 +198,6 @@ module.exports = Object.freeze({
DEFAULT_CYPRESS_SPEC_PATH,
SPEC_TOTAL_CHAR_LIMIT,
METADATA_CHAR_BUFFER_PER_SPEC,
usageReportingConstants,
LATEST_VERSION_SYNTAX_REGEX
});
Loading