diff --git a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.java b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.java index d7d256deec..e48ee4cb00 100644 --- a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.java +++ b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.java @@ -16,11 +16,15 @@ package org.mobilitydata.gtfsvalidator.app.gui; import com.google.common.flogger.FluentLogger; +import java.awt.Color; import java.awt.Component; +import java.awt.Cursor; import java.awt.Dimension; import java.awt.Font; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.io.File; import java.net.URI; import java.net.URISyntaxException; @@ -53,12 +57,15 @@ public class GtfsValidatorApp extends JFrame { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final Dimension VERTICAL_GAP = new Dimension(0, 40); + private static final Dimension TEXT_GAP = new Dimension(0, 10); private static final Font BOLD_FONT = createBoldFont(); private final JTextField gtfsInputField = new JTextField(); private final JTextField outputDirectoryField = new JTextField(); + private final JPanel newVersionAvailablePanel = new JPanel(); + private final JButton validateButton = new JButton(); private final JPanel advancedOptionsPanel = new JPanel(); @@ -120,6 +127,11 @@ void addPreValidationCallback(Runnable callback) { preValidationCallbacks.add(callback); } + public void showNewVersionAvailable() { + newVersionAvailablePanel.setVisible(true); + pack(); + } + void constructUI() { setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); @@ -133,6 +145,7 @@ void constructUI() { constructOutputDirectorySection(panel); panel.add(Box.createRigidArea(VERTICAL_GAP)); constructAdvancedOptionsPanel(panel); + constructNewVersionAvailablePanel(panel); constructValidateButton(panel); // Ensure everything is left-aligned in the main application panel. @@ -247,6 +260,32 @@ private void constructAdvancedOptionsPanel(JPanel parent) { advancedOptionsPanel.setVisible(false); } + private void constructNewVersionAvailablePanel(JPanel parent) { + // Panel is initially not shown. + newVersionAvailablePanel.setVisible(false); + newVersionAvailablePanel.setLayout( + new BoxLayout(newVersionAvailablePanel, BoxLayout.PAGE_AXIS)); + parent.add(newVersionAvailablePanel); + + newVersionAvailablePanel.add( + createLabelWithFont(bundle.getString("new_version_available"), BOLD_FONT)); + newVersionAvailablePanel.add(Box.createRigidArea(TEXT_GAP)); + + JLabel download_link = new JLabel(bundle.getString("download_here")); + download_link.setForeground(Color.BLUE.darker()); + download_link.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + download_link.addMouseListener( + new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + validationDisplay.handleBrowseToHomepage(); + } + }); + newVersionAvailablePanel.add(download_link); + + newVersionAvailablePanel.add(Box.createRigidArea(VERTICAL_GAP)); + } + private void constructValidateButton(JPanel panel) { JPanel validateButtonPanel = new JPanel(); validateButtonPanel.setLayout(new BoxLayout(validateButtonPanel, BoxLayout.LINE_AXIS)); diff --git a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/Main.java b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/Main.java index 529b9cb091..c8b9b0f1fb 100644 --- a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/Main.java +++ b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/Main.java @@ -26,6 +26,7 @@ import javax.swing.SwingUtilities; import javax.swing.UIManager; import org.mobilitydata.gtfsvalidator.runner.ValidationRunner; +import org.mobilitydata.gtfsvalidator.util.VersionResolver; /** * The main entry point for the GUI application. @@ -74,9 +75,10 @@ private static void createAndShowGUI(String[] args) { logger.atSevere().withCause(e).log("Error setting system look-and-feel"); } + VersionResolver resolver = new VersionResolver(); ValidationDisplay display = new ValidationDisplay(); MonitoredValidationRunner runner = - new MonitoredValidationRunner(new ValidationRunner(), display); + new MonitoredValidationRunner(new ValidationRunner(resolver), display); GtfsValidatorApp app = new GtfsValidatorApp(runner, display); app.constructUI(); @@ -88,6 +90,14 @@ private static void createAndShowGUI(String[] args) { prefs.savePreferences(app); }); + // Check to see if there is a new version of the app available. + resolver.addCallback( + (versionInfo) -> { + if (versionInfo.updateAvailable()) { + SwingUtilities.invokeLater(() -> app.showNewVersionAvailable()); + } + }); + // On Windows, if you drag a file onto the application shortcut, it will // execute the app with the file as the first command-line argument. This // doesn't appear to work on Mac OS. diff --git a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/ValidationDisplay.java b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/ValidationDisplay.java index fd919d2aea..968e18e9ac 100644 --- a/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/ValidationDisplay.java +++ b/app/gui/src/main/java/org/mobilitydata/gtfsvalidator/app/gui/ValidationDisplay.java @@ -3,6 +3,7 @@ import com.google.common.flogger.FluentLogger; import java.awt.Desktop; import java.io.IOException; +import java.net.URI; import java.nio.file.Path; import javax.swing.JOptionPane; import org.mobilitydata.gtfsvalidator.runner.ValidationRunner; @@ -43,4 +44,13 @@ void handleError() { JOptionPane.ERROR_MESSAGE); System.exit(-1); } + + void handleBrowseToHomepage() { + try { + Desktop.getDesktop() + .browse(URI.create("https://github.com/MobilityData/gtfs-validator/releases")); + } catch (IOException ex) { + logger.atSevere().withCause(ex).log("Error opening webpage"); + } + } } diff --git a/app/gui/src/main/resources/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.properties b/app/gui/src/main/resources/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.properties index 4a8898a872..0334634911 100644 --- a/app/gui/src/main/resources/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.properties +++ b/app/gui/src/main/resources/org/mobilitydata/gtfsvalidator/app/gui/GtfsValidatorApp.properties @@ -8,6 +8,9 @@ output_directory=Output Directory: choose_output_directory=Choose Output Directory... output_directory_description=The validation report will be written here. +new_version_available=A new version of the Canonical GTFS Schedule validator is available! +download_here=Download here to get the latest/best validation results. + advanced=Advanced advanced_options=Advanced Options number_of_threads=Number of threads used to run the validator: diff --git a/core/build.gradle b/core/build.gradle index 930b4adc1f..e72d630284 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -48,6 +48,13 @@ dependencies { testImplementation 'com.google.truth.extensions:truth-java8-extension:1.0.1' } +jar { + manifest { + attributes('Implementation-Title': 'gtfs-validator-core', + 'Implementation-Version': project.version) + } +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/main/build.gradle b/main/build.gradle index dea00b85e5..2ce136c97d 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -77,10 +77,11 @@ dependencies { implementation 'com.univocity:univocity-parsers:2.9.0' implementation 'com.google.geometry:s2-geometry:2.0.0' implementation 'org.thymeleaf:thymeleaf:3.0.15.RELEASE' - implementation 'com.jcabi:jcabi-manifests:1.1' + implementation 'io.github.classgraph:classgraph:4.8.146' testImplementation group: 'junit', name: 'junit', version: '4.13' testImplementation 'com.google.truth:truth:1.0.1' testImplementation 'com.google.truth.extensions:truth-java8-extension:1.0.1' + testImplementation 'org.mockito:mockito-core:4.5.1' } test { @@ -91,6 +92,8 @@ test { testLogging { events "passed", "skipped", "failed" } + + systemProperty 'gtfsValidatorVersionForTest', project.version } // Share the test report data to be aggregated for the whole project diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/cli/Main.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/cli/Main.java index f29226f836..5d1822f061 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/cli/Main.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/cli/Main.java @@ -27,6 +27,7 @@ import java.nio.file.Paths; import org.mobilitydata.gtfsvalidator.notice.NoticeSchemaGenerator; import org.mobilitydata.gtfsvalidator.runner.ValidationRunner; +import org.mobilitydata.gtfsvalidator.util.VersionResolver; /** The main entry point for GTFS Validator CLI. */ public class Main { @@ -41,7 +42,7 @@ public static void main(String[] argv) { } try { - ValidationRunner runner = new ValidationRunner(); + ValidationRunner runner = new ValidationRunner(new VersionResolver()); if (runner.run(args.toConfig()) != ValidationRunner.Status.SUCCESS) { System.exit(-1); } @@ -49,6 +50,8 @@ public static void main(String[] argv) { logger.atSevere().withCause(ex).log("Error running validation"); System.exit(-1); } + + System.exit(0); } /** diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java index e72159ee50..88c5c05d24 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java @@ -16,7 +16,6 @@ package org.mobilitydata.gtfsvalidator.report; -import com.jcabi.manifests.Manifests; import java.io.FileWriter; import java.io.IOException; import java.nio.file.Path; @@ -25,6 +24,7 @@ import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; import org.mobilitydata.gtfsvalidator.report.model.ReportSummary; import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig; +import org.mobilitydata.gtfsvalidator.util.VersionInfo; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import org.thymeleaf.templatemode.TemplateMode; @@ -35,15 +35,17 @@ public class HtmlReportGenerator { /** Generate the HTML report using the class ReportSummary and the notice container. */ public void generateReport( - NoticeContainer noticeContainer, ValidationRunnerConfig config, Path reportPath) + NoticeContainer noticeContainer, + ValidationRunnerConfig config, + VersionInfo versionInfo, + Path reportPath) throws IOException { TemplateEngine templateEngine = new TemplateEngine(); ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); templateResolver.setTemplateMode(TemplateMode.HTML); templateEngine.setTemplateResolver(templateResolver); - ReportSummary summary = new ReportSummary(noticeContainer); - String version = Manifests.read("Implementation-Version"); + ReportSummary summary = new ReportSummary(noticeContainer, versionInfo); SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z"); Date now = new Date(System.currentTimeMillis()); @@ -52,7 +54,6 @@ public void generateReport( Context context = new Context(); context.setVariable("summary", summary); context.setVariable("config", config); - context.setVariable("version", version); context.setVariable("date", date); try (FileWriter writer = new FileWriter(reportPath.toFile())) { diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummary.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummary.java index 8f215c4192..ba29b6351a 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummary.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummary.java @@ -24,14 +24,16 @@ import org.mobilitydata.gtfsvalidator.notice.Notice; import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; import org.mobilitydata.gtfsvalidator.notice.SeverityLevel; +import org.mobilitydata.gtfsvalidator.util.VersionInfo; /** ReportSummary is the class containing the summary methods for the HTML report. */ public class ReportSummary { private final NoticeContainer container; private final Map severityCounts; private final Map>> noticesMap; + private final VersionInfo versionInfo; - public ReportSummary(NoticeContainer container) { + public ReportSummary(NoticeContainer container, VersionInfo versionInfo) { this.container = container; this.severityCounts = container.getValidationNotices().stream() @@ -44,6 +46,7 @@ public ReportSummary(NoticeContainer container) { NoticeView::getSeverityLevel, LinkedHashMap::new, Collectors.groupingBy(NoticeView::getCode, TreeMap::new, Collectors.toList()))); + this.versionInfo = versionInfo; } /** @@ -93,4 +96,12 @@ public long getWarningCount() { public long getInfoCount() { return severityCounts.getOrDefault(SeverityLevel.INFO, 0L); } + + public String getVersion() { + return versionInfo.currentVersion().orElse(null); + } + + public boolean isNewVersionOfValidatorAvailable() { + return versionInfo.updateAvailable(); + } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/runner/ValidationRunner.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/runner/ValidationRunner.java index 25bbdc95ed..12e91ce4ae 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/runner/ValidationRunner.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/runner/ValidationRunner.java @@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.time.Duration; import java.time.ZoneId; import java.time.ZonedDateTime; import org.mobilitydata.gtfsvalidator.input.CurrentDateTime; @@ -35,6 +36,8 @@ import org.mobilitydata.gtfsvalidator.report.HtmlReportGenerator; import org.mobilitydata.gtfsvalidator.table.GtfsFeedContainer; import org.mobilitydata.gtfsvalidator.table.GtfsFeedLoader; +import org.mobilitydata.gtfsvalidator.util.VersionInfo; +import org.mobilitydata.gtfsvalidator.util.VersionResolver; import org.mobilitydata.gtfsvalidator.validator.DefaultValidatorProvider; import org.mobilitydata.gtfsvalidator.validator.ValidationContext; import org.mobilitydata.gtfsvalidator.validator.ValidatorLoader; @@ -46,6 +49,8 @@ public class ValidationRunner { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final String GTFS_ZIP_FILENAME = "gtfs.zip"; + private final VersionResolver versionResolver; + public enum Status { // Indicates validation successfully completed, but doesn't imply the // feed itself is valid. @@ -58,7 +63,17 @@ public enum Status { EXCEPTION } + public ValidationRunner(VersionResolver versionResolver) { + this.versionResolver = versionResolver; + } + public Status run(ValidationRunnerConfig config) { + VersionInfo versionInfo = versionResolver.getVersionInfoWithTimeout(Duration.ofSeconds(5)); + logger.atInfo().log("VersionInfo: %s", versionInfo); + if (versionInfo.updateAvailable()) { + logger.atInfo().log("A new version of the validator is available!"); + } + ValidatorLoader validatorLoader = null; try { validatorLoader = new ValidatorLoader(); @@ -87,7 +102,7 @@ public Status run(ValidationRunnerConfig config) { noticeContainer.addSystemError(new URISyntaxError(e)); } if (gtfsInput == null) { - exportReport(noticeContainer, config); + exportReport(noticeContainer, config, versionInfo); if (!noticeContainer.getSystemErrors().isEmpty()) { return Status.SYSTEM_ERRORS; } else { @@ -110,7 +125,7 @@ public Status run(ValidationRunnerConfig config) { closeGtfsInput(gtfsInput, noticeContainer); // Output - exportReport(noticeContainer, config); + exportReport(noticeContainer, config, versionInfo); printSummary(startNanos, feedContainer); return Status.SUCCESS; } @@ -191,7 +206,7 @@ private static Gson createGson(boolean pretty) { /** Generates and exports reports for both validation notices and system errors reports. */ public static void exportReport( - final NoticeContainer noticeContainer, final ValidationRunnerConfig config) { + NoticeContainer noticeContainer, ValidationRunnerConfig config, VersionInfo versionInfo) { if (!Files.exists(config.outputDirectory())) { try { Files.createDirectories(config.outputDirectory()); @@ -207,7 +222,10 @@ public static void exportReport( config.outputDirectory().resolve(config.validationReportFileName()), gson.toJson(noticeContainer.exportValidationNotices()).getBytes(StandardCharsets.UTF_8)); generator.generateReport( - noticeContainer, config, config.outputDirectory().resolve(config.htmlReportFileName())); + noticeContainer, + config, + versionInfo, + config.outputDirectory().resolve(config.htmlReportFileName())); Files.write( config.outputDirectory().resolve(config.systemErrorsReportFileName()), gson.toJson(noticeContainer.exportSystemErrors()).getBytes(StandardCharsets.UTF_8)); diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionInfo.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionInfo.java new file mode 100644 index 0000000000..16d72e7650 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionInfo.java @@ -0,0 +1,35 @@ +package org.mobilitydata.gtfsvalidator.util; + +import com.google.auto.value.AutoValue; +import java.util.Optional; + +/** Version information about the validator. */ +@AutoValue +public abstract class VersionInfo { + + /** The version of the currently running validator instance. */ + public abstract Optional currentVersion(); + + /** The latest released version of the validator. */ + public abstract Optional latestReleaseVersion(); + + public boolean updateAvailable() { + if (currentVersion().isEmpty() || latestReleaseVersion().isEmpty()) { + return false; + } + // We don't trigger an update if the user is running a development snapshot. + if (currentVersion().get().endsWith("SNAPSHOT")) { + return false; + } + return !latestReleaseVersion().get().equals(currentVersion().get()); + } + + public static VersionInfo empty() { + return create(Optional.empty(), Optional.empty()); + } + + public static VersionInfo create( + Optional currentVersion, Optional latestReleaseVersion) { + return new AutoValue_VersionInfo(currentVersion, latestReleaseVersion); + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionResolver.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionResolver.java new file mode 100644 index 0000000000..90a79c8f6f --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/util/VersionResolver.java @@ -0,0 +1,150 @@ +package org.mobilitydata.gtfsvalidator.util; + +import com.google.common.flogger.FluentLogger; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.SettableFuture; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.Resource; +import io.github.classgraph.ScanResult; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Methods to resolve the {@link VersionInfo} for the current validator instance. Since resolving + * the latest release version requires an external network request, we resolve the version + * asynchronously. + */ +public class VersionResolver { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + /** We look up the latest release version at the following wiki page. */ + private static final String LATEST_RELEASE_VERSION_PAGE_URL = + "https://raw.githubusercontent.com/wiki/MobilityData/gtfs-validator/Current-Version.md"; + + private static final Pattern VERSION_PATTERN = Pattern.compile("version=(\\d+\\.\\d+\\.\\d+)"); + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + private SettableFuture resolvedVersionInfo = SettableFuture.create(); + + private boolean resolutionStarted = false; + + /** + * Attempts to resolve the application {@link VersionInfo} within the specified timeout. If the + * version info can't be resolved in the specified timeout, an empty info will be returned. + */ + public VersionInfo getVersionInfoWithTimeout(Duration timeout) { + try { + resolve(); + return resolvedVersionInfo.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (Throwable ex) { + return VersionInfo.empty(); + } + } + + /** + * Adds a callback that will be called with the resolved {@link VersionInfo} if and when it + * becomes available. + */ + public void addCallback(Consumer callback) { + resolve(); + Futures.addCallback( + resolvedVersionInfo, + new FutureCallback<>() { + @Override + public void onSuccess(VersionInfo result) { + callback.accept(result); + } + + @Override + public void onFailure(Throwable t) { + logger.atSevere().withCause(t).log("Error resolving version"); + } + }, + executor); + } + + /** Starts version resolution on a background thread. */ + public synchronized void resolve() { + if (resolutionStarted) { + return; + } + resolutionStarted = true; + + executor.submit( + () -> { + Optional currentVersion = resolveCurrentVersion(); + Optional latestReleaseVersion = resolveLatestReleaseVersion(); + VersionInfo info = VersionInfo.create(currentVersion, latestReleaseVersion); + resolvedVersionInfo.set(info); + return info; + }); + } + + /** + * We resolve the current application version by looking at META-INF/MANIFEST.MF entries. This + * resolution is slightly complicated, depending on our deployment environment. For the + * application shadow jar, there will be a single MANIFEST.MF entry, with an Implementation-Title + * of `gtfs-validator`. In a non-shadow-jar deployment (e.g. unit-test or gradle :run), there will + * be multiple MANIFEST.MF entries (different jars on the classpath can provide there own), so we + * look for the `gtfs-validator-core` MANIFEST.MF, since the shadow jar won't be present. + * + *

The return value is Optional because it's possible no version info is found. + * + * @throws IOException + */ + private Optional resolveCurrentVersion() throws IOException { + ScanResult scan = new ClassGraph().scan(); + Optional gtfsValidatorCoreVersion = Optional.empty(); + for (Resource resource : scan.getResourcesWithPath("META-INF/MANIFEST.MF")) { + Manifest m = new Manifest(); + m.read(resource.open()); + String title = m.getMainAttributes().getValue("Implementation-Title"); + if (title == null) { + continue; + } + if (title.equals("gtfs-validator")) { + // We prefer the gtfs-validator version and return it immediately if found. + String version = m.getMainAttributes().getValue("Implementation-Version"); + if (version != null && !version.isBlank()) { + return Optional.of(version); + } + } else if (title.equals("gtfs-validator-core")) { + // We'll also use gtfs-validator-core version if available but keep processing in case + // we find gtfs-validator instead. + String version = m.getMainAttributes().getValue("Implementation-Version"); + if (version != null && !version.isBlank()) { + gtfsValidatorCoreVersion = Optional.of(version); + } + } + } + return gtfsValidatorCoreVersion; + } + + private Optional resolveLatestReleaseVersion() throws IOException { + URL url = new URL(LATEST_RELEASE_VERSION_PAGE_URL); + try (BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream()))) { + String line = null; + while ((line = in.readLine()) != null) { + Matcher m = VERSION_PATTERN.matcher(line); + if (m.matches()) { + return Optional.of(m.group(1)); + } + } + } + return Optional.empty(); + } +} diff --git a/main/src/main/resources/report.html b/main/src/main/resources/report.html index 285873b211..b11b7892e3 100644 --- a/main/src/main/resources/report.html +++ b/main/src/main/resources/report.html @@ -32,6 +32,11 @@ padding: 1em 2em; } + .version-update { + font-weight: bold; + color: red; + } + table { width: 100%; } @@ -90,16 +95,20 @@ .desc-content h3 { margin-top: 0; } +

GTFS Schedule Validation Report

+ +

A new version of the Canonical GTFS Schedule validator is available! Please update to get the latest/best validation results.

+

X notices reported (0 errors, 0 warnings, 0 infos)

-

This validation report was generated using the Canonical GTFS Schedule validator

+

This validation report was generated using the Canonical GTFS Schedule validator.

Use this report alongside the RULES.md file to get more details about the validation issues.

@@ -146,7 +155,7 @@

Settings and version

-

+

Parameters used (more about the available parameters in USAGE.md):

diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummaryTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummaryTest.java index fa259fdd49..e9329af278 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummaryTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/report/model/ReportSummaryTest.java @@ -17,7 +17,11 @@ package org.mobilitydata.gtfsvalidator.report.model; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import java.util.Optional; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -26,6 +30,7 @@ import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; import org.mobilitydata.gtfsvalidator.notice.SeverityLevel; import org.mobilitydata.gtfsvalidator.notice.UnknownColumnNotice; +import org.mobilitydata.gtfsvalidator.util.VersionInfo; @RunWith(JUnit4.class) public class ReportSummaryTest { @@ -42,7 +47,7 @@ private static ReportSummary generateReportSummary() { noticeContainer.addValidationNotice( new NonAsciiOrNonPrintableCharNotice("test.txt", 1, "column", "value")); noticeContainer.addValidationNotice(new UnknownColumnNotice("test.txt", "unknown", 2)); - ReportSummary reportSummary = new ReportSummary(noticeContainer); + ReportSummary reportSummary = new ReportSummary(noticeContainer, VersionInfo.empty()); return reportSummary; } @@ -94,4 +99,22 @@ public void noticesMapTest() { .size(), 1); } + + @Test + public void testVersionPresent() { + VersionInfo versionInfo = VersionInfo.create(Optional.of("1.2.3"), Optional.of("1.2.4")); + ReportSummary reportSummary = new ReportSummary(new NoticeContainer(), versionInfo); + + assertEquals("1.2.3", reportSummary.getVersion()); + assertTrue(reportSummary.isNewVersionOfValidatorAvailable()); + } + + @Test + public void testVersionMissing() { + VersionInfo versionInfo = VersionInfo.empty(); + ReportSummary reportSummary = new ReportSummary(new NoticeContainer(), versionInfo); + + assertNull(reportSummary.getVersion()); + assertFalse(reportSummary.isNewVersionOfValidatorAvailable()); + } } diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionInfoTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionInfoTest.java new file mode 100644 index 0000000000..5314abbbec --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionInfoTest.java @@ -0,0 +1,48 @@ +package org.mobilitydata.gtfsvalidator.util; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class VersionInfoTest { + + @Test + public void testUpdateAvailable_bothMissing() { + VersionInfo info = VersionInfo.create(Optional.empty(), Optional.empty()); + assertThat(info.updateAvailable()).isFalse(); + } + + @Test + public void testUpdateAvailable_currentVersionMissing() { + VersionInfo info = VersionInfo.create(Optional.empty(), Optional.of("1.2.3")); + assertThat(info.updateAvailable()).isFalse(); + } + + @Test + public void testUpdateAvailable_latestReleaseMissing() { + VersionInfo info = VersionInfo.create(Optional.of("1.2.3"), Optional.empty()); + assertThat(info.updateAvailable()).isFalse(); + } + + @Test + public void testUpdateAvailable_versionMatch() { + VersionInfo info = VersionInfo.create(Optional.of("1.2.3"), Optional.of("1.2.3")); + assertThat(info.updateAvailable()).isFalse(); + } + + @Test + public void testUpdateAvailable_versionMismatch() { + VersionInfo info = VersionInfo.create(Optional.of("1.2.3"), Optional.of("1.2.4")); + assertThat(info.updateAvailable()).isTrue(); + } + + @Test + public void testUpdateAvailable_snapshotVersion() { + VersionInfo info = VersionInfo.create(Optional.of("1.2.5-SNAPSHOT"), Optional.of("1.2.4")); + assertThat(info.updateAvailable()).isFalse(); + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionResolverTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionResolverTest.java new file mode 100644 index 0000000000..66d5862011 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/util/VersionResolverTest.java @@ -0,0 +1,118 @@ +package org.mobilitydata.gtfsvalidator.util; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class VersionResolverTest { + + private static final MockStreamHandler mockStreamHandler = new MockStreamHandler(); + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @BeforeClass + public static void beforeClass() throws IOException { + URLStreamHandlerFactory urlStreamHandlerFactory = mock(URLStreamHandlerFactory.class); + URL.setURLStreamHandlerFactory(urlStreamHandlerFactory); + when(urlStreamHandlerFactory.createURLStreamHandler("https")).thenReturn(mockStreamHandler); + mockStreamHandler.setContent(""); + } + + @Test + public void testResolveLatestReleaseVersion() throws IOException { + StringBuilder b = new StringBuilder(); + b.append("Version Information from the wiki\n"); + b.append("```\n"); + b.append("version=10.0.5\n"); + b.append("```\n"); + mockStreamHandler.setContent(b.toString()); + + VersionResolver checker = new VersionResolver(); + VersionInfo versionInfo = checker.getVersionInfoWithTimeout(TIMEOUT); + + assertThat(versionInfo.latestReleaseVersion()).hasValue("10.0.5"); + } + + @Test + public void testLatestReleaseVersionNotFound() throws IOException { + StringBuilder b = new StringBuilder(); + b.append("Page not found"); + mockStreamHandler.setContent(b.toString()); + + VersionResolver checker = new VersionResolver(); + VersionInfo versionInfo = checker.getVersionInfoWithTimeout(TIMEOUT); + + assertThat(versionInfo.latestReleaseVersion()).isEmpty(); + } + + @Test + public void testLocalVersion() { + // This property is set via gradle test { } stanza. + String expectedVersion = System.getProperty("gtfsValidatorVersionForTest"); + assertThat(expectedVersion).isNotEmpty(); + + VersionResolver checker = new VersionResolver(); + VersionInfo versionInfo = checker.getVersionInfoWithTimeout(TIMEOUT); + + assertThat(versionInfo.currentVersion()).hasValue(expectedVersion); + } + + @Test + public void testVersionCallback() throws IOException, InterruptedException { + StringBuilder b = new StringBuilder(); + b.append("version=10.0.5\n"); + mockStreamHandler.setContent(b.toString()); + + // A dummy callback that captures the updated version info and triggers a countdown latch + // that our test can wait for. + AtomicReference versionInfo = new AtomicReference<>(VersionInfo.empty()); + CountDownLatch callbackLatch = new CountDownLatch(1); + Consumer callback = + (updatedVersionInfo) -> { + versionInfo.set(updatedVersionInfo); + callbackLatch.countDown(); + }; + + VersionResolver checker = new VersionResolver(); + checker.addCallback(callback); + + // Wait until the callback is actually triggered. + callbackLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + + assertThat(versionInfo.get().latestReleaseVersion()).hasValue("10.0.5"); + } + + private static class MockStreamHandler extends URLStreamHandler { + + private URLConnection connection = mock(URLConnection.class); + + public void setContent(String content) throws IOException { + when(connection.getInputStream()) + .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } + + @Override + protected URLConnection openConnection(URL u) throws IOException { + return connection; + } + } +}