diff --git a/README.md b/README.md index 605884e..ebcfd78 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,187 @@ 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` | 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) + +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": 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." } + } + } + } + } +} +``` + +### 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..c516e57 100644 --- a/src/main/java/com/seleniumboot/migrator/Cli.java +++ b/src/main/java/com/seleniumboot/migrator/Cli.java @@ -1,36 +1,200 @@ 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); + 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) { + try { + if (args.length == 0) { + printUsage(err); + return 2; } - System.out.print(new Analyzer().analyze(dir).render()); - return; + + 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; } - 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 runAnalyze(String[] args, PrintStream out, PrintStream err) { + 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; 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()); + 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 { + 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; + } + return threshold; + } catch (NumberFormatException exception) { + err.println("invalid threshold for --fail-under: " + value + " (must be an integer between 0 and 100)"); + return null; + } + } + + private static int runMigrate(String[] args, PrintStream out, PrintStream err) { + 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(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; + } catch (Exception exception) { + err.println("migration failed: " + (exception.getMessage() != null ? exception.getMessage() : exception.toString())); + return 3; } } + + 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..98fdc46 --- /dev/null +++ b/src/main/java/com/seleniumboot/migrator/JsonRenderer.java @@ -0,0 +1,150 @@ +package com.seleniumboot.migrator; + +import java.util.List; +import java.util.Map; + + +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"); + + 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, report.ruleCounts(), " "); + 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..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,18 @@ 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); + } + + 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..d84f12d --- /dev/null +++ b/src/test/java/com/seleniumboot/migrator/CliTest.java @@ -0,0 +1,253 @@ +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) { + 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() { + RunResult result = runCli(); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("usage:")); + } + + @Test + void returnsExitCodeTwoOnUnknownCommand() { + 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() { + RunResult result = runCli("analyze"); + assertEquals(2, result.exitCode()); + assertTrue(result.err().contains("missing project directory")); + assertTrue(result.err().contains("usage:")); + } + + @Test + void returnsExitCodeTwoOnNonExistentDirectory() { + 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 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"), """ + 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()); + + // 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(); + 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()); + + // 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 new file mode 100644 index 0000000..e7dbff0 --- /dev/null +++ b/src/test/java/com/seleniumboot/migrator/ReportJsonTest.java @@ -0,0 +1,126 @@ +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")); + } + + @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()); + } +}