From c9051530a49ec9b81c3bdd15ad3bfe7cc73be812 Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Thu, 13 Aug 2026 00:55:37 +0400 Subject: [PATCH] feat(api-detector-core): add ScanProgressReporter for visible long-scan progress Adds ScanProgressReporter in a new console package (deliberately separate from the unrelated progress package, which is scenario/contract *history* across builds, not live console feedback for a single run). Emits periodic LIFECYCLE-level status lines - one plain line per update, never overwriting in place - throttled to at most every 50 items or every 2 seconds, whichever comes first, so a consumer watching a long @RestController scan or OpenAPI $ref resolution knows the task is still alive. Deliberately built only on the public org.gradle.api.logging.Logger API, not Gradle's internal rich-console progress indicator: these plugins are published to the Gradle Plugin Portal for consumers on Gradle versions this codebase doesn't control, and the internal API offers no compatibility guarantee across versions. Adds an additive collect(File, Consumer) overload to OpenApiEndpointCollector so a caller can drive an indeterminate progress reporter during $ref resolution, which the underlying parser performs internally with no callback of its own - backed by a lightweight, best-effort textual $ref scan run ahead of the real parse, purely for progress purposes. The existing collect(File) is unchanged, delegating to the new overload with a no-op callback. --- api-detector-core/build.gradle | 7 + .../core/console/ScanProgressReporter.java | 178 +++++++++++ .../openapi/OpenApiEndpointCollector.java | 83 ++++- .../core/console/RecordingLogger.java | 283 ++++++++++++++++++ .../console/ScanProgressReporterTest.java | 142 +++++++++ .../openapi/OpenApiEndpointCollectorTest.java | 40 +++ 6 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java create mode 100644 api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java create mode 100644 api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java diff --git a/api-detector-core/build.gradle b/api-detector-core/build.gradle index 9640184..14a0f1b 100644 --- a/api-detector-core/build.gradle +++ b/api-detector-core/build.gradle @@ -68,9 +68,16 @@ dependencies { implementation libs.javaparser.core implementation libs.swagger.parser + // Only for org.gradle.api.logging.Logger, the sole Gradle type ScanProgressReporter's public + // API accepts. compileOnly: the real implementation is always supplied by whichever Gradle + // version a consuming plugin task actually runs under, so it must not be bundled/pinned here. + compileOnly gradleApi() + testImplementation libs.junit.jupiter testImplementation libs.assertj.core testRuntimeOnly libs.junit.platform.launcher + // Needed at test runtime too, to hand-write a Logger test double (RecordingLogger). + testImplementation gradleApi() } publishing { diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java new file mode 100644 index 0000000..541c95d --- /dev/null +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporter.java @@ -0,0 +1,178 @@ +package com.arc_e_tect.gradle.detector.core.console; + +import org.gradle.api.logging.Logger; + +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Emits periodic, low-overhead {@code LIFECYCLE}-level status lines for a long-running scan loop + * (controller scanning, OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so + * a consumer watching the build knows the task is still alive and roughly how far along it is. + * + *

Deliberately built only on the public {@link Logger} API - one plain line per status update, + * never overwriting a line in place - rather than Gradle's internal, unsupported rich-console + * single-line progress indicator. These plugins are published to the Gradle Plugin Portal for + * consumers on Gradle versions this codebase doesn't control; the internal progress API has + * changed shape across versions and offers no compatibility guarantee. This is a deliberate + * trade-off in exchange for forward/backward compatibility, not an oversight.

+ * + *

A status line is emitted every {@code everyNItems} items or every + * {@code everySeconds} seconds since the last emission, whichever comes first - never on every + * single item, and never silent for more than {@code everySeconds} regardless of how many items + * are processed in between. {@link #complete()} always emits a final summary line, even if the + * most recent {@link #step()} landed inside the throttle window - the final line is never + * suppressed by throttling.

+ */ +public final class ScanProgressReporter { + + /** Default number of items between emitted status lines, absent an explicit override. */ + public static final int DEFAULT_EVERY_N_ITEMS = 50; + + /** Default number of seconds between emitted status lines, absent an explicit override. */ + public static final long DEFAULT_EVERY_SECONDS = 2; + + private static final int INDETERMINATE_TOTAL = -1; + + private final Logger logger; + private final String phaseLabel; + private final int total; + private final int everyNItems; + private final long everyNanos; + private final LongSupplier nanoTimeSource; + + private int count; + private int lastEmittedCount; + private long lastEmittedNanos; + + /** + * Creates a reporter with the default throttle (every {@value #DEFAULT_EVERY_N_ITEMS} items or + * every {@value #DEFAULT_EVERY_SECONDS} seconds) and the real wall-clock time source. Prefer + * {@link #determinate(Logger, String, int)} or {@link #indeterminate(Logger, String)}. + * + * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param phaseLabel short label identifying the scan phase, e.g. {@code "Scanning @RestController classes"} + * @param total the total number of items expected, or a negative number for an + * indeterminate-total scan (the total isn't known ahead of time) + */ + public ScanProgressReporter(Logger logger, String phaseLabel, int total) { + this(logger, phaseLabel, total, DEFAULT_EVERY_N_ITEMS, DEFAULT_EVERY_SECONDS, System::nanoTime); + } + + /** + * Creates a reporter with an explicit throttle and the real wall-clock time source. + * + * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param phaseLabel short label identifying the scan phase + * @param total the total number of items expected, or a negative number for an + * indeterminate-total scan + * @param everyNItems emit a status line at least this often, counted in processed items + * @param everySeconds emit a status line at least this often, counted in elapsed seconds since + * the last emission + */ + public ScanProgressReporter(Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds) { + this(logger, phaseLabel, total, everyNItems, everySeconds, System::nanoTime); + } + + /** + * Creates a reporter with an explicit throttle and time source, for use by tests that need to + * control elapsed time without a real {@code Thread.sleep}. + * + * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param phaseLabel short label identifying the scan phase + * @param total the total number of items expected, or a negative number for an + * indeterminate-total scan + * @param everyNItems emit a status line at least this often, counted in processed items + * @param everySeconds emit a status line at least this often, counted in elapsed seconds + * since the last emission + * @param nanoTimeSource nanosecond tick source, normally {@code System::nanoTime} + */ + ScanProgressReporter( + Logger logger, String phaseLabel, int total, int everyNItems, long everySeconds, + LongSupplier nanoTimeSource) { + this.logger = logger; + this.phaseLabel = phaseLabel; + this.total = total; + this.everyNItems = everyNItems; + this.everyNanos = TimeUnit.SECONDS.toNanos(everySeconds); + this.nanoTimeSource = nanoTimeSource; + this.lastEmittedNanos = nanoTimeSource.getAsLong(); + } + + /** + * Creates a reporter for a scan whose total item count is known ahead of time. Emitted lines + * include a running fraction and percentage, e.g. {@code "Scanning @RestController classes: 150/438 (34%)"}. + * + * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param phaseLabel short label identifying the scan phase + * @param total the total number of items expected; {@code 0} is valid and not an error + * @return a new determinate-mode reporter, using the default throttle + */ + public static ScanProgressReporter determinate(Logger logger, String phaseLabel, int total) { + return new ScanProgressReporter(logger, phaseLabel, total); + } + + /** + * Creates a reporter for a scan whose total item count isn't known ahead of time. Emitted + * lines report only a running count, e.g. {@code "Resolving OpenAPI documents: 27 processed so far"}. + * + * @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level + * @param phaseLabel short label identifying the scan phase + * @return a new indeterminate-mode reporter, using the default throttle + */ + public static ScanProgressReporter indeterminate(Logger logger, String phaseLabel) { + return new ScanProgressReporter(logger, phaseLabel, INDETERMINATE_TOTAL); + } + + /** + * Records one item processed, emitting a status line if the throttle window has elapsed. + * Equivalent to {@link #step(String)} with no detail. + */ + public void step() { + step(null); + } + + /** + * Records one item processed, emitting a status line - with {@code detail} appended, when + * given - if the throttle window has elapsed. + * + * @param detail short description of the current item, appended to the line only when this + * call itself results in an emission; or {@code null}/blank for no detail + */ + public void step(String detail) { + count++; + long now = nanoTimeSource.getAsLong(); + boolean dueByCount = (count - lastEmittedCount) >= everyNItems; + boolean dueByTime = (now - lastEmittedNanos) >= everyNanos; + if (dueByCount || dueByTime) { + logger.lifecycle(progressLine(detail)); + lastEmittedCount = count; + lastEmittedNanos = now; + } + } + + /** + * Emits a final summary line unconditionally, regardless of throttling state - the last + * {@link #step()} may have landed inside the throttle window, but this line is never + * suppressed. Safe to call on a reporter that never had {@link #step()} called at all (the + * {@code 0}-items-processed case). + */ + public void complete() { + logger.lifecycle(phaseLabel + ": done, " + count + " item(s)"); + } + + private String progressLine(String detail) { + String base = total >= 0 + ? phaseLabel + ": " + count + "/" + total + " (" + percentage() + "%)" + : phaseLabel + ": " + count + " processed so far"; + return isBlank(detail) ? base : base + " - " + detail; + } + + private long percentage() { + return total == 0 ? 100 : Math.round(100.0 * count / total); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java index 81f6687..18818df 100644 --- a/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java +++ b/api-detector-core/src/main/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollector.java @@ -14,7 +14,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,19 +33,56 @@ public class OpenApiEndpointCollector { private static final Pattern OPENAPI_32_JSON_PATTERN = Pattern.compile("(\"openapi\"\\s*:\\s*\")3\\.2(?:\\.\\d+)?(\")"); + /** + * Matches a {@code $ref} entry's target in either YAML ({@code $ref: 'foo.yaml#/...'}) or + * JSON ({@code "$ref": "foo.json#/..."}) syntax, capturing everything up to the closing quote + * or a {@code #} fragment marker, whichever comes first. A same-document ref (starting + * directly with {@code #}) captures an empty group. + */ + private static final Pattern REF_TARGET_PATTERN = Pattern.compile("\\$ref\\s*:\\s*[\"']([^\"'#]*)"); + /** Creates a new {@code OpenApiEndpointCollector}. */ public OpenApiEndpointCollector() {} /** * Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are * resolved automatically), and returns the verb + path template pair described by every - * operation found. + * operation found. Equivalent to {@link #collect(File, Consumer)} with a callback that does + * nothing. * * @param rootDocument the root OpenAPI document (JSON or YAML) * @return possibly-empty list of described endpoints, never {@code null} * @throws IllegalStateException if the document cannot be parsed */ public List collect(File rootDocument) { + return collect(rootDocument, file -> { }); + } + + /** + * Parses {@code rootDocument} and every document it links to (relative {@code $ref}s are + * resolved automatically), and returns the verb + path template pair described by every + * operation found. + * + *

{@code onDocumentResolved} is invoked once for {@code rootDocument} itself and once for + * every distinct document reachable from it via a relative {@code $ref}, so a caller can drive + * a progress indicator during what would otherwise be a single opaque, potentially long-running + * call - the underlying parser resolves {@code $ref}s internally and offers no such callback of + * its own. The set of documents is discovered by a lightweight, best-effort textual scan for + * {@code $ref} entries (the same "read the file as data" approach the plugins' own WireMock and + * Spring Cloud Contract scanners use), run before the real parse; it does not replace + * or influence actual {@code $ref} resolution, which is still performed by the parser exactly + * as it is for {@link #collect(File)}. A document that can't be read for this discovery pass is + * silently skipped - the real parse below still surfaces the failure normally.

+ * + * @param rootDocument the root OpenAPI document (JSON or YAML) + * @param onDocumentResolved invoked once per distinct document discovered, including the root; + * never {@code null} + * @return possibly-empty list of described endpoints, never {@code null} + * @throws IllegalStateException if the document cannot be parsed + */ + public List collect(File rootDocument, Consumer onDocumentResolved) { + discoverReferencedDocuments(rootDocument, new HashSet<>(), onDocumentResolved); + ParseOptions options = new ParseOptions(); options.setResolve(true); options.setResolveFully(true); @@ -77,6 +117,47 @@ private static List operationTags(Operation operation) { return operation.getTags() == null ? List.of() : List.copyOf(operation.getTags()); } + /** + * Recursively discovers every document reachable from {@code document} via a relative + * {@code $ref}, invoking {@code onDocumentResolved} once for each distinct document the first + * time it's encountered. {@code visited} guards against revisiting a document already seen - + * both to avoid infinite recursion on a {@code $ref} cycle and to guarantee each document is + * reported at most once. + */ + private void discoverReferencedDocuments(File document, Set visited, Consumer onDocumentResolved) { + if (!visited.add(canonicalOrAbsolute(document))) { + return; + } + onDocumentResolved.accept(document); + if (!document.isFile()) { + return; + } + + String content; + try { + content = Files.readString(document.toPath()); + } catch (IOException e) { + return; + } + + Matcher matcher = REF_TARGET_PATTERN.matcher(content); + while (matcher.find()) { + String refPath = matcher.group(1); + if (refPath.isBlank() || refPath.contains("://")) { + continue; + } + discoverReferencedDocuments(new File(document.getParentFile(), refPath), visited, onDocumentResolved); + } + } + + private File canonicalOrAbsolute(File file) { + try { + return file.getCanonicalFile(); + } catch (IOException e) { + return file.getAbsoluteFile(); + } + } + private static SwaggerParseResult parse(File rootDocument, ParseOptions options) { return new OpenAPIV3Parser().readLocation(rootDocument.getAbsolutePath(), null, options); } diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java new file mode 100644 index 0000000..5c99fc1 --- /dev/null +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/RecordingLogger.java @@ -0,0 +1,283 @@ +package com.arc_e_tect.gradle.detector.core.console; + +import org.gradle.api.logging.LogLevel; +import org.gradle.api.logging.Logger; +import org.slf4j.Marker; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hand-written {@link Logger} test double that records every message passed to + * {@link #lifecycle(String)}/{@link #lifecycle(String, Object...)} and no-ops everything else - + * this codebase's tests use hand-written fakes rather than a mocking framework. + */ +class RecordingLogger implements Logger { + + private final List lifecycleMessages = new ArrayList<>(); + + /** Creates a new {@code RecordingLogger}. */ + RecordingLogger() {} + + /** + * Every message passed to {@code lifecycle(...)}, in call order. + * + * @return the recorded lifecycle messages + */ + List lifecycleMessages() { + return lifecycleMessages; + } + + @Override + public void lifecycle(String message) { + lifecycleMessages.add(message); + } + + @Override + public void lifecycle(String message, Object... objects) { + lifecycleMessages.add(message); + } + + @Override + public void lifecycle(String message, Throwable throwable) { + lifecycleMessages.add(message); + } + + @Override + public boolean isLifecycleEnabled() { + return true; + } + + @Override + public void quiet(String message) {} + + @Override + public void quiet(String message, Object... objects) {} + + @Override + public void quiet(String message, Throwable throwable) {} + + @Override + public boolean isQuietEnabled() { + return false; + } + + @Override + public boolean isEnabled(LogLevel level) { + return false; + } + + @Override + public void log(LogLevel level, String message) {} + + @Override + public void log(LogLevel level, String message, Object... objects) {} + + @Override + public void log(LogLevel level, String message, Throwable throwable) {} + + @Override + public String getName() { + return "RecordingLogger"; + } + + @Override + public boolean isTraceEnabled() { + return false; + } + + @Override + public void trace(String msg) {} + + @Override + public void trace(String format, Object arg) {} + + @Override + public void trace(String format, Object arg1, Object arg2) {} + + @Override + public void trace(String format, Object... arguments) {} + + @Override + public void trace(String msg, Throwable t) {} + + @Override + public boolean isTraceEnabled(Marker marker) { + return false; + } + + @Override + public void trace(Marker marker, String msg) {} + + @Override + public void trace(Marker marker, String format, Object arg) {} + + @Override + public void trace(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void trace(Marker marker, String format, Object... argArray) {} + + @Override + public void trace(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isDebugEnabled() { + return false; + } + + @Override + public void debug(String msg) {} + + @Override + public void debug(String format, Object arg) {} + + @Override + public void debug(String format, Object arg1, Object arg2) {} + + @Override + public void debug(String format, Object... arguments) {} + + @Override + public void debug(String msg, Throwable t) {} + + @Override + public boolean isDebugEnabled(Marker marker) { + return false; + } + + @Override + public void debug(Marker marker, String msg) {} + + @Override + public void debug(Marker marker, String format, Object arg) {} + + @Override + public void debug(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void debug(Marker marker, String format, Object... arguments) {} + + @Override + public void debug(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isInfoEnabled() { + return false; + } + + @Override + public void info(String msg) {} + + @Override + public void info(String format, Object arg) {} + + @Override + public void info(String format, Object arg1, Object arg2) {} + + @Override + public void info(String format, Object... arguments) {} + + @Override + public void info(String msg, Throwable t) {} + + @Override + public boolean isInfoEnabled(Marker marker) { + return false; + } + + @Override + public void info(Marker marker, String msg) {} + + @Override + public void info(Marker marker, String format, Object arg) {} + + @Override + public void info(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void info(Marker marker, String format, Object... arguments) {} + + @Override + public void info(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isWarnEnabled() { + return false; + } + + @Override + public void warn(String msg) {} + + @Override + public void warn(String format, Object arg) {} + + @Override + public void warn(String format, Object... arguments) {} + + @Override + public void warn(String format, Object arg1, Object arg2) {} + + @Override + public void warn(String msg, Throwable t) {} + + @Override + public boolean isWarnEnabled(Marker marker) { + return false; + } + + @Override + public void warn(Marker marker, String msg) {} + + @Override + public void warn(Marker marker, String format, Object arg) {} + + @Override + public void warn(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void warn(Marker marker, String format, Object... arguments) {} + + @Override + public void warn(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isErrorEnabled() { + return false; + } + + @Override + public void error(String msg) {} + + @Override + public void error(String format, Object arg) {} + + @Override + public void error(String format, Object arg1, Object arg2) {} + + @Override + public void error(String format, Object... arguments) {} + + @Override + public void error(String msg, Throwable t) {} + + @Override + public boolean isErrorEnabled(Marker marker) { + return false; + } + + @Override + public void error(Marker marker, String msg) {} + + @Override + public void error(Marker marker, String format, Object arg) {} + + @Override + public void error(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void error(Marker marker, String format, Object... arguments) {} + + @Override + public void error(Marker marker, String msg, Throwable t) {} +} diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java new file mode 100644 index 0000000..9864042 --- /dev/null +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/console/ScanProgressReporterTest.java @@ -0,0 +1,142 @@ +package com.arc_e_tect.gradle.detector.core.console; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ScanProgressReporter") +class ScanProgressReporterTest { + + private final RecordingLogger logger = new RecordingLogger(); + + @Test + @DisplayName("emits nothing before the item-count throttle is reached") + void emitsNothingBeforeItemCountThrottleIsReached() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 5, 3600, fixedClock(0)); + + for (int i = 0; i < 4; i++) { + reporter.step(); + } + + assertThat(logger.lifecycleMessages()).isEmpty(); + } + + @Test + @DisplayName("emits a status line once the item-count throttle is reached") + void emitsStatusLineOnceItemCountThrottleIsReached() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 5, 3600, fixedClock(0)); + + for (int i = 0; i < 5; i++) { + reporter.step(); + } + + assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 5/100 (5%)"); + } + + @Test + @DisplayName("does not emit again until another full item-count throttle window has passed") + void doesNotEmitAgainUntilAnotherFullItemCountWindow() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 5, 3600, fixedClock(0)); + + for (int i = 0; i < 9; i++) { + reporter.step(); + } + + assertThat(logger.lifecycleMessages()).hasSize(1); + } + + @Test + @DisplayName("emits nothing before the elapsed-time throttle is reached") + void emitsNothingBeforeElapsedTimeThrottleIsReached() { + AtomicLong nanos = new AtomicLong(0); + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 1_000_000, 2, nanos::get); + + nanos.set(1_000_000_000L); // 1 second, less than the 2-second throttle + reporter.step(); + + assertThat(logger.lifecycleMessages()).isEmpty(); + } + + @Test + @DisplayName("emits a status line once the elapsed-time throttle is reached, even with few items") + void emitsStatusLineOnceElapsedTimeThrottleIsReached() { + AtomicLong nanos = new AtomicLong(0); + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 1_000_000, 2, nanos::get); + + nanos.set(2_000_000_000L); // exactly 2 seconds + reporter.step(); + + assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%)"); + } + + @Test + @DisplayName("complete() emits a final line even when the last step landed inside the throttle window") + void completeEmitsFinalLineDespiteThrottleWindow() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 50, 3600, fixedClock(0)); + + reporter.step(); + reporter.complete(); + + assertThat(logger.lifecycleMessages()).containsExactly("Scanning: done, 1 item(s)"); + } + + @Test + @DisplayName("a zero-total determinate reporter emits nothing until complete(), which reports 0 items cleanly") + void zeroTotalReporterEmitsNothingUntilComplete() { + ScanProgressReporter reporter = ScanProgressReporter.determinate(logger, "Scanning", 0); + + reporter.complete(); + + assertThat(logger.lifecycleMessages()).containsExactly("Scanning: done, 0 item(s)"); + } + + @Test + @DisplayName("indeterminate mode reports a running count with no total or percentage") + void indeterminateModeReportsRunningCount() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Resolving", -1, 3, 3600, fixedClock(0)); + + for (int i = 0; i < 3; i++) { + reporter.step(); + } + + assertThat(logger.lifecycleMessages()).containsExactly("Resolving: 3 processed so far"); + } + + @Test + @DisplayName("indeterminate mode's complete() line matches the determinate wording") + void indeterminateCompleteLineMatchesDeterminateWording() { + ScanProgressReporter reporter = ScanProgressReporter.indeterminate(logger, "Resolving"); + + reporter.step(); + reporter.step(); + reporter.complete(); + + assertThat(logger.lifecycleMessages()).contains("Resolving: done, 2 item(s)"); + } + + @Test + @DisplayName("step(String detail) appends the detail to the line it actually emits") + void stepWithDetailAppendsDetailToEmittedLine() { + ScanProgressReporter reporter = + new ScanProgressReporter(logger, "Scanning", 100, 1, 3600, fixedClock(0)); + + reporter.step("UserController.java"); + + assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%) - UserController.java"); + } + + private LongSupplier fixedClock(long value) { + return () -> value; + } +} diff --git a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java index b4d9b5e..44ce882 100644 --- a/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java +++ b/api-detector-core/src/test/java/com/arc_e_tect/gradle/detector/core/openapi/OpenApiEndpointCollectorTest.java @@ -6,6 +6,7 @@ import java.io.File; import java.net.URL; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -125,6 +126,45 @@ void throwsForUnparsableDocument(@org.junit.jupiter.api.io.TempDir java.nio.file .hasMessageContaining("failed to parse OpenAPI document"); } + @Test + @DisplayName("collect(File) with no callback still collects endpoints exactly as before") + void collectWithoutCallbackStillCollectsEndpoints() { + List endpoints = collector.collect( + resource("openapi/single-file/openapi.yaml"), file -> { }); + + assertThat(endpoints).hasSize(4); + } + + @Test + @DisplayName("invokes the callback once for the root document, even when it has no $ref at all") + void invokesCallbackForRootDocumentWithNoRefs() { + List resolved = new ArrayList<>(); + + collector.collect(resource("openapi/single-file/openapi.yaml"), resolved::add); + + assertThat(resolved).hasSize(1); + } + + @Test + @DisplayName("invokes the callback once for the root document and once for each distinct referenced document") + void invokesCallbackForRootAndEachReferencedDocument() { + List resolved = new ArrayList<>(); + + collector.collect(resource("openapi/with-ref/openapi.yaml"), resolved::add); + + assertThat(resolved).hasSize(2); + } + + @Test + @DisplayName("the callback receives the referenced document's own file, not just the root") + void callbackReceivesTheReferencedDocumentFile() { + List resolved = new ArrayList<>(); + + collector.collect(resource("openapi/with-ref/openapi.yaml"), resolved::add); + + assertThat(resolved).anyMatch(file -> file.getName().equals("users.yaml")); + } + private static File resource(String name) { URL url = OpenApiEndpointCollectorTest.class.getClassLoader().getResource(name); if (url == null) {