From 828ecb08466a22787705d271e10f4a00b117255a Mon Sep 17 00:00:00 2001 From: Pushpak-Jaiswal Date: Tue, 29 Sep 2026 20:32:09 +0530 Subject: [PATCH 1/2] Feat: added CLI Friendly output format and json --- README.md | 193 +++++++++++++- .../java/com/seleniumboot/migrator/Cli.java | 183 ++++++++++++-- .../seleniumboot/migrator/JsonRenderer.java | 152 +++++++++++ .../com/seleniumboot/migrator/Report.java | 5 + .../com/seleniumboot/migrator/CliTest.java | 235 ++++++++++++++++++ .../seleniumboot/migrator/ReportJsonTest.java | 91 +++++++ 6 files changed, 838 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/seleniumboot/migrator/JsonRenderer.java create mode 100644 src/test/java/com/seleniumboot/migrator/CliTest.java create mode 100644 src/test/java/com/seleniumboot/migrator/ReportJsonTest.java diff --git a/README.md b/README.md index 605884e..d64c59b 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,23 @@ your machine. ```bash mvn package + +# Text report (default) java -jar target/selenium-boot-migrator.jar analyze ./my-selenium-project + +# JSON output for tooling / CI +java -jar target/selenium-boot-migrator.jar analyze ./my-selenium-project --format json + +# Enforce a minimum confidence threshold in CI +java -jar target/selenium-boot-migrator.jar analyze ./my-selenium-project --fail-under 80 + +# Combine format and threshold flags +java -jar target/selenium-boot-migrator.jar analyze ./my-selenium-project --format json --fail-under 80 + +# Apply mechanical migrations java -jar target/selenium-boot-migrator.jar migrate ./my-selenium-project --out ./my-selenium-project-migrated ``` - The output directory must not already exist and cannot be the source directory or one of its children. Migration output lists applied changes, compatibility notes, and any findings still requiring manual review. The POM rewrite uses the published Selenium Boot `3.5.0` release. @@ -26,6 +38,185 @@ The estimate is a guide, not a guarantee. The report also lists dependencies fou `pom.xml` files and Gradle `build.gradle` / `build.gradle.kts` files. Gradle files are inspected as text; a Gradle installation is not required. +### CLI Options + +#### `analyze ` + +- `--format `: Output format. Defaults to `text`. +- `--fail-under `: Threshold percentage between `0` and `100`. If estimated confidence is strictly less than this threshold, the command exits with code `1`. The report is always printed before exiting. + +#### `migrate --out ` + +- `--out `: Target directory for migrated copy. Must not already exist. + +### Exit Codes + +| Exit Code | Description | +|---|---| +| `0` | Success: command completed normally and estimated confidence meets `--fail-under` (if specified). | +| `1` | Threshold failure: estimated confidence is strictly below the `--fail-under` percentage. | +| `2` | Usage or input error: invalid arguments, unsupported format, invalid threshold, missing directory, or directory not found. | + +## Machine-Readable Output (JSON) + +Use `--format json` to integrate analysis with CI pipelines, code review bots, and migration dashboards: + +```bash +java -jar target/selenium-boot-migrator.jar analyze ./my-selenium-project --format json +``` + +### JSON Schema + +The output adheres to the following stable schema: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SeleniumBootMigrationReport", + "type": "object", + "required": [ + "filesFound", + "filesParsed", + "unparsableFiles", + "detectedTechnologies", + "recognizedTechnologies", + "locatorCounts", + "summary", + "estimatedConfidence", + "findings" + ], + "properties": { + "filesFound": { + "type": "integer", + "minimum": 0, + "description": "Total number of .java files discovered." + }, + "filesParsed": { + "type": "integer", + "minimum": 0, + "description": "Number of .java files successfully parsed." + }, + "unparsableFiles": { + "type": "array", + "items": { "type": "string" }, + "description": "Normalized paths of files that could not be parsed." + }, + "detectedTechnologies": { + "type": "array", + "items": { "type": "string" }, + "description": "Build systems and dependency coordinates detected." + }, + "recognizedTechnologies": { + "type": "array", + "items": { "type": "string" }, + "description": "Recognized testing frameworks and libraries (e.g. Selenium 4.21.0, TestNG)." + }, + "locatorCounts": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "description": "Counts per Selenium locator method (e.g. By.id, By.xpath)." + }, + "summary": { + "type": "object", + "required": ["mapsCleanly", "manualReviewRequired", "unparsableFiles", "byRule"], + "properties": { + "mapsCleanly": { + "type": "integer", + "minimum": 0, + "description": "Total count of AUTO findings that map cleanly to Selenium Boot." + }, + "manualReviewRequired": { + "type": "integer", + "minimum": 0, + "description": "Total count of MANUAL findings requiring human attention." + }, + "unparsableFiles": { + "type": "integer", + "minimum": 0, + "description": "Count of unparsable source files." + }, + "byRule": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "description": "Counts of findings grouped by rule ID." + } + } + }, + "estimatedConfidence": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Confidence score percentage (0-100)." + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["ruleId", "status", "file", "line", "detected", "advice"], + "properties": { + "ruleId": { "type": "string", "description": "Migration rule ID (e.g. MIG-001)." }, + "status": { "type": "string", "enum": ["AUTO", "MANUAL"], "description": "Mapping status." }, + "file": { "type": "string", "description": "Normalized relative file path." }, + "line": { "type": "integer", "minimum": 1, "description": "1-based line number of finding." }, + "detected": { "type": "string", "description": "Code snippet or construct detected." }, + "advice": { "type": "string", "description": "Suggested migration action." } + } + } + } + } +} +``` + +### Example JSON Output + +```json +{ + "filesFound": 4, + "filesParsed": 4, + "unparsableFiles": [], + "detectedTechnologies": [ + "Build system: Maven", + "Dependency: org.seleniumhq.selenium:selenium-java:4.21.0", + "Dependency: org.testng:testng:7.10.2" + ], + "recognizedTechnologies": [ + "Selenium 4.21.0", + "TestNG" + ], + "locatorCounts": { + "By.id": 3, + "By.xpath": 1 + }, + "summary": { + "mapsCleanly": 1, + "manualReviewRequired": 1, + "unparsableFiles": 0, + "byRule": { + "MIG-001": 1, + "MIG-003": 1 + } + }, + "estimatedConfidence": 75, + "findings": [ + { + "ruleId": "MIG-001", + "status": "AUTO", + "file": "src/test/java/DriverFactory.java", + "line": 12, + "detected": "ThreadLocal", + "advice": "Delete the driver factory; extend BaseTest (per-thread isolation is built in)." + }, + { + "ruleId": "MIG-003", + "status": "MANUAL", + "file": "src/test/java/LoginPage.java", + "line": 28, + "detected": "WebDriverWait / ExpectedConditions", + "advice": "Use $(locator) auto-wait or getWait(); review the condition by hand." + } + ] +} +``` ## Rules diff --git a/src/main/java/com/seleniumboot/migrator/Cli.java b/src/main/java/com/seleniumboot/migrator/Cli.java index d21081f..a713705 100644 --- a/src/main/java/com/seleniumboot/migrator/Cli.java +++ b/src/main/java/com/seleniumboot/migrator/Cli.java @@ -1,36 +1,179 @@ package com.seleniumboot.migrator; +import java.io.PrintStream; import java.nio.file.Files; import java.nio.file.Path; public final class Cli { public static void main(String[] args) throws Exception { - if (args.length == 2 && args[0].equals("analyze")) { - Path dir = Path.of(args[1]); - if (!Files.isDirectory(dir)) { - System.err.println("not a directory: " + dir); - System.exit(2); + int exitCode = run(args, System.out, System.err); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + public static int run(String[] args, PrintStream out, PrintStream err) throws Exception { + if (args.length == 0) { + printUsage(err); + return 2; + } + + String command = args[0]; + if (command.equals("analyze")) { + return runAnalyze(args, out, err); + } else if (command.equals("migrate")) { + return runMigrate(args, out, err); + } else { + err.println("unknown command: " + command); + printUsage(err); + return 2; + } + } + + private static int runAnalyze(String[] args, PrintStream out, PrintStream err) throws Exception { + Path dir = null; + String format = "text"; + Integer failUnder = null; + + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + if (arg.equals("--format")) { + if (i + 1 >= args.length) { + err.println("missing value for --format"); + printUsage(err); + return 2; + } + format = args[++i]; + } else if (arg.startsWith("--format=")) { + format = arg.substring("--format=".length()); + } else if (arg.equals("--fail-under")) { + if (i + 1 >= args.length) { + err.println("missing value for --fail-under"); + printUsage(err); + return 2; + } + String val = args[++i]; + Integer parsed = parseThreshold(val, err); + if (parsed == null) return 2; + failUnder = parsed; + } else if (arg.startsWith("--fail-under=")) { + String val = arg.substring("--fail-under=".length()); + Integer parsed = parseThreshold(val, err); + if (parsed == null) return 2; + failUnder = parsed; + } else if (arg.startsWith("-")) { + err.println("unknown option: " + arg); + printUsage(err); + return 2; + } else { + if (dir == null) { + dir = Path.of(arg); + } else { + err.println("unexpected argument: " + arg); + printUsage(err); + return 2; + } + } + } + + if (!format.equalsIgnoreCase("text") && !format.equalsIgnoreCase("json")) { + err.println("unsupported format: " + format + " (expected 'text' or 'json')"); + printUsage(err); + return 2; + } + + if (dir == null) { + err.println("missing project directory"); + printUsage(err); + return 2; + } + + if (!Files.isDirectory(dir)) { + err.println("not a directory: " + dir); + return 2; + } + + Report report = new Analyzer().analyze(dir); + if (format.equalsIgnoreCase("json")) { + out.print(report.toJson()); + } else { + out.print(report.render()); + } + + if (failUnder != null && report.estimatedConfidence() < failUnder) { + return 1; + } + + return 0; + } + + private static Integer parseThreshold(String value, PrintStream err) { + try { + int threshold = Integer.parseInt(value); + if (threshold < 0 || threshold > 100) { + err.println("invalid threshold for --fail-under: " + value + " (must be between 0 and 100)"); + return null; } - System.out.print(new Analyzer().analyze(dir).render()); - return; + return threshold; + } catch (NumberFormatException exception) { + err.println("invalid threshold for --fail-under: " + value + " (must be an integer between 0 and 100)"); + return null; } - if (args.length != 4 || !args[0].equals("migrate") || !args[2].equals("--out")) { - System.err.println("usage: selenium-boot-migrator analyze \n" - + " selenium-boot-migrator migrate --out "); - System.exit(2); + } + + private static int runMigrate(String[] args, PrintStream out, PrintStream err) throws Exception { + Path projectDir = null; + Path outputDir = null; + + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + if (arg.equals("--out")) { + if (i + 1 >= args.length) { + err.println("missing value for --out"); + printUsage(err); + return 2; + } + outputDir = Path.of(args[++i]); + } else if (arg.startsWith("--out=")) { + outputDir = Path.of(arg.substring("--out=".length())); + } else if (arg.startsWith("-")) { + err.println("unknown option: " + arg); + printUsage(err); + return 2; + } else { + if (projectDir == null) { + projectDir = Path.of(arg); + } else { + err.println("unexpected argument: " + arg); + printUsage(err); + return 2; + } + } + } + + if (projectDir == null || outputDir == null) { + printUsage(err); + return 2; } + try { - var result = new Migrator().migrate(Path.of(args[1]), Path.of(args[3])); - System.out.println("Migrated copy: " + result.output()); - System.out.println("Applied changes: " + result.applied().size()); - result.applied().forEach(change -> System.out.println(" [applied] " + change)); - result.notes().forEach(note -> System.out.println(" [note] " + note)); - System.out.println("\nItems still requiring review:"); - System.out.print(result.remaining().render()); + var result = new Migrator().migrate(projectDir, outputDir); + out.println("Migrated copy: " + result.output()); + out.println("Applied changes: " + result.applied().size()); + result.applied().forEach(change -> out.println(" [applied] " + change)); + result.notes().forEach(note -> out.println(" [note] " + note)); + out.println("\nItems still requiring review:"); + out.print(result.remaining().render()); + return 0; } catch (IllegalArgumentException exception) { - System.err.println(exception.getMessage()); - System.exit(2); + err.println(exception.getMessage()); + return 2; } } + + private static void printUsage(PrintStream err) { + err.println("usage: selenium-boot-migrator analyze [--format ] [--fail-under ]\n" + + " selenium-boot-migrator migrate --out "); + } } diff --git a/src/main/java/com/seleniumboot/migrator/JsonRenderer.java b/src/main/java/com/seleniumboot/migrator/JsonRenderer.java new file mode 100644 index 0000000..07915ac --- /dev/null +++ b/src/main/java/com/seleniumboot/migrator/JsonRenderer.java @@ -0,0 +1,152 @@ +package com.seleniumboot.migrator; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +final class JsonRenderer { + + private JsonRenderer() { } + + static String render(Report report) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"filesFound\": ").append(report.filesFound()).append(",\n"); + sb.append(" \"filesParsed\": ").append(report.filesParsed()).append(",\n"); + + sb.append(" \"unparsableFiles\": "); + appendNormalizedStringList(sb, report.unparsable(), " "); + sb.append(",\n"); + + sb.append(" \"detectedTechnologies\": "); + appendStringList(sb, report.detectedTechnologies(), " "); + sb.append(",\n"); + + sb.append(" \"recognizedTechnologies\": "); + appendStringList(sb, report.recognizedTechnologies(), " "); + sb.append(",\n"); + + sb.append(" \"locatorCounts\": "); + appendNumberMap(sb, report.locatorCounts(), " "); + sb.append(",\n"); + + Map byRule = new TreeMap<>(); + report.findings().forEach(f -> byRule.merge(f.ruleId(), 1L, Long::sum)); + + sb.append(" \"summary\": {\n"); + sb.append(" \"mapsCleanly\": ").append(report.count(Finding.Status.AUTO)).append(",\n"); + sb.append(" \"manualReviewRequired\": ").append(report.count(Finding.Status.MANUAL)).append(",\n"); + sb.append(" \"unparsableFiles\": ").append(report.unparsable().size()).append(",\n"); + sb.append(" \"byRule\": "); + appendNumberMap(sb, byRule, " "); + sb.append("\n },\n"); + + sb.append(" \"estimatedConfidence\": ").append(report.estimatedConfidence()).append(",\n"); + + sb.append(" \"findings\": "); + if (report.findings().isEmpty()) { + sb.append("[]\n"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.findings().size(); i++) { + Finding finding = report.findings().get(i); + sb.append(" {\n"); + sb.append(" \"ruleId\": ").append(escapeString(finding.ruleId())).append(",\n"); + sb.append(" \"status\": ").append(escapeString(finding.status().name())).append(",\n"); + sb.append(" \"file\": ").append(escapeString(normalizePath(finding.file()))).append(",\n"); + sb.append(" \"line\": ").append(finding.line()).append(",\n"); + sb.append(" \"detected\": ").append(escapeString(finding.detected())).append(",\n"); + sb.append(" \"advice\": ").append(escapeString(finding.advice())).append("\n"); + sb.append(" }"); + if (i < report.findings().size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(" ]\n"); + } + + sb.append("}\n"); + return sb.toString(); + } + + private static void appendStringList(StringBuilder sb, List list, String indent) { + if (list.isEmpty()) { + sb.append("[]"); + return; + } + sb.append("[\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(indent).append(" ").append(escapeString(list.get(i))); + if (i < list.size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(indent).append("]"); + } + + private static void appendNormalizedStringList(StringBuilder sb, List list, String indent) { + if (list.isEmpty()) { + sb.append("[]"); + return; + } + sb.append("[\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(indent).append(" ").append(escapeString(normalizePath(list.get(i)))); + if (i < list.size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(indent).append("]"); + } + + private static void appendNumberMap(StringBuilder sb, Map map, String indent) { + if (map.isEmpty()) { + sb.append("{}"); + return; + } + sb.append("{\n"); + int count = 0; + for (Map.Entry entry : map.entrySet()) { + sb.append(indent).append(" ").append(escapeString(entry.getKey())) + .append(": ").append(entry.getValue()); + if (++count < map.size()) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(indent).append("}"); + } + + private static String normalizePath(String path) { + return path == null ? "" : path.replace('\\', '/'); + } + + static String escapeString(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + sb.append("\""); + return sb.toString(); + } +} diff --git a/src/main/java/com/seleniumboot/migrator/Report.java b/src/main/java/com/seleniumboot/migrator/Report.java index c331125..aabab70 100644 --- a/src/main/java/com/seleniumboot/migrator/Report.java +++ b/src/main/java/com/seleniumboot/migrator/Report.java @@ -89,6 +89,11 @@ public String render() { return sb.toString(); } + public String toJson() { + return JsonRenderer.render(this); + } + + private static String ruleLabel(String ruleId) { return switch (ruleId) { case "MIG-010" -> "MIG-010 (Page objects)"; diff --git a/src/test/java/com/seleniumboot/migrator/CliTest.java b/src/test/java/com/seleniumboot/migrator/CliTest.java new file mode 100644 index 0000000..c3d3150 --- /dev/null +++ b/src/test/java/com/seleniumboot/migrator/CliTest.java @@ -0,0 +1,235 @@ +package com.seleniumboot.migrator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class CliTest { + + private static Path fixture(String name) throws URISyntaxException { + return Path.of(CliTest.class.getResource("/" + name).toURI()); + } + + private record RunResult(int exitCode, String out, String err) { } + + private static RunResult runCli(String... args) throws Exception { + ByteArrayOutputStream outBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); + try (PrintStream out = new PrintStream(outBytes, true, StandardCharsets.UTF_8); + PrintStream err = new PrintStream(errBytes, true, StandardCharsets.UTF_8)) { + int code = Cli.run(args, out, err); + return new RunResult(code, outBytes.toString(StandardCharsets.UTF_8), errBytes.toString(StandardCharsets.UTF_8)); + } + } + + @Test + void analyzeDefaultsToTextAndExitsZero() throws Exception { + Path path = fixture("maven"); + RunResult result = runCli("analyze", path.toString()); + + assertEquals(0, result.exitCode()); + assertTrue(result.out().contains("Selenium Boot Migration Analysis")); + assertTrue(result.out().contains("Files found:")); + assertTrue(result.err().isEmpty()); + } + + @Test + void analyzeWithFormatJsonOutputsValidJsonAndExitsZero() throws Exception { + Path path = fixture("maven"); + RunResult result = runCli("analyze", path.toString(), "--format", "json"); + + assertEquals(0, result.exitCode()); + assertTrue(result.out().trim().startsWith("{")); + assertTrue(result.out().trim().endsWith("}")); + assertTrue(result.out().contains("\"filesFound\":")); + assertTrue(result.out().contains("\"summary\":")); + assertTrue(result.out().contains("\"estimatedConfidence\":")); + assertTrue(result.err().isEmpty()); + } + + @Test + void analyzeAcceptsFormatWithEqualSignAndCaseInsensitive() throws Exception { + Path path = fixture("maven"); + RunResult result = runCli("analyze", "--format=JSON", path.toString()); + + assertEquals(0, result.exitCode()); + assertTrue(result.out().contains("\"filesFound\":")); + assertTrue(result.err().isEmpty()); + } + + @Test + void analyzeFailUnderExitsZeroWhenConfidenceMeetsThreshold() throws Exception { + Path path = fixture("maven"); + RunResult result = runCli("analyze", path.toString(), "--fail-under", "50"); + + assertEquals(0, result.exitCode()); + assertTrue(result.out().contains("Estimated migration confidence: 100%")); + } + + @Test + void analyzeFailUnderExitsOneWhenConfidenceBelowThresholdAndStillOutputsReport(@TempDir Path temp) throws Exception { + Files.writeString(temp.resolve("Test.java"), """ + class Test { + void m() { + new org.openqa.selenium.support.ui.WebDriverWait(driver, java.time.Duration.ofSeconds(1)); + } + } + """); + + RunResult result = runCli("analyze", temp.toString(), "--fail-under", "80"); + + assertEquals(1, result.exitCode()); + assertTrue(result.out().contains("Estimated migration confidence: 50%")); + assertTrue(result.out().contains("Findings")); + } + + @Test + void analyzeFailUnderWithJsonExitsOneWhenConfidenceBelowThreshold(@TempDir Path temp) throws Exception { + Files.writeString(temp.resolve("Test.java"), """ + class Test { + void m() { + new org.openqa.selenium.support.ui.WebDriverWait(driver, java.time.Duration.ofSeconds(1)); + } + } + """); + + RunResult result = runCli("analyze", temp.toString(), "--format", "json", "--fail-under", "90"); + + assertEquals(1, result.exitCode()); + assertTrue(result.out().contains("\"estimatedConfidence\": 50")); + assertTrue(result.out().contains("\"ruleId\": \"MIG-003\"")); + } + + @Test + void analyzeSupportsAnyOrderOfArgumentsAndFlags() throws Exception { + Path path = fixture("maven"); + RunResult result = runCli("analyze", "--fail-under=10", "--format", "json", path.toString()); + + assertEquals(0, result.exitCode()); + assertTrue(result.out().contains("\"estimatedConfidence\": 100")); + } + + @Test + void analyzeThresholdBoundaryValues() throws Exception { + Path path = fixture("maven"); + + assertEquals(0, runCli("analyze", path.toString(), "--fail-under", "0").exitCode()); + assertEquals(0, runCli("analyze", path.toString(), "--fail-under", "100").exitCode()); + } + + @Test + void returnsExitCodeTwoOnEmptyArguments() throws Exception { + RunResult result = runCli(); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("usage:")); + } + + @Test + void returnsExitCodeTwoOnUnknownCommand() throws Exception { + RunResult result = runCli("unknown-command"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("unknown command: unknown-command")); + assertTrue(result.err().contains("usage:")); + } + + @Test + void returnsExitCodeTwoOnMissingDirectory() throws Exception { + RunResult result = runCli("analyze"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("missing project directory")); + assertTrue(result.err().contains("usage:")); + } + + @Test + void returnsExitCodeTwoOnNonExistentDirectory() throws Exception { + RunResult result = runCli("analyze", "this-directory-does-not-exist-12345"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("not a directory:")); + } + + @Test + void returnsExitCodeTwoOnUnknownOption() throws Exception { + RunResult result = runCli("analyze", fixture("maven").toString(), "--bogus"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("unknown option: --bogus")); + } + + @Test + void returnsExitCodeTwoOnUnsupportedFormat() throws Exception { + RunResult result = runCli("analyze", fixture("maven").toString(), "--format", "yaml"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("unsupported format: yaml (expected 'text' or 'json')")); + } + + @Test + void returnsExitCodeTwoOnMissingFormatValue() throws Exception { + RunResult result = runCli("analyze", fixture("maven").toString(), "--format"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("missing value for --format")); + } + + @Test + void returnsExitCodeTwoOnMissingFailUnderValue() throws Exception { + RunResult result = runCli("analyze", fixture("maven").toString(), "--fail-under"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("missing value for --fail-under")); + } + + @Test + void returnsExitCodeTwoOnInvalidFailUnderThreshold() throws Exception { + Path path = fixture("maven"); + + RunResult notNumber = runCli("analyze", path.toString(), "--fail-under", "not-a-number"); + assertEquals(2, notNumber.exitCode()); + assertTrue(notNumber.err().contains("invalid threshold for --fail-under: not-a-number")); + + RunResult negative = runCli("analyze", path.toString(), "--fail-under", "-1"); + assertEquals(2, negative.exitCode()); + assertTrue(negative.err().contains("invalid threshold for --fail-under: -1")); + + RunResult overHundred = runCli("analyze", path.toString(), "--fail-under", "101"); + assertEquals(2, overHundred.exitCode()); + assertTrue(overHundred.err().contains("invalid threshold for --fail-under: 101")); + } + + @Test + void processExitCodes(@TempDir Path temp) throws Exception { + Files.writeString(temp.resolve("Wait.java"), """ + class Wait { + void test() { + new org.openqa.selenium.support.ui.WebDriverWait(driver, java.time.Duration.ofSeconds(1)); + } + } + """); + + String java = ProcessHandle.current().info().command() + .orElse(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + String classpath = System.getProperty("java.class.path"); + + // Success process: exit code 0 + Process passProcess = new ProcessBuilder(java, "-cp", classpath, + "com.seleniumboot.migrator.Cli", "analyze", temp.toString(), "--fail-under", "50") + .start(); + assertEquals(0, passProcess.waitFor()); + + // Fail-under violation process: exit code 1 + Process failProcess = new ProcessBuilder(java, "-cp", classpath, + "com.seleniumboot.migrator.Cli", "analyze", temp.toString(), "--fail-under", "90") + .start(); + assertEquals(1, failProcess.waitFor()); + + // Usage error process: exit code 2 + Process errorProcess = new ProcessBuilder(java, "-cp", classpath, + "com.seleniumboot.migrator.Cli", "analyze", temp.toString(), "--format", "invalid") + .start(); + assertEquals(2, errorProcess.waitFor()); + } +} diff --git a/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java b/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java new file mode 100644 index 0000000..32cddf0 --- /dev/null +++ b/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java @@ -0,0 +1,91 @@ +package com.seleniumboot.migrator; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ReportJsonTest { + + @Test + void rendersEmptyReport() { + Report report = new Report(0, 0, List.of(), List.of(), List.of(), List.of(), Map.of()); + String json = report.toJson(); + + assertTrue(json.contains("\"filesFound\": 0")); + assertTrue(json.contains("\"filesParsed\": 0")); + assertTrue(json.contains("\"unparsableFiles\": []")); + assertTrue(json.contains("\"detectedTechnologies\": []")); + assertTrue(json.contains("\"recognizedTechnologies\": []")); + assertTrue(json.contains("\"locatorCounts\": {}")); + assertTrue(json.contains("\"mapsCleanly\": 0")); + assertTrue(json.contains("\"manualReviewRequired\": 0")); + assertTrue(json.contains("\"unparsableFiles\": 0")); + assertTrue(json.contains("\"byRule\": {}")); + assertTrue(json.contains("\"estimatedConfidence\": 100")); + assertTrue(json.contains("\"findings\": []")); + } + + @Test + void rendersFullReportWithFindingsAndLocators() { + Report report = new Report( + 10, + 8, + List.of("src/broken/Bad.java", "src\\windows\\Path.java"), + List.of( + new Finding("MIG-001", Finding.Status.AUTO, "src/Driver.java", 15, + "ThreadLocal", "Delete the driver factory; extend BaseTest."), + new Finding("MIG-003", Finding.Status.MANUAL, "src\\Wait.java", 42, + "new WebDriverWait(driver, 10)", "Use $(locator) auto-wait or \"getWait()\".") + ), + List.of("Build system: Maven", "Dependency: org.testng:testng:7.10.0"), + List.of("TestNG"), + Map.of("By.id", 5L, "By.xpath", 3L) + ); + + String json = report.toJson(); + + assertTrue(json.contains("\"filesFound\": 10")); + assertTrue(json.contains("\"filesParsed\": 8")); + assertTrue(json.contains("\"src/broken/Bad.java\"")); + assertTrue(json.contains("\"src/windows/Path.java\""), "Windows path backslashes should be normalized"); + assertTrue(json.contains("\"Build system: Maven\"")); + assertTrue(json.contains("\"Dependency: org.testng:testng:7.10.0\"")); + assertTrue(json.contains("\"TestNG\"")); + assertTrue(json.contains("\"By.id\": 5")); + assertTrue(json.contains("\"By.xpath\": 3")); + assertTrue(json.contains("\"mapsCleanly\": 1")); + assertTrue(json.contains("\"manualReviewRequired\": 1")); + assertTrue(json.contains("\"unparsableFiles\": 2")); + assertTrue(json.contains("\"MIG-001\": 1")); + assertTrue(json.contains("\"MIG-003\": 1")); + assertTrue(json.contains("\"estimatedConfidence\":")); + assertTrue(json.contains("\"ruleId\": \"MIG-001\"")); + assertTrue(json.contains("\"status\": \"AUTO\"")); + assertTrue(json.contains("\"file\": \"src/Driver.java\"")); + assertTrue(json.contains("\"line\": 15")); + assertTrue(json.contains("\"detected\": \"ThreadLocal\"")); + assertTrue(json.contains("\"file\": \"src/Wait.java\""), "Finding path should be normalized"); + assertTrue(json.contains("\"advice\": \"Use $(locator) auto-wait or \\\"getWait()\\\".\""), "Quotes in advice must be escaped"); + } + + @Test + void escapesSpecialCharactersProperly() { + Report report = new Report( + 1, + 1, + List.of(), + List.of( + new Finding("MIG-TEST", Finding.Status.MANUAL, "file.java", 1, + "line1\nline2\t\"quoted\"\\backslash", "advice\r\n\b\f") + ) + ); + + String json = report.toJson(); + + assertTrue(json.contains("\\nline2\\t\\\"quoted\\\"\\\\backslash")); + assertTrue(json.contains("advice\\r\\n\\b\\f")); + } +} From 7f764d5af67e4cf895264bd0842b11f9fa3e92d3 Mon Sep 17 00:00:00 2001 From: Pushpak-Jaiswal Date: Wed, 30 Sep 2026 18:47:07 +0530 Subject: [PATCH 2/2] fix: clear exit code seperation --- README.md | 6 +- .../java/com/seleniumboot/migrator/Cli.java | 59 +++++++++++++------ .../seleniumboot/migrator/JsonRenderer.java | 8 +-- .../com/seleniumboot/migrator/Report.java | 13 +++- .../com/seleniumboot/migrator/CliTest.java | 30 ++++++++-- .../seleniumboot/migrator/ReportJsonTest.java | 35 +++++++++++ 6 files changed, 116 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d64c59b..ebcfd78 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ as text; a Gradle installation is not required. | Exit Code | Description | |---|---| | `0` | Success: command completed normally and estimated confidence meets `--fail-under` (if specified). | -| `1` | Threshold failure: estimated confidence is strictly below the `--fail-under` percentage. | +| `1` | Quality gate failed: estimated confidence is strictly below the `--fail-under` percentage. | | `2` | Usage or input error: invalid arguments, unsupported format, invalid threshold, missing directory, or directory not found. | +| `3` | Runtime error: analysis or migration failed due to an I/O error or unexpected runtime failure. | + ## Machine-Readable Output (JSON) @@ -157,7 +159,7 @@ The output adheres to the following stable schema: "ruleId": { "type": "string", "description": "Migration rule ID (e.g. MIG-001)." }, "status": { "type": "string", "enum": ["AUTO", "MANUAL"], "description": "Mapping status." }, "file": { "type": "string", "description": "Normalized relative file path." }, - "line": { "type": "integer", "minimum": 1, "description": "1-based line number of finding." }, + "line": { "type": "integer", "minimum": 0, "description": "1-based line number of finding, or 0 if position is unavailable." }, "detected": { "type": "string", "description": "Code snippet or construct detected." }, "advice": { "type": "string", "description": "Suggested migration action." } } diff --git a/src/main/java/com/seleniumboot/migrator/Cli.java b/src/main/java/com/seleniumboot/migrator/Cli.java index a713705..c516e57 100644 --- a/src/main/java/com/seleniumboot/migrator/Cli.java +++ b/src/main/java/com/seleniumboot/migrator/Cli.java @@ -6,32 +6,43 @@ public final class Cli { - public static void main(String[] args) throws Exception { - int exitCode = run(args, System.out, System.err); + public static void main(String[] args) { + int exitCode; + try { + exitCode = run(args, System.out, System.err); + } catch (Throwable throwable) { + System.err.println("runtime error: " + (throwable.getMessage() != null ? throwable.getMessage() : throwable.toString())); + exitCode = 3; + } if (exitCode != 0) { System.exit(exitCode); } } - public static int run(String[] args, PrintStream out, PrintStream err) throws Exception { - if (args.length == 0) { - printUsage(err); - return 2; - } + public static int run(String[] args, PrintStream out, PrintStream err) { + try { + if (args.length == 0) { + printUsage(err); + return 2; + } - String command = args[0]; - if (command.equals("analyze")) { - return runAnalyze(args, out, err); - } else if (command.equals("migrate")) { - return runMigrate(args, out, err); - } else { - err.println("unknown command: " + command); - printUsage(err); - return 2; + String command = args[0]; + if (command.equals("analyze")) { + return runAnalyze(args, out, err); + } else if (command.equals("migrate")) { + return runMigrate(args, out, err); + } else { + err.println("unknown command: " + command); + printUsage(err); + return 2; + } + } catch (Throwable throwable) { + err.println("runtime error: " + (throwable.getMessage() != null ? throwable.getMessage() : throwable.toString())); + return 3; } } - private static int runAnalyze(String[] args, PrintStream out, PrintStream err) throws Exception { + private static int runAnalyze(String[] args, PrintStream out, PrintStream err) { Path dir = null; String format = "text"; Integer failUnder = null; @@ -94,7 +105,14 @@ private static int runAnalyze(String[] args, PrintStream out, PrintStream err) t return 2; } - Report report = new Analyzer().analyze(dir); + Report report; + try { + report = new Analyzer().analyze(dir); + } catch (Exception exception) { + err.println("analysis failed: " + (exception.getMessage() != null ? exception.getMessage() : exception.toString())); + return 3; + } + if (format.equalsIgnoreCase("json")) { out.print(report.toJson()); } else { @@ -122,7 +140,7 @@ private static Integer parseThreshold(String value, PrintStream err) { } } - private static int runMigrate(String[] args, PrintStream out, PrintStream err) throws Exception { + private static int runMigrate(String[] args, PrintStream out, PrintStream err) { Path projectDir = null; Path outputDir = null; @@ -169,6 +187,9 @@ private static int runMigrate(String[] args, PrintStream out, PrintStream err) t } catch (IllegalArgumentException exception) { err.println(exception.getMessage()); return 2; + } catch (Exception exception) { + err.println("migration failed: " + (exception.getMessage() != null ? exception.getMessage() : exception.toString())); + return 3; } } diff --git a/src/main/java/com/seleniumboot/migrator/JsonRenderer.java b/src/main/java/com/seleniumboot/migrator/JsonRenderer.java index 07915ac..98fdc46 100644 --- a/src/main/java/com/seleniumboot/migrator/JsonRenderer.java +++ b/src/main/java/com/seleniumboot/migrator/JsonRenderer.java @@ -2,7 +2,7 @@ import java.util.List; import java.util.Map; -import java.util.TreeMap; + final class JsonRenderer { @@ -30,17 +30,15 @@ static String render(Report report) { appendNumberMap(sb, report.locatorCounts(), " "); sb.append(",\n"); - Map byRule = new TreeMap<>(); - report.findings().forEach(f -> byRule.merge(f.ruleId(), 1L, Long::sum)); - sb.append(" \"summary\": {\n"); sb.append(" \"mapsCleanly\": ").append(report.count(Finding.Status.AUTO)).append(",\n"); sb.append(" \"manualReviewRequired\": ").append(report.count(Finding.Status.MANUAL)).append(",\n"); sb.append(" \"unparsableFiles\": ").append(report.unparsable().size()).append(",\n"); sb.append(" \"byRule\": "); - appendNumberMap(sb, byRule, " "); + appendNumberMap(sb, report.ruleCounts(), " "); sb.append("\n },\n"); + sb.append(" \"estimatedConfidence\": ").append(report.estimatedConfidence()).append(",\n"); sb.append(" \"findings\": "); diff --git a/src/main/java/com/seleniumboot/migrator/Report.java b/src/main/java/com/seleniumboot/migrator/Report.java index aabab70..9a3cc25 100644 --- a/src/main/java/com/seleniumboot/migrator/Report.java +++ b/src/main/java/com/seleniumboot/migrator/Report.java @@ -1,9 +1,11 @@ package com.seleniumboot.migrator; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.TreeMap; + public record Report(int filesFound, int filesParsed, List unparsable, List findings, List detectedTechnologies, List recognizedTechnologies, Map locatorCounts) { @@ -72,9 +74,7 @@ public String render() { locatorCounts.forEach((locator, count) -> sb.append(String.format(" %-22s %d%n", locator + ":", count))); } - Map byRule = new TreeMap<>(); - findings.forEach(f -> byRule.merge(f.ruleId(), 1L, Long::sum)); - byRule.forEach((r, n) -> sb.append(String.format("%-40s %d%n", ruleLabel(r) + ":", n))); + ruleCounts().forEach((r, n) -> sb.append(String.format("%-40s %d%n", ruleLabel(r) + ":", n))); sb.append(String.format("%nMaps cleanly: %d%n", count(Finding.Status.AUTO))); sb.append(String.format("Manual review required: %d%n", count(Finding.Status.MANUAL))); sb.append(String.format("Unparsable files: %d%n", unparsable.size())); @@ -89,6 +89,13 @@ public String render() { return sb.toString(); } + public Map ruleCounts() { + Map byRule = new TreeMap<>(); + findings.forEach(f -> byRule.merge(f.ruleId(), 1L, Long::sum)); + return Collections.unmodifiableMap(byRule); + } + + public String toJson() { return JsonRenderer.render(this); } diff --git a/src/test/java/com/seleniumboot/migrator/CliTest.java b/src/test/java/com/seleniumboot/migrator/CliTest.java index c3d3150..d84f12d 100644 --- a/src/test/java/com/seleniumboot/migrator/CliTest.java +++ b/src/test/java/com/seleniumboot/migrator/CliTest.java @@ -20,7 +20,7 @@ private static Path fixture(String name) throws URISyntaxException { private record RunResult(int exitCode, String out, String err) { } - private static RunResult runCli(String... args) throws Exception { + private static RunResult runCli(String... args) { ByteArrayOutputStream outBytes = new ByteArrayOutputStream(); ByteArrayOutputStream errBytes = new ByteArrayOutputStream(); try (PrintStream out = new PrintStream(outBytes, true, StandardCharsets.UTF_8); @@ -126,14 +126,14 @@ void analyzeThresholdBoundaryValues() throws Exception { } @Test - void returnsExitCodeTwoOnEmptyArguments() throws Exception { + void returnsExitCodeTwoOnEmptyArguments() { RunResult result = runCli(); assertEquals(2, result.exitCode()); assertTrue(result.err().contains("usage:")); } @Test - void returnsExitCodeTwoOnUnknownCommand() throws Exception { + void returnsExitCodeTwoOnUnknownCommand() { RunResult result = runCli("unknown-command"); assertEquals(2, result.exitCode()); assertTrue(result.err().contains("unknown command: unknown-command")); @@ -141,7 +141,7 @@ void returnsExitCodeTwoOnUnknownCommand() throws Exception { } @Test - void returnsExitCodeTwoOnMissingDirectory() throws Exception { + void returnsExitCodeTwoOnMissingDirectory() { RunResult result = runCli("analyze"); assertEquals(2, result.exitCode()); assertTrue(result.err().contains("missing project directory")); @@ -149,7 +149,7 @@ void returnsExitCodeTwoOnMissingDirectory() throws Exception { } @Test - void returnsExitCodeTwoOnNonExistentDirectory() throws Exception { + void returnsExitCodeTwoOnNonExistentDirectory() { RunResult result = runCli("analyze", "this-directory-does-not-exist-12345"); assertEquals(2, result.exitCode()); assertTrue(result.err().contains("not a directory:")); @@ -200,6 +200,16 @@ void returnsExitCodeTwoOnInvalidFailUnderThreshold() throws Exception { assertTrue(overHundred.err().contains("invalid threshold for --fail-under: 101")); } + @Test + void returnsExitCodeThreeOnRuntimeError(@TempDir Path temp) throws Exception { + Path file = Files.writeString(temp.resolve("file.txt"), "hello"); + Path invalidOutput = file.resolve("child"); + + RunResult result = runCli("migrate", fixture("maven").toString(), "--out", invalidOutput.toString()); + assertEquals(3, result.exitCode()); + assertTrue(result.err().contains("migration failed:")); + } + @Test void processExitCodes(@TempDir Path temp) throws Exception { Files.writeString(temp.resolve("Wait.java"), """ @@ -220,7 +230,7 @@ void test() { .start(); assertEquals(0, passProcess.waitFor()); - // Fail-under violation process: exit code 1 + // Quality gate failure process: exit code 1 Process failProcess = new ProcessBuilder(java, "-cp", classpath, "com.seleniumboot.migrator.Cli", "analyze", temp.toString(), "--fail-under", "90") .start(); @@ -231,5 +241,13 @@ void test() { "com.seleniumboot.migrator.Cli", "analyze", temp.toString(), "--format", "invalid") .start(); assertEquals(2, errorProcess.waitFor()); + + // Runtime error process: exit code 3 + Path blockingFile = Files.writeString(temp.resolve("blocking-file.txt"), "hello"); + Path invalidOutput = blockingFile.resolve("child"); + Process runtimeErrorProcess = new ProcessBuilder(java, "-cp", classpath, + "com.seleniumboot.migrator.Cli", "migrate", fixture("maven").toString(), "--out", invalidOutput.toString()) + .start(); + assertEquals(3, runtimeErrorProcess.waitFor()); } } diff --git a/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java b/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java index 32cddf0..e7dbff0 100644 --- a/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java +++ b/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java @@ -88,4 +88,39 @@ void escapesSpecialCharactersProperly() { assertTrue(json.contains("\\nline2\\t\\\"quoted\\\"\\\\backslash")); assertTrue(json.contains("advice\\r\\n\\b\\f")); } + + @Test + void rendersLineZeroWhenPositionUnavailable() { + Report report = new Report( + 1, + 1, + List.of(), + List.of( + new Finding("MIG-015", Finding.Status.MANUAL, "Driver.java", 0, + "CustomDriverManager", "Review and replace with BaseTest.") + ) + ); + + String json = report.toJson(); + assertTrue(json.contains("\"line\": 0"), "Line 0 should be rendered validly when position is unavailable"); + } + + @Test + void ruleCountsProvidesSharedAggregation() { + Report report = new Report( + 1, + 1, + List.of(), + List.of( + new Finding("MIG-001", Finding.Status.AUTO, "A.java", 1, "d", "a"), + new Finding("MIG-001", Finding.Status.AUTO, "B.java", 2, "d", "a"), + new Finding("MIG-003", Finding.Status.MANUAL, "C.java", 3, "d", "a") + ) + ); + + Map counts = report.ruleCounts(); + assertEquals(2L, counts.get("MIG-001")); + assertEquals(1L, counts.get("MIG-003")); + assertEquals(2, counts.size()); + } }