diff --git a/README.md b/README.md index 0c63ab5..1ab039f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ runLambdaTest { isFlutter = true //if you are running flutter dart tests appId = "lt//1234343" //provide this only if you have already uploaded the app testSuiteId = "lt//1223444" //provide this only if you have already uploaded the app + showUploadProgress = true //enable upload progress tracking in console } ``` @@ -50,6 +51,7 @@ uploadApkToLambdaTest { accessKey = 'yourLambdaTestAccessKey' appFilePath = 'pathToYourAppFile' testSuiteFilePath = 'pathToYourTestSuite' + showUploadProgress = true //enable upload progress tracking in console } ``` @@ -68,6 +70,7 @@ The following capabilities are supported: - `build`: Set the name of the Espresso test build. Example: My Espresso Build. - `geoLocation`: Set the geolocation country code if you want to enable the same in your test. Example - FR. - `tunnel`, `tunnelName`: Set tunnel as true and provide the tunnelName such as NewTunnel as needed if you are running a tunnel. +- `showUploadProgress`: Display real-time upload progress in the console with percentage and data transferred. Example: true. - `appFilePath` : Path of your app file (this will be uploaded to LambdaTest) diff --git a/build.gradle b/build.gradle index 97afcfb..f6e4de2 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'com.diffplug.spotless' version '6.25.0' } -version = '1.0.6' +version = '1.0.7' group = 'io.github.lambdatest' repositories { diff --git a/src/main/java/io/github/lambdatest/gradle/AppUploader.java b/src/main/java/io/github/lambdatest/gradle/AppUploader.java index e166fc6..d9e7941 100644 --- a/src/main/java/io/github/lambdatest/gradle/AppUploader.java +++ b/src/main/java/io/github/lambdatest/gradle/AppUploader.java @@ -19,6 +19,7 @@ public class AppUploader { private String username; private String accessKey; private String appFilePath; + private boolean showProgress; /** * Creates a new AppUploader instance with the specified credentials and file path. @@ -28,6 +29,20 @@ public class AppUploader { * @param appFilePath The path to the application file to be uploaded */ public AppUploader(String username, String accessKey, String appFilePath) { + this(username, accessKey, appFilePath, false); + } + + /** + * Creates a new AppUploader instance with the specified credentials, file path, and progress + * tracking option. + * + * @param username The LambdaTest account username + * @param accessKey The LambdaTest account access key + * @param appFilePath The path to the application file to be uploaded + * @param showProgress Whether to display upload progress in the console + */ + public AppUploader( + String username, String accessKey, String appFilePath, boolean showProgress) { if (username == null) throw new IllegalArgumentException("Username cannot be null"); if (accessKey == null) throw new IllegalArgumentException("Access Key cannot be null"); if (appFilePath == null) throw new IllegalArgumentException("App File Path cannot be null"); @@ -38,6 +53,7 @@ public AppUploader(String username, String accessKey, String appFilePath) { this.username = username; this.accessKey = accessKey; this.appFilePath = appFilePath; + this.showProgress = showProgress; } /** @@ -52,7 +68,8 @@ public CompletableFuture uploadAppAsync() { () -> { try { String appId = - UploaderUtil.uploadAndGetId(username, accessKey, appFilePath); + UploaderUtil.uploadAndGetId( + username, accessKey, appFilePath, showProgress, "App"); logger.info("Uploaded app ID: {}", appId); return appId; } catch (IOException e) { diff --git a/src/main/java/io/github/lambdatest/gradle/LambdaTestTask.java b/src/main/java/io/github/lambdatest/gradle/LambdaTestTask.java index 44af815..8c23636 100644 --- a/src/main/java/io/github/lambdatest/gradle/LambdaTestTask.java +++ b/src/main/java/io/github/lambdatest/gradle/LambdaTestTask.java @@ -45,6 +45,7 @@ public class LambdaTestTask extends DefaultTask { private String appId; private String testSuiteId; private Integer queueTimeout; + private Boolean showUploadProgress; /** * Executes the LambdaTest task, which includes uploading the application and test suite, @@ -58,22 +59,31 @@ public class LambdaTestTask extends DefaultTask { */ @TaskAction public void runLambdaTest() { - logger.info("Starting LambdaTest task..."); + boolean progressEnabled = showUploadProgress != null && showUploadProgress; + + if (!progressEnabled) { + logger.info("Starting LambdaTest task..."); + } // Upload app CompletableFuture appIdFuture = null; CompletableFuture testSuiteIdFuture = null; if (appId == null && appFilePath != null) { - logger.info("Uploading app..."); - AppUploader appUploader = new AppUploader(username, accessKey, appFilePath); + if (!progressEnabled) { + logger.info("Uploading app..."); + } + AppUploader appUploader = + new AppUploader(username, accessKey, appFilePath, progressEnabled); appIdFuture = appUploader.uploadAppAsync(); } if (testSuiteId == null && testSuiteFilePath != null) { - logger.info("Uploading test suite..."); + if (!progressEnabled) { + logger.info("Uploading test suite..."); + } TestSuiteUploader testSuiteUploader = - new TestSuiteUploader(username, accessKey, testSuiteFilePath); + new TestSuiteUploader(username, accessKey, testSuiteFilePath, progressEnabled); testSuiteIdFuture = testSuiteUploader.uploadTestSuiteAsync(); } @@ -81,14 +91,34 @@ public void runLambdaTest() { try { if (appIdFuture != null) { appId = appIdFuture.join(); - logger.info("App uploaded successfully with ID: {}", appId); + if (!progressEnabled) { + logger.info("App uploaded successfully with ID: {}", appId); + } } if (testSuiteIdFuture != null) { testSuiteId = testSuiteIdFuture.join(); - logger.info("Test suite uploaded successfully with ID: {}", testSuiteId); + if (!progressEnabled) { + logger.info("Test suite uploaded successfully with ID: {}", testSuiteId); + } + } + + // Clear progress display if enabled + if (progressEnabled) { + ProgressTracker.cleanup(); + // Show success messages after progress cleanup + if (appIdFuture != null) { + logger.info("App uploaded successfully with ID: {}", appId); + } + if (testSuiteIdFuture != null) { + logger.info("Test suite uploaded successfully with ID: {}", testSuiteId); + } } } catch (CompletionException e) { + // Cleanup progress display on error + if (progressEnabled) { + ProgressTracker.cleanup(); + } logger.error("Failed to execute tasks: {}", e); throw new RuntimeException(e); } @@ -217,4 +247,8 @@ public void setTestSuiteId(String testSuiteId) { this.testSuiteId = testSuiteId; } } + + public void setShowUploadProgress(Boolean showUploadProgress) { + this.showUploadProgress = showUploadProgress; + } } diff --git a/src/main/java/io/github/lambdatest/gradle/LambdaUploaderTask.java b/src/main/java/io/github/lambdatest/gradle/LambdaUploaderTask.java index dc65649..927b779 100644 --- a/src/main/java/io/github/lambdatest/gradle/LambdaUploaderTask.java +++ b/src/main/java/io/github/lambdatest/gradle/LambdaUploaderTask.java @@ -21,47 +21,85 @@ public class LambdaUploaderTask extends DefaultTask { private String accessKey; private String appFilePath; private String testSuiteFilePath; + private Boolean showUploadProgress; @TaskAction public void uploadApkToLambdaTest() { // Generated after upload of app and test suite - String appId; - String testSuiteId; + String appId = null; + String testSuiteId = null; CompletableFuture appIdFuture = null; CompletableFuture testSuiteIdFuture = null; - logger.lifecycle("Starting LambdaTest APK Uploader task..."); + + boolean progressEnabled = showUploadProgress != null && showUploadProgress; + + // Only log to lifecycle if progress is disabled + if (!progressEnabled) { + logger.lifecycle("Starting LambdaTest APK Uploader task..."); + } if (appFilePath != null) { - logger.lifecycle("Uploading app ..."); - AppUploader appUploader = new AppUploader(username, accessKey, appFilePath); + if (!progressEnabled) { + logger.lifecycle("Uploading app ..."); + } + AppUploader appUploader = + new AppUploader(username, accessKey, appFilePath, progressEnabled); appIdFuture = appUploader.uploadAppAsync(); } if (testSuiteFilePath != null) { - logger.lifecycle("Uploading test suite ..."); + if (!progressEnabled) { + logger.lifecycle("Uploading test suite ..."); + } TestSuiteUploader testSuiteUploader = - new TestSuiteUploader(username, accessKey, testSuiteFilePath); + new TestSuiteUploader(username, accessKey, testSuiteFilePath, progressEnabled); testSuiteIdFuture = testSuiteUploader.uploadTestSuiteAsync(); } try { if (appIdFuture != null) { appId = appIdFuture.join(); - logger.lifecycle("\u001B[32mApp uploaded successfully with ID: {}\u001B[0m", appId); + if (!progressEnabled) { + logger.lifecycle( + "\u001B[32mApp uploaded successfully with ID: {}\u001B[0m", appId); + } } if (testSuiteIdFuture != null) { testSuiteId = testSuiteIdFuture.join(); - logger.lifecycle( - "\u001B[32mTest suite uploaded successfully with ID: {}\u001B[0m", - testSuiteId); + if (!progressEnabled) { + logger.lifecycle( + "\u001B[32mTest suite uploaded successfully with ID: {}\u001B[0m", + testSuiteId); + } + } + + // Clear progress display if enabled + if (progressEnabled) { + ProgressTracker.cleanup(); + // Show success messages after progress cleanup + if (appIdFuture != null) { + logger.lifecycle( + "\u001B[32mApp uploaded successfully with ID: {}\u001B[0m", appId); + } + if (testSuiteIdFuture != null) { + logger.lifecycle( + "\u001B[32mTest suite uploaded successfully with ID: {}\u001B[0m", + testSuiteId); + } } } catch (CompletionException e) { + // Cleanup progress display on error + if (progressEnabled) { + ProgressTracker.cleanup(); + } logger.error("Failed to execute LambdaTest APK Uploader task : {}", e); throw new RuntimeException(e); } - logger.lifecycle("Completed LambdaTest APK Uploader task ..."); + if (!progressEnabled) { + logger.lifecycle("Completed LambdaTest APK Uploader task ..."); + } } // Setter functions for the task @@ -80,4 +118,8 @@ public void setAppFilePath(String appFilePath) { public void setTestSuiteFilePath(String testSuiteFilePath) { this.testSuiteFilePath = testSuiteFilePath; } + + public void setShowUploadProgress(Boolean showUploadProgress) { + this.showUploadProgress = showUploadProgress; + } } diff --git a/src/main/java/io/github/lambdatest/gradle/ProgressRequestBody.java b/src/main/java/io/github/lambdatest/gradle/ProgressRequestBody.java new file mode 100644 index 0000000..501054a --- /dev/null +++ b/src/main/java/io/github/lambdatest/gradle/ProgressRequestBody.java @@ -0,0 +1,125 @@ +package io.github.lambdatest.gradle; + +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.Buffer; +import okio.BufferedSink; +import okio.ForwardingSink; +import okio.Okio; +import okio.Sink; +import org.jetbrains.annotations.NotNull; + +/** + * A RequestBody wrapper that tracks upload progress and displays it in the console. This class uses + * OkHttp's native Sink and BufferedSink APIs to monitor the upload progress. + */ +public class ProgressRequestBody extends RequestBody { + + private final RequestBody delegate; + private final ProgressCallback progressCallback; + + /** Interface for progress callbacks. */ + public interface ProgressCallback { + void onProgress(long bytesWritten, long totalBytes, float percentage); + } + + /** + * Creates a new ProgressRequestBody that wraps the given RequestBody. + * + * @param delegate The original RequestBody to wrap + * @param progressCallback Callback to receive progress updates + */ + public ProgressRequestBody(RequestBody delegate, ProgressCallback progressCallback) { + this.delegate = delegate; + this.progressCallback = progressCallback; + } + + @Override + public MediaType contentType() { + return delegate.contentType(); + } + + @Override + public long contentLength() throws IOException { + return delegate.contentLength(); + } + + @Override + public void writeTo(@NotNull BufferedSink sink) throws IOException { + long contentLength = contentLength(); + ProgressSink progressSink = new ProgressSink(sink, contentLength, progressCallback); + BufferedSink bufferedSink = Okio.buffer(progressSink); + + delegate.writeTo(bufferedSink); + bufferedSink.flush(); + } + + /** + * Custom Sink implementation that tracks progress while forwarding data to the original sink. + */ + private static class ProgressSink extends ForwardingSink { + private final long totalBytes; + private final ProgressCallback progressCallback; + private long bytesWritten = 0L; + private long lastLoggedPercentage = -1L; + private long lastUpdateTime = System.currentTimeMillis(); + + public ProgressSink(Sink delegate, long totalBytes, ProgressCallback progressCallback) { + super(delegate); + this.totalBytes = totalBytes; + this.progressCallback = progressCallback; + } + + @Override + public void write(@NotNull Buffer source, long byteCount) throws IOException { + super.write(source, byteCount); + bytesWritten += byteCount; + + if (progressCallback != null) { + float percentage = totalBytes > 0 ? (bytesWritten * 100.0f) / totalBytes : 0f; + long currentTime = System.currentTimeMillis(); + + // Update every 1% or every 250ms for more real-time updates + long currentPercentage = Math.round(percentage); + boolean timeBased = (currentTime - lastUpdateTime) >= 250; // 250ms intervals + + if (currentPercentage != lastLoggedPercentage || timeBased) { + progressCallback.onProgress(bytesWritten, totalBytes, percentage); + lastLoggedPercentage = currentPercentage; + lastUpdateTime = currentTime; + } + } + } + } + + /** + * Creates a console-based progress callback that displays upload progress using the + * ProgressTracker for clean, fixed-line output. + * + * @param uploadId The unique identifier for this upload (e.g., "App", "Test Suite") + * @return A ProgressCallback that logs to console + */ + public static ProgressCallback createConsoleCallback(String uploadId) { + return (bytesWritten, totalBytes, percentage) -> { + ProgressTracker.updateProgress(uploadId, percentage, bytesWritten, totalBytes); + + if (percentage >= 100.0f) { + ProgressTracker.completeUpload(uploadId); + } + }; + } + + /** + * Formats bytes into human-readable format. + * + * @param bytes The number of bytes to format + * @return Formatted string (e.g., "1.5 MB", "512 KB") + */ + public static String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } +} diff --git a/src/main/java/io/github/lambdatest/gradle/ProgressTracker.java b/src/main/java/io/github/lambdatest/gradle/ProgressTracker.java new file mode 100644 index 0000000..b5936da --- /dev/null +++ b/src/main/java/io/github/lambdatest/gradle/ProgressTracker.java @@ -0,0 +1,140 @@ +package io.github.lambdatest.gradle; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Manages progress tracking for multiple concurrent uploads with clean, fixed-line console output. + * This class ensures that concurrent uploads don't clutter the terminal by maintaining fixed + * positions for each upload's progress line. + */ +public class ProgressTracker { + private static final Map uploadLines = new ConcurrentHashMap<>(); + private static final AtomicInteger nextLineNumber = new AtomicInteger(0); + private static final Object consoleLock = new Object(); + + /** + * Registers a new upload and allocates a line number for its progress display. + * + * @param uploadId Unique identifier for the upload (e.g., "App", "Test Suite") + * @return The allocated line number for this upload + */ + private static int registerUpload(String uploadId) { + return uploadLines.computeIfAbsent( + uploadId, + k -> { + int lineNum = nextLineNumber.getAndIncrement(); + // Print a newline to reserve space for this upload + System.out.println(); + System.out.flush(); + return lineNum; + }); + } + + /** + * Updates the progress display for a specific upload. + * + * @param uploadId The unique identifier for the upload + * @param percentage The upload percentage (0-100) + * @param bytesWritten Bytes uploaded so far + * @param totalBytes Total bytes to upload + */ + public static void updateProgress( + String uploadId, float percentage, long bytesWritten, long totalBytes) { + synchronized (consoleLock) { + // Register upload if not already registered (reserves a line) + int lineNumber = registerUpload(uploadId); + int totalLines = uploadLines.size(); + + // Move cursor to the appropriate line + if (lineNumber < totalLines - 1) { + // Not the last line - need to move up + System.out.printf("\u001B[%dA", totalLines - lineNumber - 1); + } + + // Clear the line and print progress + System.out.print("\r\u001B[K"); // Clear line + String progressBar = createProgressBar(percentage); + String formattedBytes = ProgressRequestBody.formatBytes(bytesWritten); + String formattedTotal = ProgressRequestBody.formatBytes(totalBytes); + + System.out.printf( + "\u001B[33mUploading %-15s %s %.1f%% (%s / %s)\u001B[0m", + uploadId, progressBar, percentage, formattedBytes, formattedTotal); + + // Move cursor back to bottom + if (lineNumber < totalLines - 1) { + System.out.printf("\u001B[%dB", totalLines - lineNumber - 1); + } + + System.out.flush(); + } + } + + /** + * Creates a visual progress bar. + * + * @param percentage The completion percentage (0-100) + * @return A string representing the progress bar + */ + private static String createProgressBar(float percentage) { + int barLength = 10; + int filled = (int) (percentage / 100.0 * barLength); + StringBuilder bar = new StringBuilder("["); + for (int i = 0; i < barLength; i++) { + bar.append(i < filled ? "#" : "-"); + } + bar.append("]"); + return bar.toString(); + } + + /** + * Completes the progress for an upload and moves to a new line. + * + * @param uploadId The unique identifier for the upload + */ + public static void completeUpload(String uploadId) { + synchronized (consoleLock) { + Integer lineNumber = uploadLines.get(uploadId); + if (lineNumber != null && lineNumber == uploadLines.size() - 1) { + // Only add newline if this is the last upload + System.out.println(); + System.out.flush(); + } + } + } + + /** + * Cleans up all progress lines from the console and resets the tracker. This should be called + * after all uploads are complete to clear the progress display. + */ + public static void cleanup() { + synchronized (consoleLock) { + int totalLines = uploadLines.size(); + if (totalLines > 0) { + // Move to the first line + System.out.printf("\u001B[%dA", totalLines - 1); + // Clear all progress lines + for (int i = 0; i < totalLines; i++) { + System.out.print("\r\u001B[K"); // Clear current line + if (i < totalLines - 1) { + System.out.println(); // Move to next line + } + } + // Move back to start position + System.out.printf("\u001B[%dA", totalLines - 1); + System.out.flush(); + } + reset(); + } + } + + /** Resets the progress tracker. Should be called when starting a new set of uploads. */ + public static void reset() { + synchronized (consoleLock) { + uploadLines.clear(); + nextLineNumber.set(0); + } + } +} diff --git a/src/main/java/io/github/lambdatest/gradle/TestSuiteUploader.java b/src/main/java/io/github/lambdatest/gradle/TestSuiteUploader.java index b388a9b..cf8c92d 100644 --- a/src/main/java/io/github/lambdatest/gradle/TestSuiteUploader.java +++ b/src/main/java/io/github/lambdatest/gradle/TestSuiteUploader.java @@ -16,6 +16,7 @@ public class TestSuiteUploader { private String username; private String accessKey; private String testSuiteFilePath; + private boolean showProgress; /** * Creates a new TestSuiteUploader instance with the specified credentials and file path. @@ -25,6 +26,20 @@ public class TestSuiteUploader { * @param testSuiteFilePath The path to the test suite file to be uploaded */ public TestSuiteUploader(String username, String accessKey, String testSuiteFilePath) { + this(username, accessKey, testSuiteFilePath, false); + } + + /** + * Creates a new TestSuiteUploader instance with the specified credentials, file path, and + * progress tracking option. + * + * @param username The LambdaTest account username + * @param accessKey The LambdaTest account access key + * @param testSuiteFilePath The path to the test suite file to be uploaded + * @param showProgress Whether to display upload progress in the console + */ + public TestSuiteUploader( + String username, String accessKey, String testSuiteFilePath, boolean showProgress) { if (username == null) throw new IllegalArgumentException("Username cannot be null"); if (accessKey == null) throw new IllegalArgumentException("Access Key cannot be null"); if (testSuiteFilePath == null) @@ -36,6 +51,7 @@ public TestSuiteUploader(String username, String accessKey, String testSuiteFile this.username = username; this.accessKey = accessKey; this.testSuiteFilePath = testSuiteFilePath; + this.showProgress = showProgress; } /** @@ -50,7 +66,12 @@ public CompletableFuture uploadTestSuiteAsync() { () -> { try { String testSuiteId = - UploaderUtil.uploadAndGetId(username, accessKey, testSuiteFilePath); + UploaderUtil.uploadAndGetId( + username, + accessKey, + testSuiteFilePath, + showProgress, + "Test Suite"); logger.info("Uploaded test suite ID: {}", testSuiteId); return testSuiteId; } catch (IOException e) { diff --git a/src/main/java/io/github/lambdatest/gradle/UploaderUtil.java b/src/main/java/io/github/lambdatest/gradle/UploaderUtil.java index d692d42..167e29d 100644 --- a/src/main/java/io/github/lambdatest/gradle/UploaderUtil.java +++ b/src/main/java/io/github/lambdatest/gradle/UploaderUtil.java @@ -40,6 +40,50 @@ private UploaderUtil() { */ public static String uploadAndGetId(String username, String accessKey, String filePath) throws IOException { + return uploadAndGetId(username, accessKey, filePath, false, null); + } + + /** + * Uploads a file to LambdaTest and returns its ID with optional progress tracking. + * + * @implNote This method sends the file to {@link Constants#API_URL} and handles the multipart + * form data construction and response parsing. When showProgress is true, it uses {@link + * ProgressRequestBody} to track and display upload progress. + * @param username The LambdaTest account username + * @param accessKey The LambdaTest account access key + * @param filePath The path to the file to be uploaded + * @param showProgress Whether to display upload progress in the console + * @return The ID of the uploaded file + * @throws IOException if there's an error during file upload or response parsing + */ + public static String uploadAndGetId( + String username, String accessKey, String filePath, boolean showProgress) + throws IOException { + return uploadAndGetId(username, accessKey, filePath, showProgress, null); + } + + /** + * Uploads a file to LambdaTest and returns its ID with optional progress tracking and custom + * prefix. + * + * @implNote This method sends the file to {@link Constants#API_URL} and handles the multipart + * form data construction and response parsing. When showProgress is true, it uses {@link + * ProgressRequestBody} to track and display upload progress. + * @param username The LambdaTest account username + * @param accessKey The LambdaTest account access key + * @param filePath The path to the file to be uploaded + * @param showProgress Whether to display upload progress in the console + * @param progressPrefix Optional prefix for progress display (e.g., "App", "Test Suite") + * @return The ID of the uploaded file + * @throws IOException if there's an error during file upload or response parsing + */ + public static String uploadAndGetId( + String username, + String accessKey, + String filePath, + boolean showProgress, + String progressPrefix) + throws IOException { OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(1, TimeUnit.MINUTES) // Increase connection timeout @@ -47,16 +91,24 @@ public static String uploadAndGetId(String username, String accessKey, String fi .writeTimeout(0, TimeUnit.MILLISECONDS) // Increase write timeout .build(); + File file = new File(filePath); MediaType mediaType = MediaType.parse("application/octet-stream"); + RequestBody fileRequestBody = RequestBody.create(file, mediaType); + RequestBody body = new MultipartBody.Builder() .setType(MultipartBody.FORM) - .addFormDataPart( - "appFile", - filePath, - RequestBody.create(new File(filePath), mediaType)) + .addFormDataPart("appFile", filePath, fileRequestBody) .addFormDataPart("type", "espresso-android") .build(); + + // Wrap the entire multipart body with progress tracking if requested + if (showProgress) { + String uploadId = progressPrefix != null ? progressPrefix : "Upload"; + ProgressRequestBody.ProgressCallback callback = + ProgressRequestBody.createConsoleCallback(uploadId); + body = new ProgressRequestBody(body, callback); + } Request request = new Request.Builder() .url(Constants.API_URL)