From c315d1cd2b0a09cfbd4b8263a0578537558be101 Mon Sep 17 00:00:00 2001 From: Alexey Semenyuk Date: Wed, 11 Mar 2026 16:39:57 +0000 Subject: [PATCH] 8357404: jpackage should attempt to get a package version from the JDK's release file if the --version option is not specified Reviewed-by: almatvee --- .../jpackage/internal/LinuxFromOptions.java | 17 + .../jdk/jpackage/internal/MacFromOptions.java | 11 +- .../jpackage/internal/ApplicationBuilder.java | 62 +- .../jdk/jpackage/internal/FromOptions.java | 17 +- .../jdk/jpackage/internal/ModuleInfo.java | 32 +- .../resources/MainResources.properties | 2 + .../internal/util/RuntimeReleaseFile.java | 130 +++ .../jdk/jpackage/internal/WinFromOptions.java | 12 +- test/jdk/tools/jpackage/TEST.properties | 2 +- .../jdk/jpackage/test/AppImageFile.java | 3 +- .../jdk/jpackage/test/JPackageCommand.java | 116 +- .../jdk/jpackage/test/PackageType.java | 38 +- .../internal/model/DottedVersionTest.java | 46 +- .../internal/util/RuntimeReleaseFileTest.java | 172 +++ .../tools/jpackage/share/AppVersionTest.java | 1027 ++++++++++++++++- .../jpackage/share/RuntimePackageTest.java | 12 +- 16 files changed, 1560 insertions(+), 139 deletions(-) create mode 100644 src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RuntimeReleaseFile.java create mode 100644 test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RuntimeReleaseFileTest.java diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java index 0791c79c662..1f954036431 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromOptions.java @@ -27,6 +27,7 @@ package jdk.jpackage.internal; import static jdk.jpackage.internal.FromOptions.buildApplicationBuilder; import static jdk.jpackage.internal.FromOptions.createPackageBuilder; import static jdk.jpackage.internal.LinuxPackagingPipeline.APPLICATION_LAYOUT; +import static jdk.jpackage.internal.cli.StandardBundlingOperation.CREATE_LINUX_RPM; import static jdk.jpackage.internal.cli.StandardOption.LINUX_APP_CATEGORY; import static jdk.jpackage.internal.cli.StandardOption.LINUX_DEB_MAINTAINER_EMAIL; import static jdk.jpackage.internal.cli.StandardOption.LINUX_MENU_GROUP; @@ -39,6 +40,7 @@ import static jdk.jpackage.internal.model.StandardPackageType.LINUX_DEB; import static jdk.jpackage.internal.model.StandardPackageType.LINUX_RPM; import jdk.jpackage.internal.cli.Options; +import jdk.jpackage.internal.model.DottedVersion; import jdk.jpackage.internal.model.Launcher; import jdk.jpackage.internal.model.LinuxApplication; import jdk.jpackage.internal.model.LinuxDebPackage; @@ -67,6 +69,10 @@ final class LinuxFromOptions { appBuilder.launchers().map(LinuxPackagingPipeline::normalizeShortcuts).ifPresent(appBuilder::launchers); + if (OptionUtils.bundlingOperation(options) == CREATE_LINUX_RPM) { + appBuilder.derivedVersionNormalizer(LinuxFromOptions::normalizeRpmVersion); + } + return LinuxApplication.create(appBuilder.create()); } @@ -118,4 +124,15 @@ final class LinuxFromOptions { return pkgBuilder; } + private static String normalizeRpmVersion(String version) { + // RPM does not support "-" symbol in version. In some case + // we might have "-" from "release" file version. + // Normalize version if it has "-" symbols. All other supported version + // formats by "release" file should be supported by RPM. + if (version.contains("-")) { + return DottedVersion.lazy(version).toComponentsString(); + } + + return version; + } } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacFromOptions.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacFromOptions.java index be9519c57a6..20dcbcf5742 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacFromOptions.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacFromOptions.java @@ -31,7 +31,6 @@ import static jdk.jpackage.internal.MacRuntimeValidator.validateRuntimeHasJliLib import static jdk.jpackage.internal.MacRuntimeValidator.validateRuntimeHasNoBinDir; import static jdk.jpackage.internal.OptionUtils.isBundlingOperation; import static jdk.jpackage.internal.cli.StandardBundlingOperation.CREATE_MAC_PKG; -import static jdk.jpackage.internal.cli.StandardBundlingOperation.SIGN_MAC_APP_IMAGE; import static jdk.jpackage.internal.cli.StandardOption.APPCLASS; import static jdk.jpackage.internal.cli.StandardOption.ICON; import static jdk.jpackage.internal.cli.StandardOption.MAC_APP_CATEGORY; @@ -65,6 +64,7 @@ import jdk.jpackage.internal.cli.OptionValue; import jdk.jpackage.internal.cli.Options; import jdk.jpackage.internal.cli.StandardFaOption; import jdk.jpackage.internal.model.ApplicationLaunchers; +import jdk.jpackage.internal.model.DottedVersion; import jdk.jpackage.internal.model.ExternalApplication; import jdk.jpackage.internal.model.FileAssociation; import jdk.jpackage.internal.model.Launcher; @@ -234,7 +234,7 @@ final class MacFromOptions { superAppBuilder.launchers(new ApplicationLaunchers(MacLauncher.create(mainLauncher), launchers.additionalLaunchers())); } - final var app = superAppBuilder.create(); + superAppBuilder.derivedVersionNormalizer(MacFromOptions::normalizeVersion); return superAppBuilder; } @@ -389,4 +389,11 @@ final class MacFromOptions { private static boolean hasPkgInstallerSignIdentity(Options options) { return options.contains(MAC_SIGNING_KEY_NAME) || options.contains(MAC_INSTALLER_SIGN_IDENTITY); } + + private static String normalizeVersion(String version) { + // macOS requires 1, 2 or 3 components version string. + // When reading from release file it can be 1 or 3 or maybe more. + // We will always normalize to 3 components if needed. + return DottedVersion.lazy(version).trim(3).toComponentsString(); + } } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationBuilder.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationBuilder.java index ebfae9f8d4c..0cca59f7c19 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationBuilder.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationBuilder.java @@ -36,17 +36,20 @@ import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.UnaryOperator; import jdk.jpackage.internal.model.AppImageLayout; import jdk.jpackage.internal.model.Application; import jdk.jpackage.internal.model.ApplicationLaunchers; import jdk.jpackage.internal.model.ExternalApplication; import jdk.jpackage.internal.model.Launcher; import jdk.jpackage.internal.model.LauncherIcon; +import jdk.jpackage.internal.model.LauncherModularStartupInfo; import jdk.jpackage.internal.model.LauncherStartupInfo; import jdk.jpackage.internal.model.ResourceDirLauncherIcon; import jdk.jpackage.internal.model.RuntimeBuilder; import jdk.jpackage.internal.model.RuntimeLayout; import jdk.jpackage.internal.util.RootedPath; +import jdk.jpackage.internal.util.RuntimeReleaseFile; final class ApplicationBuilder { @@ -65,6 +68,8 @@ final class ApplicationBuilder { appImageLayout = other.appImageLayout; runtimeBuilder = other.runtimeBuilder; launchers = other.launchers; + runtimeReleaseFile = other.runtimeReleaseFile; + derivedVersionNormalizer = other.derivedVersionNormalizer; } ApplicationBuilder copy() { @@ -86,7 +91,7 @@ final class ApplicationBuilder { return new Application.Stub( effectiveName, Optional.ofNullable(description).orElse(effectiveName), - Optional.ofNullable(version).orElseGet(DEFAULTS::version), + validatedVersion(), Optional.ofNullable(vendor).orElseGet(DEFAULTS::vendor), Optional.ofNullable(copyright).orElseGet(DEFAULTS::copyright), Optional.ofNullable(appDirSources).orElseGet(List::of), @@ -147,6 +152,11 @@ final class ApplicationBuilder { Optional version() { return Optional.ofNullable(version); + } + + ApplicationBuilder runtimeReleaseFile(Path v) { + runtimeReleaseFile = v; + return this; } ApplicationBuilder vendor(String v) { @@ -169,6 +179,54 @@ final class ApplicationBuilder { return this; } + ApplicationBuilder derivedVersionNormalizer(UnaryOperator v) { + derivedVersionNormalizer = v; + return this; + } + + private String validatedVersion() { + return Optional.ofNullable(version).or(() -> { + // Application version has not been specified explicitly. Derive it. + var derivedVersion = derivedVersion(); + if (derivedVersionNormalizer != null) { + derivedVersion = derivedVersion.map(v -> { + var mappedVersion = derivedVersionNormalizer.apply(v); + if (!mappedVersion.equals(v)) { + Log.verbose(I18N.format("message.version-normalized", mappedVersion, v)); + } + return mappedVersion; + }); + } + return derivedVersion; + }).orElseGet(DEFAULTS::version); + } + + private Optional derivedVersion() { + if (appImageLayout instanceof RuntimeLayout && runtimeReleaseFile != null) { + try { + var releaseVersion = new RuntimeReleaseFile(runtimeReleaseFile).getJavaVersion().toString(); + Log.verbose(I18N.format("message.release-version", releaseVersion)); + return Optional.of(releaseVersion); + } catch (Exception ex) { + Log.verbose(ex); + return Optional.empty(); + } + } else if (launchers != null) { + return launchers.mainLauncher().startupInfo() + .filter(LauncherModularStartupInfo.class::isInstance) + .map(LauncherModularStartupInfo.class::cast) + .flatMap(modularStartupInfo -> { + var moduleVersion = modularStartupInfo.moduleVersion(); + moduleVersion.ifPresent(v -> { + Log.verbose(I18N.format("message.module-version", v, modularStartupInfo.moduleName())); + }); + return moduleVersion; + }); + } else { + return Optional.empty(); + } + } + static ApplicationLaunchers normalizeIcons( ApplicationLaunchers appLaunchers, Optional resourceDir, BiFunction launcherOverrideCtor) { @@ -333,6 +391,8 @@ final class ApplicationBuilder { private AppImageLayout appImageLayout; private RuntimeBuilder runtimeBuilder; private ApplicationLaunchers launchers; + private Path runtimeReleaseFile; + private UnaryOperator derivedVersionNormalizer; private static final Defaults DEFAULTS = new Defaults( "1.0", diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/FromOptions.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/FromOptions.java index 4f590850328..4b4e6f9dbac 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/FromOptions.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/FromOptions.java @@ -59,10 +59,10 @@ import jdk.jpackage.internal.model.Application; import jdk.jpackage.internal.model.ApplicationLaunchers; import jdk.jpackage.internal.model.ApplicationLayout; import jdk.jpackage.internal.model.Launcher; -import jdk.jpackage.internal.model.LauncherModularStartupInfo; import jdk.jpackage.internal.model.PackageType; import jdk.jpackage.internal.model.RuntimeLayout; import jdk.jpackage.internal.util.RootedPath; +import jdk.jpackage.internal.util.RuntimeReleaseFile; final class FromOptions { @@ -212,19 +212,6 @@ final class FromOptions { runtimeBuilderBuilder.modulePath(ensureBaseModuleInModulePath(MODULE_PATH.findIn(options).orElseGet(List::of))); - if (!APP_VERSION.containsIn(options)) { - // Version is not specified explicitly. Try to get it from the app's module. - launchers.mainLauncher().startupInfo().ifPresent(startupInfo -> { - if (startupInfo instanceof LauncherModularStartupInfo modularStartupInfo) { - modularStartupInfo.moduleVersion().ifPresent(moduleVersion -> { - appBuilder.version(moduleVersion); - Log.verbose(I18N.format("message.module-version", - moduleVersion, modularStartupInfo.moduleName())); - }); - } - }); - } - predefinedRuntimeDirectory.ifPresentOrElse(runtimeBuilderBuilder::forRuntime, () -> { final var startupInfos = launchers.asList().stream() .map(Launcher::startupInfo) @@ -239,6 +226,8 @@ final class FromOptions { } } + predefinedRuntimeDirectory.map(RuntimeReleaseFile::releaseFilePathInRuntime).ifPresent(appBuilder::runtimeReleaseFile); + return appBuilder; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ModuleInfo.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ModuleInfo.java index 71d1a66659e..9555423db62 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ModuleInfo.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ModuleInfo.java @@ -24,17 +24,13 @@ */ package jdk.jpackage.internal; -import java.io.IOException; -import java.io.Reader; import java.lang.module.ModuleDescriptor; import java.lang.module.ModuleReference; import java.net.URI; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.Properties; +import jdk.jpackage.internal.util.RuntimeReleaseFile; record ModuleInfo(String name, Optional version, Optional mainClass, Optional location) { @@ -56,30 +52,16 @@ record ModuleInfo(String name, Optional version, Optional mainCl // We can't extract info about version and main class of a module // linked in external runtime without running ModuleFinder in that // runtime. But this is too much work as the runtime might have been - // coocked without native launchers. So just make sure the module - // is linked in the runtime by simply analysing the data + // cooked without native launchers. So just make sure the module + // is linked in the runtime by simply analyzing the data // of `release` file. - final Path releaseFile = cookedRuntime.resolve("release"); - - try (Reader reader = Files.newBufferedReader(releaseFile)) { - Properties props = new Properties(); - props.load(reader); - String moduleList = props.getProperty("MODULES"); - if (moduleList == null) { + try { + var cookedRuntimeModules = RuntimeReleaseFile.loadFromRuntime(cookedRuntime).getModules(); + if (!cookedRuntimeModules.contains(moduleName)) { return Optional.empty(); } - - if ((moduleList.startsWith("\"") && moduleList.endsWith("\"")) - || (moduleList.startsWith("\'") && moduleList.endsWith( - "\'"))) { - moduleList = moduleList.substring(1, moduleList.length() - 1); - } - - if (!List.of(moduleList.split("\\s+")).contains(moduleName)) { - return Optional.empty(); - } - } catch (IOException|IllegalArgumentException ex) { + } catch (Exception ex) { Log.verbose(ex); return Optional.empty(); } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties index 233067d6457..02784a52acc 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties @@ -52,6 +52,8 @@ message.app-image-created=Succeeded in building output application image directo message.debug-working-directory=Kept working directory for debug: {0} message.module-version=Using version "{0}" from module "{1}" as application version +message.release-version=Using version "{0}" from "release" file of the predefined runtime as a package version +message.version-normalized=Using version "{0}" normalized to platform supported format from "{1}" message.error-header=Error: {0} message.advice-header=Advice to fix: {0} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RuntimeReleaseFile.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RuntimeReleaseFile.java new file mode 100644 index 00000000000..e055417f391 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/RuntimeReleaseFile.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal.util; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Properties; + +public final class RuntimeReleaseFile { + + public RuntimeReleaseFile(Path releaseFilePath) throws IOException { + // The implementation is based on the behavior of + // jdk.tools.jlink.internal.plugins.ReleaseInfoPlugin, which + // uses java.util.Properties to read/write the "release" file. + try (Reader reader = Files.newBufferedReader(releaseFilePath)) { + props = new Properties(); + props.load(reader); + } + } + + /** + * Returns path to the "runtime" file in the specified runtime directory. + * + * @param runtimeDir the path to a directory with the standard Java runtime + * structure + */ + public static Path releaseFilePathInRuntime(Path runtimeDir) { + return runtimeDir.resolve("release"); + } + + /** + * Creates the instance form the "runtime" file in the specified runtime + * directory. + *

+ * Uses {@link #releaseFilePathInRuntime(Path)} to get the path to the "runtime" + * file in the specified runtime directory. + * + * @param runtimeDir the path to a directory with the standard Java runtime + * structure + */ + public static RuntimeReleaseFile loadFromRuntime(Path runtimeDir) throws IOException { + return new RuntimeReleaseFile(releaseFilePathInRuntime(runtimeDir)); + } + + /** + * Returns verbatim value of the property with the specified name or an empty + * {@code Optional} if there is no property with the specified name. + *

+ * Property values in the "release" file are enclosed in double quotes. + * This method returns the value with the double quotes. + * + * @param propertyName the property name + */ + public Optional findRawProperty(String propertyName) { + return Optional.ofNullable(props.getProperty(propertyName)); + } + + /** + * Returns unquoted value of the property with the specified name or an empty + * {@code Optional} if there is no property with the specified name. + *

+ * Property values in the "release" file are enclosed in double quotes. This + * method returns the value without the double quotes. + * + * @param propertyName the property name + */ + public Optional findProperty(String propertyName) { + return findRawProperty(propertyName).map(v -> { + if (v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"') { + return v.substring(1, v.length() - 1); + } else { + return v; + } + }); + } + + /** + * Returns the value of the "JAVA_VERSION" property. + *

+ * Will throw {@code NoSuchElementException} if there is no such property. Will + * use {@link Runtime.Version#parse(String)} method to parse version string. Any + * exception that it may yield will be passed to the caller verbatim. + * + * @throws NoSuchElementException if there is no such property + */ + public Runtime.Version getJavaVersion() { + return findProperty("JAVA_VERSION").map(Runtime.Version::parse).orElseThrow(NoSuchElementException::new); + } + + /** + * Returns the value of the "MODULES" property. + * + * @throws NoSuchElementException if there is no such property + */ + public List getModules() { + return findProperty("MODULES").map(v -> { + return List.of(v.split("\\s+")); + }).orElseThrow(NoSuchElementException::new); + } + + private final Properties props; +} diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromOptions.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromOptions.java index 6009e2f6724..f6080523e89 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromOptions.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromOptions.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2025, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -42,6 +42,7 @@ import static jdk.jpackage.internal.cli.StandardOption.WIN_UPGRADE_UUID; import static jdk.jpackage.internal.model.StandardPackageType.WIN_MSI; import jdk.jpackage.internal.cli.Options; +import jdk.jpackage.internal.model.DottedVersion; import jdk.jpackage.internal.model.Launcher; import jdk.jpackage.internal.model.WinApplication; import jdk.jpackage.internal.model.WinExePackage; @@ -73,6 +74,8 @@ final class WinFromOptions { appBuilder.launchers().map(WinPackagingPipeline::normalizeShortcuts).ifPresent(appBuilder::launchers); + appBuilder.derivedVersionNormalizer(WinFromOptions::normalizeVersion); + return WinApplication.create(appBuilder.create()); } @@ -112,4 +115,11 @@ final class WinFromOptions { return pkgBuilder.create(); } + + private static String normalizeVersion(String version) { + // Windows requires between 2 and 4 components version string. + // When reading from release file it can be 1 or 3 or maybe more. + // One component will be normalized to 2 and more then 4 will be trim to 4. + return DottedVersion.lazy(version).trim(4).pad(2).toComponentsString(); + } } diff --git a/test/jdk/tools/jpackage/TEST.properties b/test/jdk/tools/jpackage/TEST.properties index 5cc4aa7a1b9..99af197b989 100644 --- a/test/jdk/tools/jpackage/TEST.properties +++ b/test/jdk/tools/jpackage/TEST.properties @@ -8,7 +8,7 @@ requires.properties = \ jpackage.test.SQETest \ jpackage.test.MacSignTests -maxOutputSize = 2000000 +maxOutputSize = 10000000 # Run jpackage tests on windows platform sequentially. # Having "share" directory in the list affects tests on other platforms. diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AppImageFile.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AppImageFile.java index 2dcccc673a9..ce2cb6f6b57 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AppImageFile.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AppImageFile.java @@ -26,6 +26,7 @@ import static java.util.stream.Collectors.toMap; import static jdk.jpackage.internal.util.function.ThrowingConsumer.toConsumer; import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; +import static jdk.jpackage.test.JPackageCommand.DEFAULT_VERSION; import java.io.IOException; import java.nio.file.Path; @@ -65,7 +66,7 @@ public record AppImageFile(String mainLauncherName, Optional mainLaunche } public AppImageFile(String mainLauncherName, Optional mainLauncherClassName) { - this(mainLauncherName, mainLauncherClassName, "1.0", false, Map.of(mainLauncherName, Map.of())); + this(mainLauncherName, mainLauncherClassName, DEFAULT_VERSION, false, Map.of(mainLauncherName, Map.of())); } public AppImageFile(String mainLauncherName, String mainLauncherClassName) { diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java index 67c0ba41e3f..6b1f67d50f4 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java @@ -43,6 +43,7 @@ import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.ListIterator; import java.util.Map; @@ -60,7 +61,10 @@ import java.util.spi.ToolProvider; import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; +import jdk.jpackage.internal.model.DottedVersion; import jdk.jpackage.internal.util.MacBundle; +import jdk.jpackage.internal.util.Result; +import jdk.jpackage.internal.util.RuntimeReleaseFile; import jdk.jpackage.internal.util.function.ExceptionBox; import jdk.jpackage.internal.util.function.ThrowingConsumer; import jdk.jpackage.internal.util.function.ThrowingFunction; @@ -102,6 +106,7 @@ public class JPackageCommand extends CommandArguments { executeInDirectory = cmd.executeInDirectory; winMsiLogFile = cmd.winMsiLogFile; unpackedPackageDirectory = cmd.unpackedPackageDirectory; + explicitVersion = cmd.explicitVersion; } JPackageCommand createImmutableCopy() { @@ -246,22 +251,100 @@ public class JPackageCommand extends CommandArguments { public String version() { return PropertyFinder.findAppProperty(this, - PropertyFinder.cmdlineOptionWithValue("--app-version").or(cmd -> { - if (cmd.isRuntime() && PackageType.MAC.contains(cmd.packageType())) { - // This is a macOS runtime bundle. - var predefinedRuntimeBundle = MacBundle.fromPath(Path.of(cmd.getArgumentValue("--runtime-image"))); - if (predefinedRuntimeBundle.isPresent()) { - // This is a macOS runtime bundle created from the predefined runtime bundle (not a predefined runtime directory). - // The version of this bundle should be copied from the Info.plist file of the predefined runtime bundle. - return MacHelper.readPList(predefinedRuntimeBundle.get().infoPlistFile()).findValue("CFBundleVersion"); - } - } - return Optional.empty(); - }), + PropertyFinder.of(Optional.ofNullable(explicitVersion)) + .or(PropertyFinder.cmdlineOptionWithValue("--app-version")) + .or(JPackageCommand::derivedVersion), PropertyFinder.appImageFile(appImageFile -> { return appImageFile.version(); }) - ).orElse("1.0"); + ).orElse(DEFAULT_VERSION); + } + + private Optional derivedVersion() { + if (isRuntime()) { + var predefinedRuntimePath = Path.of(getArgumentValue("--runtime-image")); + if (TKit.isOSX()) { + // This is a macOS runtime bundle. + return MacBundle.fromPath(predefinedRuntimePath).map(predefinedRuntimeBundle -> { + return Result.>of(() -> { + // This is a macOS runtime bundle created from the predefined runtime bundle (not a predefined runtime directory). + // The version of this bundle should be copied from the Info.plist file of the predefined runtime bundle. + return MacHelper.readPList(predefinedRuntimeBundle.infoPlistFile()).findValue("CFBundleVersion"); + }).value().flatMap(x -> x).or(() -> { + // Failed to read version from the Info.plist file of the predefined runtime bundle. + // Try to read it from the "release" file of the predefined runtime directory. + return normalizedVersionFromRuntimeReleaseFile(predefinedRuntimeBundle.homeDir()); + }); + }).orElseGet(() -> { + return normalizedVersionFromRuntimeReleaseFile(predefinedRuntimePath); + }); + } else { + return normalizedVersionFromRuntimeReleaseFile(predefinedRuntimePath); + } + } else { + return Optional.empty(); + } + } + + private Optional normalizedVersionFromRuntimeReleaseFile(Path runtimeDir) { + return Result.of(() -> { + return RuntimeReleaseFile.loadFromRuntime(runtimeDir).getJavaVersion().toString(); + }, Exception.class).value().map(JPackageCommand::normalizeDerivedVersion).map(map -> { + return Objects.requireNonNull(map.get(packageType())); + }); + } + + public static Map normalizeDerivedVersion(String version) { + var dotted = DottedVersion.lazy(version); + + var map = new HashMap(); + + // Linux + map.put(PackageType.LINUX_IMAGE, version); + map.put(PackageType.LINUX_DEB, version); + if (dotted.getUnprocessedSuffix().contains("-")) { + map.put(PackageType.LINUX_RPM, dotted.toComponentsString()); + } else { + map.put(PackageType.LINUX_RPM, version); + } + + // macOS + PackageType.ALL_MAC.forEach(type -> { + map.put(type, dotted.trim(3).pad(1).toComponentsString()); + }); + + // Windows + PackageType.ALL_WINDOWS.forEach(type -> { + DottedVersion ver; + if (dotted.getComponentsCount() < 2) { + ver = dotted.pad(2); + } else { + ver = dotted.trim(4); + } + map.put(type, ver.toComponentsString()); + }); + + map.put(PackageType.IMAGE, Objects.requireNonNull(map.get(PackageType.appImageForOS(PackageType.IMAGE.os())))); + + return map; + } + + /** + * Sets application version. + *

+ * Use this method to explicitly set the application version. Normally, the + * application version can be derived from the command line, but sometimes, when + * jpackage derives it from other sources, the {@code JPackageCommand} class + * can't get it correctly. Use this method in these uncommon cases. + * + * @param v the application version or {@code null} to reset previously set + * value + * @return this + */ + public JPackageCommand version(String v) { + verifyMutable(); + explicitVersion = v; + return this; } public String name() { @@ -1930,6 +2013,7 @@ public class JPackageCommand extends CommandArguments { private Path executeInDirectory; private Path winMsiLogFile; private Path unpackedPackageDirectory; + private String explicitVersion; private Set readOnlyPathAsserts = Set.of(ReadOnlyPathAssert.values()); private Set standardAsserts = Set.of(StandardAssert.values()); private List> validators = new ArrayList<>(); @@ -1938,7 +2022,9 @@ public class JPackageCommand extends CommandArguments { VALUE } - private static final Map PACKAGE_TYPES = Stream.of(PackageType.values()).collect(toMap(PackageType::getType, x -> x)); + private static final Map PACKAGE_TYPES = Stream.of(PackageType.values()).filter(type -> { + return type.isNative() || type == PackageType.IMAGE; + }).collect(toMap(PackageType::getType, x -> x)); // Set the property to the path of run-time image to speed up // building app images and platform bundles by avoiding running jlink. @@ -1947,6 +2033,8 @@ public class JPackageCommand extends CommandArguments { // `--runtime-image` parameter set. public static final Path DEFAULT_RUNTIME_IMAGE = Optional.ofNullable(TKit.getConfigProperty("runtime-image")).map(Path::of).orElse(null); + public final static String DEFAULT_VERSION = "1.0"; + // [HH:mm:ss.SSS] private static final Pattern TIMESTAMP_REGEXP = Pattern.compile( "^\\[\\d\\d:\\d\\d:\\d\\d.\\d\\d\\d\\] "); diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PackageType.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PackageType.java index 085e782ea40..df020d39fc8 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PackageType.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/PackageType.java @@ -48,16 +48,22 @@ public enum PackageType { LINUX_RPM(".rpm", OperatingSystem.LINUX), MAC_DMG(".dmg", OperatingSystem.MACOS), MAC_PKG(".pkg", OperatingSystem.MACOS), - IMAGE; + IMAGE(OperatingSystem.current()), + WIN_IMAGE(OperatingSystem.WINDOWS), + LINUX_IMAGE(OperatingSystem.LINUX), + MAC_IMAGE(OperatingSystem.MACOS), + ; - PackageType() { + PackageType(OperatingSystem os) { + this.os = Objects.requireNonNull(os); type = "app-image"; suffix = null; - supported = true; - enabled = true; + supported = (os == OperatingSystem.current()); + enabled = supported; } PackageType(String packageName, String bundleSuffix, OperatingSystem os) { + this.os = Objects.requireNonNull(os); type = Objects.requireNonNull(packageName); suffix = Objects.requireNonNull(bundleSuffix); supported = isSupported(new BundlingOperationDescriptor(os, type)); @@ -88,10 +94,29 @@ public enum PackageType { return enabled; } + public boolean isAppImage() { + return type.equals(IMAGE.type); + } + + public boolean isNative() { + return !isAppImage(); + } + public String getType() { return type; } + public OperatingSystem os() { + return os; + } + + public static PackageType appImageForOS(OperatingSystem os) { + Objects.requireNonNull(os); + return Stream.of(LINUX_IMAGE, MAC_IMAGE, WIN_IMAGE).filter(type -> { + return type.os() == os; + }).findFirst().orElseThrow(); + } + public static RuntimeException throwSkippedExceptionIfNativePackagingUnavailable() { if (NATIVE.stream().noneMatch(PackageType::isSupported)) { TKit.throwSkippedException("None of the native packagers supported in this environment"); @@ -115,6 +140,7 @@ public enum PackageType { return new LinkedHashSet<>(List.of(types)); } + private final OperatingSystem os; private final String type; private final String suffix; private final boolean enabled; @@ -126,6 +152,10 @@ public enum PackageType { public static final Set NATIVE = Stream.of(LINUX, WINDOWS, MAC) .flatMap(Collection::stream).collect(Collectors.toUnmodifiableSet()); + public static final Set ALL_LINUX = orderedSet(LINUX_IMAGE, LINUX_DEB, LINUX_RPM); + public static final Set ALL_WINDOWS = orderedSet(WIN_IMAGE, WIN_MSI, WIN_EXE); + public static final Set ALL_MAC = orderedSet(MAC_IMAGE, MAC_DMG, MAC_PKG); + private static final class Inner { private static final Set DISABLED_PACKAGERS = Optional.ofNullable( TKit.tokenizeConfigProperty("disabledPackagers")).orElse( diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/model/DottedVersionTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/model/DottedVersionTest.java index df020f1a34c..65f9c71a910 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/model/DottedVersionTest.java +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/model/DottedVersionTest.java @@ -22,18 +22,19 @@ */ package jdk.jpackage.internal.model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrowsExactly; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertNotSame; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -54,14 +55,6 @@ public class DottedVersionTest { this(input, type.createVersion, "", expectedComponentCount, input); } - static TestConfig greedy(String input, int expectedComponentCount, String expectedToComponent) { - return new TestConfig(input, Type.GREEDY.createVersion, "", expectedComponentCount, expectedToComponent); - } - - static TestConfig greedy(String input, int expectedComponentCount) { - return new TestConfig(input, Type.GREEDY.createVersion, "", expectedComponentCount, input); - } - static TestConfig lazy(String input, String expectedSuffix, int expectedComponentCount, String expectedToComponent) { return new TestConfig(input, Type.LAZY.createVersion, expectedSuffix, expectedComponentCount, expectedToComponent); } @@ -74,6 +67,7 @@ public class DottedVersionTest { assertEquals(cfg.expectedSuffix(), dv.getUnprocessedSuffix()); assertEquals(cfg.expectedComponentCount(), dv.getComponents().length); assertEquals(cfg.expectedToComponent(), dv.toComponentsString()); + assertEquals(dv.toString(), cfg.input()); } private static List testValid() { @@ -123,7 +117,7 @@ public class DottedVersionTest { @ParameterizedTest @MethodSource - public void testTrim(DottedVersion ver, String expectedStr, int limit) { + public void test_trim(DottedVersion ver, String expectedStr, int limit) { var expected = DottedVersion.lazy(expectedStr); var actual = ver.trim(limit); assertEquals(expected, actual); @@ -136,14 +130,14 @@ public class DottedVersionTest { } @ParameterizedTest - @MethodSource - public void testTrimNegative(DottedVersion ver, int limit) { + @MethodSource("test_trim_pad_negative") + public void test_trim_negative(DottedVersion ver, int limit) { assertThrowsExactly(IllegalArgumentException.class, () -> { ver.trim(limit); }); } - private static Stream testTrim() { + private static Stream test_trim() { var testCases = new ArrayList(); @@ -160,15 +154,9 @@ public class DottedVersionTest { return testCases.stream().map(DottedVersionTest::mapFirstStringToDottedVersion); } - private static Stream testTrimNegative() { - return Stream.of( - Arguments.of("10.5.foo", -1) - ).map(DottedVersionTest::mapFirstStringToDottedVersion); - } - @ParameterizedTest @MethodSource - public void testPad(DottedVersion ver, String expectedStr, int limit) { + public void test_pad(DottedVersion ver, String expectedStr, int limit) { var expected = DottedVersion.lazy(expectedStr); var actual = ver.pad(limit); assertEquals(expected, actual); @@ -181,14 +169,14 @@ public class DottedVersionTest { } @ParameterizedTest - @MethodSource - public void testPadNegative(DottedVersion ver, int limit) { + @MethodSource("test_trim_pad_negative") + public void test_pad_negative(DottedVersion ver, int limit) { assertThrowsExactly(IllegalArgumentException.class, () -> { ver.pad(limit); }); } - private static Stream testPad() { + private static Stream test_pad() { var testCases = new ArrayList(); @@ -206,7 +194,7 @@ public class DottedVersionTest { return testCases.stream().map(DottedVersionTest::mapFirstStringToDottedVersion); } - private static Stream testPadNegative() { + private static Stream test_trim_pad_negative() { return Stream.of( Arguments.of("10.5.foo", -1) ).map(DottedVersionTest::mapFirstStringToDottedVersion); diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RuntimeReleaseFileTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RuntimeReleaseFileTest.java new file mode 100644 index 00000000000..097f63d51ab --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/RuntimeReleaseFileTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.lang.module.ModuleDescriptor; +import java.lang.module.ModuleFinder; +import java.lang.module.ModuleReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class RuntimeReleaseFileTest { + + @Test + void test_invalid_input(@TempDir Path workdir) { + assertThrows(IOException.class, () -> { + new RuntimeReleaseFile(workdir); + }); + } + + @Test + void test_findRawProperty(@TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFile(workdir, Map.of("Name", "John", "Company", "\"Acme LTD\""))); + + assertEquals(Optional.empty(), releaseFile.findRawProperty("foo")); + assertEquals(Optional.of("John"), releaseFile.findRawProperty("Name")); + assertEquals(Optional.of("\"Acme LTD\""), releaseFile.findRawProperty("Company")); + } + + @Test + void test_findProperty(@TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFile(workdir, Map.of("Name", "John", "Company", "\"Acme LTD\""))); + + assertEquals(Optional.empty(), releaseFile.findProperty("foo")); + assertEquals(Optional.of("John"), releaseFile.findProperty("Name")); + assertEquals(Optional.of("Acme LTD"), releaseFile.findProperty("Company")); + } + + @ParameterizedTest + @CsvSource({ + "foo, foo", + "\"foo\", foo", + "'foo', 'foo'", + "\"f\"o\"o\", f\"o\"o", + "\"foo, \"foo", + "foo\", foo\"", + }) + void test_findProperty(String rawValue, String expectedValue, @TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFile(workdir, Map.of("FOO", rawValue))); + + assertEquals(expectedValue, releaseFile.findProperty("FOO").orElseThrow()); + } + + @ParameterizedTest + @CsvSource({ + "\"27.1.2\", 27.1.2", + "27.1.2, 27.1.2", + "\"27.1.2-ea\", 27.1.2-ea", + "27.1.2-ea, 27.1.2-ea", + "\"27.1.2+15\", 27.1.2+15", + "27.1.2+15, 27.1.2+15", + }) + void test_getJavaVersion(String version, String expectedVersion, @TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFileWithValue(workdir, "JAVA_VERSION", version)); + + final var value = releaseFile.getJavaVersion(); + + assertEquals(expectedVersion, value.toString()); + } + + @ParameterizedTest + @CsvSource({ + "\"7.1.2+foo\"", + "\"foo\"", + "\"\"", + "7.1.2+foo", + "foo", + "''" + }) + void test_getJavaVersion_invalid(String version, @TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFileWithValue(workdir, "JAVA_VERSION", version)); + + var ex = assertThrows(RuntimeException.class, releaseFile::getJavaVersion); + + assertFalse(NoSuchElementException.class.isInstance(ex)); + } + + @Test + void test_without_version(@TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFileWithValue(workdir, "JDK_VERSION", "\"27.1.2\"")); + + assertThrowsExactly(NoSuchElementException.class, releaseFile::getJavaVersion); + } + + @Test + void test_getModules(@TempDir Path workdir) throws IOException { + var releaseFile = new RuntimeReleaseFile(createPropFileWithValue(workdir, "MODULES", "foo bar\t buz ")); + + assertEquals(List.of("foo", "bar", "buz"), releaseFile.getModules()); + } + + @Test + void test_current() throws IOException { + var releaseFile = new RuntimeReleaseFile(Path.of(System.getProperty("java.home")).resolve("release")); + + final var expectedVersion = Runtime.version(); + final var actualVersion = releaseFile.getJavaVersion(); + + assertEquals(expectedVersion.version(), actualVersion.version()); + + final var expectedModules = ModuleFinder.ofSystem().findAll().stream() + .map(ModuleReference::descriptor).map(ModuleDescriptor::name).sorted().toList(); + final var actualModules = releaseFile.getModules().stream().sorted().toList(); + + assertEquals(expectedModules, actualModules); + } + + private Path createPropFileWithValue(Path workdir, String name, String value) { + return createPropFile(workdir, Map.of(name, value)); + } + + private Path createPropFile(Path workdir, Map input) { + Path releaseFile = workdir.resolve("foo"); + Properties props = new Properties(); + props.putAll(input); + + try (Writer writer = Files.newBufferedWriter(releaseFile)) { + props.store(writer, null); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + + return releaseFile; + } +} diff --git a/test/jdk/tools/jpackage/share/AppVersionTest.java b/test/jdk/tools/jpackage/share/AppVersionTest.java index ad96a9b9e0c..3b6ac5a9241 100644 --- a/test/jdk/tools/jpackage/share/AppVersionTest.java +++ b/test/jdk/tools/jpackage/share/AppVersionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -22,15 +22,50 @@ */ +import static java.util.stream.Collectors.toUnmodifiableMap; +import static java.util.stream.Collectors.toUnmodifiableSet; +import static jdk.jpackage.internal.util.PListWriter.writeDict; +import static jdk.jpackage.internal.util.PListWriter.writePList; +import static jdk.jpackage.internal.util.XmlUtils.createXml; +import static jdk.jpackage.internal.util.XmlUtils.toXmlConsumer; +import static jdk.jpackage.test.JPackageCommand.DEFAULT_VERSION; +import static jdk.jpackage.test.JPackageCommand.normalizeDerivedVersion; +import static jdk.jpackage.test.JPackageCommand.RuntimeImageType.RUNTIME_TYPE_FAKE; + import java.io.IOException; -import java.util.Collection; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; -import javax.xml.xpath.XPathExpressionException; -import jdk.jpackage.test.AppImageFile; -import jdk.jpackage.test.Annotations.Parameters; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.util.MacBundle; +import jdk.jpackage.internal.util.RuntimeReleaseFile; +import jdk.jpackage.internal.util.Slot; +import jdk.jpackage.test.Annotations.ParameterSupplier; import jdk.jpackage.test.Annotations.Test; +import jdk.jpackage.test.AppImageFile; +import jdk.jpackage.test.CannedFormattedString; +import jdk.jpackage.test.ConfigurationTarget; import jdk.jpackage.test.JPackageCommand; +import jdk.jpackage.test.JPackageCommand.StandardAssert; +import jdk.jpackage.test.JPackageOutputValidator; +import jdk.jpackage.test.JPackageStringBundle; +import jdk.jpackage.test.JavaAppDesc; +import jdk.jpackage.test.MacHelper; +import jdk.jpackage.test.PackageTest; +import jdk.jpackage.test.PackageType; +import jdk.jpackage.test.RunnablePackageTest.Action; import jdk.jpackage.test.TKit; /* @@ -45,54 +80,954 @@ import jdk.jpackage.test.TKit; public final class AppVersionTest { - @Parameters - public static Collection input() { - List data = new ArrayList<>(); + @Test + @ParameterSupplier + public static void testApp(AppTestSpec testSpec) { - data.addAll(List.of(new Object[][]{ - // Default jpackage version - {"1.0", "Hello", null}, - {"1.0", "com.other/com.other.Hello", null}, - // Version should be picked from --app-version - {"3.1", "Hello", new String[]{"--app-version", "3.1"}}, - {"3.2", "com.other/com.other.Hello", new String[]{"--app-version", - "3.2"}}, - // Version should be picked from the last --app-version - {"3.3", "Hello", new String[]{"--app-version", "4", "--app-version", - "3.3"}}, - {"7.8", "com.other/com.other.Hello", new String[]{"--app-version", - "4", "--app-version", "7.8"}}, - // Pick version from jar - {"3.10.17", "com.other/com.other.Hello@3.10.17", null}, - // Ignore version in jar if --app-version given - {"7.5.81", "com.other/com.other.Hello@3.10.17", new String[]{ - "--app-version", "7.5.81"}} - })); + var moduleVersionSource = testSpec.findVersionSource(ModuleVersionSource.class); - return data; - } + ConfigurationTarget cfg; + if (testSpec.isImagePackageType()) { + var cmd = moduleVersionSource.map(ModuleVersionSource::appDesc) + .map(JPackageCommand::helloAppImage) + .orElseGet(JPackageCommand::helloAppImage); + cfg = new ConfigurationTarget(cmd); + } else { + var nativeTest = new PackageTest().forTypes(testSpec.spec().expected().keySet()); + moduleVersionSource + .map(ModuleVersionSource::appDesc) + .ifPresentOrElse(nativeTest::configureHelloApp, nativeTest::configureHelloApp); - public AppVersionTest(String expectedVersion, String javaAppDesc, - String[] jpackageArgs) { - this.expectedVersion = expectedVersion; - this.javaAppDesc = javaAppDesc; - this.jpackageArgs = jpackageArgs; + cfg = new ConfigurationTarget(nativeTest); + } + + cfg.addInitializer(JPackageCommand::setFakeRuntime); + + cfg.addInitializer(testSpec::applyTo); + + cfg.cmd().ifPresent(JPackageCommand::executeAndAssertHelloAppImageCreated); + + testSpec.validateVersion(cfg); + + cfg.test().ifPresent(pkg -> { + pkg.run(testSpec.spec().packageTestActions()); + }); } @Test - public void test() throws XPathExpressionException, IOException { - JPackageCommand cmd = JPackageCommand.helloAppImage(javaAppDesc); - if (jpackageArgs != null) { - cmd.addArguments(jpackageArgs); - } - cmd.executeAndAssertHelloAppImageCreated(); + @ParameterSupplier + @ParameterSupplier(value = "testMacPredefinedRuntimeBundle", ifOS = OperatingSystem.MACOS) + public static void testRuntime(RuntimeTestSpec testSpec) { - String actualVersion = AppImageFile.load(cmd.outputBundle()).version(); - TKit.assertEquals(expectedVersion, actualVersion, - "Check application version"); + var predefinedRuntimeDir = Slot.createEmpty(); + new PackageTest() + .forTypes(testSpec.spec().expected().keySet()) + .addRunOnceInitializer(() -> { + predefinedRuntimeDir.set(testSpec.createRuntime()); + }) + .addInitializer(cmd -> { + cmd.removeArgumentWithValue("--input").setArgumentValue("--runtime-image", predefinedRuntimeDir.get()); + }) + .addInitializer(testSpec::applyTo) + .mutate(test -> { + testSpec.validateVersion(new ConfigurationTarget(test)); + }) + .run(testSpec.spec().packageTestActions()); } - private final String expectedVersion; - private final String javaAppDesc; - private final String[] jpackageArgs; + public static Collection testApp() { + + List testCases = new ArrayList<>(); + + for (var modular : List.of(true, false)) { + String appDesc; + if (modular) { + appDesc = "com.other/com.other.Hello"; + } else { + appDesc = "Hello"; + } + + // Default version. + AppTestSpec.create(appDesc, TestSpec.build().expectDefaultVersion(), testCases::add); + + // Pick version from the command line. + AppTestSpec.create(appDesc, TestSpec.build().versionFromCmdline("3.1"), testCases::add); + } + + // Pick version from the modular jar. + AppTestSpec.create(TestSpec.build() + .versionFromAppModule("com.other/com.other.Hello@3.10.16"), testCases::add); + + // Pick version from the command line, ignore version of the modular jar. + AppTestSpec.create(TestSpec.build() + .versionFromAppModule("com.other/com.other.Hello@3.10.18") + .versionFromCmdline("7.5.81"), testCases::add); + + // Pick version from the modular jar. Apply package-specific normalization. + for (var ver : List.of( + "30.10.17.204.899-foo" + )) { + var versionSource = new ModuleVersionSource("com.other/com.other.Hello@" + ver); + + var builder = TestSpec.build().versionSource(versionSource); + for (var e : TestSpec.Builder.getExpectedVersions(versionSource).entrySet()) { + builder.withTypes(e.getKey()).expect(e.getValue()); + } + + AppTestSpec.create(builder, testCases::add); + } + + return testCases.stream().map(v -> { + return new Object[] {v}; + }).toList(); + } + + public static Collection testRuntime() { + + List testCases = new ArrayList<>(); + + // Default version. + RuntimeTestSpec.create(TestSpec.build().expectDefaultVersion(), testCases::add); + + // Pick version from the command line. + RuntimeTestSpec.create(TestSpec.build().versionFromCmdline("3.1"), testCases::add); + + // Invalid versions. + for (var ver : List.of("foo", "", "17.21.3+foo")) { + RuntimeTestSpec.create(TestSpec.build().versionFromReleaseFile(ver).expectDefaultVersion(), testCases::add); + } + + // Valid version values (see java.lang.Runtime.Version javadoc and https://openjdk.org/jeps/223) + for (var suffix : jep223VersionSuffixes()) { + for (var vnum : List.of("17", "17.1", "17.1.2", "17.1.2.3", "17.1.2.3.5")) { + var ver = new RuntimeReleaseFileVersionSource(vnum + suffix); + + var builder = TestSpec.build().versionSource(ver); + for (var e : TestSpec.Builder.getExpectedVersions(ver).entrySet()) { + builder.withTypes(e.getKey()).expect(e.getValue()); + } + + RuntimeTestSpec.create(builder, testCases::add); + } + } + + return testCases.stream().map(v -> { + return new Object[] {v}; + }).toList(); + } + + public static Collection testMacPredefinedRuntimeBundle() { + + List testCases = new ArrayList<>(); + + var appendTestCases = skipImagePackageType(testSpec -> { + for (var runtimeType : List.of( + RuntimeType.MAC_BUNDLE_PLIST_FILE_MALFORMED, + RuntimeType.MAC_BUNDLE_PLIST_WITHOUT_VERSION + )) { + testCases.add(new RuntimeTestSpec(runtimeType, testSpec)); + } + }); + + // Invalid version. + TestSpec.build().versionFromReleaseFile("foo").expectDefaultVersion().create(appendTestCases); + + // Valid versions. + for (var suffix : List.of("", "-foo")) { + for (var vnum : List.of("17", "17.1", "17.1.2", "17.1.2.3")) { + var ver = new RuntimeReleaseFileVersionSource(vnum + suffix); + + var builder = TestSpec.build().versionSource(ver); + var allBundleTypes = TestSpec.Builder.getExpectedVersions(ver); + for (var bundleType : PackageType.MAC) { + builder.withTypes(bundleType).expect(Objects.requireNonNull(allBundleTypes.get(bundleType))); + } + + builder.create(appendTestCases); + } + } + + return testCases.stream().map(v -> { + return new Object[] {v}; + }).toList(); + } + + private static List jep223VersionSuffixes() { + var suffixes = new HashSet(); + for (var pre : List.of("", "-ea")) { + for (var build : List.of("", "+5678")) { + for (var opt : List.of("", "-foo", "-12.UZ3")) { + if (pre.isEmpty() && build.isEmpty() && !opt.isEmpty()) { + suffixes.add("+" + opt); + } else { + suffixes.add(pre + build + opt); + } + } + } + } + return suffixes.stream().sorted().peek(suffix -> { + // Validate version suffixes. + Runtime.Version.parse("11" + suffix); + }).toList(); + } + + enum Message { + VERSION_FROM_MODULE("message.module-version", "version", "module"), + VERSION_FROM_RELEASE_FILE("message.release-version", "version"), + VERSION_NORMALIZED("message.version-normalized", "version", "version"), + ; + + Message(String key, Object ... args) { + this.key = Objects.requireNonNull(key); + this.args = args; + } + + CannedFormattedString cannedFormattedString(Object ... args) { + return JPackageStringBundle.MAIN.cannedFormattedString(key, args); + } + + TKit.TextStreamVerifier negateFind() { + var pattern = JPackageStringBundle.MAIN.cannedFormattedStringAsPattern(key, _ -> { + return Pattern.compile(".*"); + }, args); + return TKit.assertTextStream(pattern).negate(); + } + + String key() { + return key; + } + + private final String key; + private final Object[] args; + } + + sealed interface VersionSource { + String version(); + + VersionSource copyWithVersion(String v); + } + + enum DefaultVersionSource implements VersionSource { + INSTANCE; + + @Override + public String version() { + return DEFAULT_VERSION; + } + + @Override + public VersionSource copyWithVersion(String v) { + return this; + } + } + + record ModuleVersionSource(String appDesc) implements VersionSource { + + ModuleVersionSource { + Objects.requireNonNull(appDesc); + if (JavaAppDesc.parse(appDesc).moduleVersion() == null) { + throw new IllegalArgumentException(); + } + } + + @Override + public String version() { + return JavaAppDesc.parse(appDesc).moduleVersion(); + } + + @Override + public VersionSource copyWithVersion(String v) { + return new ModuleVersionSource(moduleName() + "@" + Objects.requireNonNull(v)); + } + + String moduleName() { + return JavaAppDesc.parse(appDesc).moduleName(); + } + + @Override + public String toString() { + return appDesc; + } + } + + record CmdlineVersionSource(String version) implements VersionSource { + + CmdlineVersionSource { + Objects.requireNonNull(version); + } + + @Override + public VersionSource copyWithVersion(String v) { + return new CmdlineVersionSource(v); + } + + @Override + public String toString() { + return String.format("--app-version=[%s]", version); + } + } + + record RuntimeReleaseFileVersionSource(String version) implements VersionSource { + + RuntimeReleaseFileVersionSource { + Objects.requireNonNull(version); + } + + @Override + public VersionSource copyWithVersion(String v) { + return new RuntimeReleaseFileVersionSource(v); + } + + @Override + public String toString() { + return String.format("JAVA_VERSION=[%s]", version); + } + } + + record Expected(String version, List messages) { + + Expected { + Objects.requireNonNull(version); + Objects.requireNonNull(messages); + } + + void applyTo(JPackageCommand cmd) { + Objects.requireNonNull(cmd); + new JPackageOutputValidator().expectMatchingStrings(messages).matchTimestamps().stripTimestamps().applyTo(cmd); + cmd.version(version); + + var expectMessageKeys = messages.stream().map(CannedFormattedString::key).toList(); + Stream.of(Message.values()).filter(message -> { + return !expectMessageKeys.contains(message.key()); + }).map(Message::negateFind).forEach(validator -> { + new JPackageOutputValidator().add(validator).applyTo(cmd); + }); + } + + @Override + public String toString() { + var sb = new StringBuilder(); + sb.append(version); + if (!messages.isEmpty()) { + sb.append("; ").append(messages); + } + return sb.toString(); + } + + static Builder build() { + return new Builder(); + } + + static final class Builder { + + Expected create() { + return new Expected(version, List.copyOf(messages)); + } + + Builder version(String v) { + version = v; + return this; + } + + Builder messages(List v) { + messages.addAll(v); + return this; + } + + Builder messages(CannedFormattedString... v) { + return messages(List.of(v)); + } + + Builder message(Message message, Object ... args) { + return messages(message.cannedFormattedString(args)); + } + + private String version; + private final List messages = new ArrayList<>(); + } + } + + record TestSpec(Collection versions, Map expected) { + + TestSpec { + Objects.requireNonNull(versions); + Objects.requireNonNull(expected); + + if (expected.isEmpty()) { + throw new IllegalArgumentException(); + } + + if (expected.keySet().contains(PackageType.IMAGE) && !Collections.disjoint(expected.keySet(), PackageType.NATIVE)) { + throw new IllegalArgumentException("Mixing of native and app image packaging"); + } + + if (expected.keySet().stream().map(PackageType::os).distinct().count() != 1) { + throw new IllegalArgumentException("All package types must be for the same OS"); + } + } + + Optional findVersionSource(Class versionSourceType) { + Objects.requireNonNull(versionSourceType); + return versions.stream().filter(versionSourceType::isInstance).map(versionSourceType::cast).findFirst(); + } + + void applyTo(JPackageCommand cmd) { + Objects.requireNonNull(cmd); + findVersionSource(CmdlineVersionSource.class).ifPresent(ver -> { + cmd.setArgumentValue("--app-version", ver.version()); + }); + expected.get(cmd.packageType()).applyTo(cmd); + } + + void validateVersion(ConfigurationTarget cfg) { + cfg.cmd().ifPresent(cmd -> { + var actualVersion = AppImageFile.load(cmd.outputBundle()).version(); + TKit.assertEquals(expected.get(cmd.packageType()).version(), actualVersion, "Check application version"); + }); + cfg.test().ifPresent(test -> { + expected.entrySet().forEach(e -> { + nativeBundleVersionPropertyName(e.getKey()).ifPresent(propertyName -> { + test.forTypes(e.getKey(), _ -> { + test.addBundlePropertyVerifier(propertyName, e.getValue().version()); + }); + }); + }); + }); + + if (os() == OperatingSystem.MACOS) { + cfg.addInstallVerifier(cmd -> { + final var bundleRoot = cmd.isImagePackageType() ? cmd.outputBundle() + : cmd.pathToUnpackedPackageFile(cmd.appInstallationDirectory()); + var plist = MacHelper.readPListFromAppImage(bundleRoot); + var expectedVersion = expected.get(cmd.packageType()).version(); + for (var prop : List.of("CFBundleVersion", "CFBundleShortVersionString")) { + TKit.assertEquals(expectedVersion, plist.queryValue(prop), + String.format("Check the value of '%s' property in [%s] bundle", prop, bundleRoot)); + } + }); + } + } + + boolean isImagePackageType() { + return expected.keySet().contains(PackageType.IMAGE); + } + + Action[] packageTestActions() { + if (os() == OperatingSystem.MACOS) { + return Action.CREATE_AND_UNPACK; + } else { + return new Action[] {Action.CREATE}; + } + } + + OperatingSystem os() { + return expected.keySet().iterator().next().os(); + } + + @Override + public String toString() { + var sb = new StringBuilder(); + + switch (versions.size()) { + case 0 -> { + } + case 1 -> { + sb.append(versions.iterator().next()).append("; "); + } + default -> { + sb.append("versions=").append(versions).append("; "); + } + } + + sb.append("expect="); + if (expected.values().stream().distinct().count() == 1) { + sb.append(expected.keySet().stream().sorted().toList()).append(":"); + sb.append(expected.values().iterator().next()); + } else { + sb.append('[').append(expected).append(']'); + } + + return sb.toString(); + } + + private static Optional nativeBundleVersionPropertyName(PackageType type) { + switch (type) { + case LINUX_DEB, LINUX_RPM -> { + return Optional.of("Version"); + } + case WIN_MSI -> { + return Optional.of("ProductVersion"); + } + default -> { + return Optional.empty(); + } + } + } + + static Builder build() { + return new Builder(); + } + + static final class Builder { + + Builder() { + } + + Builder(Builder other) { + currentTypes = other.currentTypes; + currentExpectedVersion = other.currentExpectedVersion; + pendingCommit = other.pendingCommit; + versions.addAll(other.versions); + expected.putAll(other.expected); + } + + Builder copy() { + return new Builder(this); + } + + void create(Consumer sink) { + Objects.requireNonNull(sink); + if (pendingCommit) { + copy().expect(expectedVersion()).commit().create(sink); + } else { + var types = expected.keySet(); + var copiedVersions = List.copyOf(versions); + if (types.contains(PackageType.IMAGE) && !Collections.disjoint(types, PackageType.NATIVE)) { + sink.accept(new TestSpec(copiedVersions, Map.of(PackageType.IMAGE, expected.get(PackageType.IMAGE)))); + var copy = new HashMap<>(expected); + copy.remove(PackageType.IMAGE); + sink.accept(new TestSpec(copiedVersions, copy)); + } else { + new TestSpec(copiedVersions, Map.copyOf(expected)); + } + } + } + + Builder versionSource(VersionSource v) { + if (Objects.requireNonNull(v) == DefaultVersionSource.INSTANCE) { + throw new IllegalArgumentException(); + } + versions.add(v); + pendingCommit = true; + return this; + } + + Builder versionFromCmdline(String v) { + return versionSource(new CmdlineVersionSource(v)); + } + + Builder versionFromAppModule(String v) { + return versionSource(new ModuleVersionSource(v)); + } + + Builder versionFromReleaseFile(String v) { + return versionSource(new RuntimeReleaseFileVersionSource(v)); + } + + Builder expect(VersionSource v) { + currentExpectedVersion = v; + pendingCommit = true; + return this; + } + + Builder expectDefaultVersion() { + return expect(DefaultVersionSource.INSTANCE); + } + + Builder expectVersionFromCmdline(String v) { + return expect(new CmdlineVersionSource(v)); + } + + Builder expectVersionFromAppModule(String appDesc) { + return expect(new ModuleVersionSource(appDesc)); + } + + Builder expectVersionFromReleaseFile(String v) { + return expect(new RuntimeReleaseFileVersionSource(v)); + } + + Builder withTypes(Set types) { + if (types.isEmpty()) { + types = ALL_TYPES; + } + + if (!currentTypes.equals(types)) { + commit(); + currentTypes = types; + } + + return this; + } + + Builder withTypes(PackageType ... types) { + return withTypes(Set.of(types)); + } + + static Map getExpectedVersions(VersionSource versionSource) { + var map = new HashMap(normalizeDerivedVersion(versionSource.version()).entrySet() + .stream() + .collect(toUnmodifiableMap(Map.Entry::getKey, e -> { + return versionSource.copyWithVersion(e.getValue()); + }) + )); + + ALL_TYPES.forEach(type -> { + map.putIfAbsent(type, DefaultVersionSource.INSTANCE); + }); + + return map; + } + + private VersionSource expectedVersion() { + return Optional.ofNullable(currentExpectedVersion).or(() -> { + return versions.stream().filter(CmdlineVersionSource.class::isInstance).findFirst(); + }).or(() -> { + if (versions.size() == 1) { + return Optional.of(versions.getFirst()); + } else { + return Optional.empty(); + } + }).orElseThrow(IllegalStateException::new); + } + + private Builder commit() { + pendingCommit = false; + + if (versions.isEmpty() && currentExpectedVersion == null) { + // Nothing to commit. + return this; + } + + var filteredTypes = normalize(currentTypes); + if (filteredTypes.isEmpty()) { + // Version configuration is not supported on the current OS. + return this; + } + + VersionSource expectedVersion = expectedVersion(); + + VersionSource versionSource; + if (expectedVersion == DefaultVersionSource.INSTANCE) { + versionSource = expectedVersion; + } else { + versionSource = versions.stream().filter(expectedVersion.getClass()::isInstance).findFirst().orElseThrow(); + } + + var expectedBuilder = Expected.build().version(expectedVersion.version()); + switch (versionSource) { + case ModuleVersionSource ver -> { + expectedBuilder.message(Message.VERSION_FROM_MODULE, ver.version(), ver.moduleName()); + } + case RuntimeReleaseFileVersionSource ver -> { + expectedBuilder.message(Message.VERSION_FROM_RELEASE_FILE, ver.version()); + } + default -> { + // NOP + } + } + + if (!versionSource.version().equals(expectedVersion.version())) { + expectedBuilder.message(Message.VERSION_NORMALIZED, expectedVersion.version(), versionSource.version()); + } + + var expectedValue = expectedBuilder.create(); + filteredTypes.forEach(type -> { + expected.put(type, expectedValue); + }); + + return this; + } + + private static Set normalize(Collection types) { + return types.stream().filter(type -> { + return type.os() == OperatingSystem.current(); + }) + // Filter out "exe" packaging as it is a duplicate of "msi" packaging and + // the testing lib can't validate properties of embedded msi file. + .filter(Predicate.isEqual(PackageType.WIN_EXE).negate()) + .map(type -> { + if (type.isAppImage()) { + return PackageType.IMAGE; + } else { + return type; + } + }).collect(toUnmodifiableSet()); + } + + private Set currentTypes = ALL_TYPES; + private VersionSource currentExpectedVersion; + private boolean pendingCommit; + private final List versions = new ArrayList<>(); + private final Map expected = new HashMap<>(); + + private final static Set ALL_TYPES = Set.of(PackageType.values()); + } + } + + record AppTestSpec(String appDesc, TestSpec spec) { + + AppTestSpec { + Objects.requireNonNull(appDesc); + Objects.requireNonNull(spec); + } + + AppTestSpec(TestSpec spec) { + this(spec.findVersionSource(ModuleVersionSource.class).orElseThrow().appDesc(), spec); + } + + static void create(String appDesc, TestSpec.Builder specBuilder, Consumer sink) { + Objects.requireNonNull(appDesc); + Objects.requireNonNull(sink); + specBuilder.create(spec -> { + sink.accept(new AppTestSpec(appDesc, spec)); + }); + } + + static void create(TestSpec.Builder specBuilder, Consumer sink) { + Objects.requireNonNull(sink); + specBuilder.create(spec -> { + sink.accept(new AppTestSpec(spec)); + }); + } + + Optional findVersionSource(Class versionSourceType) { + return spec.findVersionSource(versionSourceType); + } + + void applyTo(JPackageCommand cmd) { + spec.applyTo(cmd); + } + + void validateVersion(ConfigurationTarget cfg) { + spec.validateVersion(cfg); + } + + boolean isImagePackageType() { + return spec.isImagePackageType(); + } + + @Override + public String toString() { + var sb = new StringBuilder(); + if (spec.findVersionSource(ModuleVersionSource.class).map(ModuleVersionSource::appDesc).filter(appDesc::equals).isEmpty()) { + sb.append("app-desc=").append(appDesc()).append("; "); + } + sb.append(spec); + return sb.toString(); + } + } + + /** + * Type of the predefined runtime. + */ + enum RuntimeType { + /** + * A directory with the standard Java runtime structure. + */ + IMAGE, + /** + * macOS bundle with valid Info.plist file. + */ + MAC_BUNDLE, + /** + * macOS bundle with malformed Info.plist file. + */ + MAC_BUNDLE_PLIST_FILE_MALFORMED, + /** + * macOS bundle with Info.plist file without version. + */ + MAC_BUNDLE_PLIST_WITHOUT_VERSION, + ; + } + + record RuntimeTestSpec(RuntimeType type, TestSpec spec) { + + RuntimeTestSpec { + Objects.requireNonNull(type); + Objects.requireNonNull(spec); + if (spec.isImagePackageType()) { + throw new IllegalArgumentException(); + } + if (type == RuntimeType.MAC_BUNDLE && spec.os() != OperatingSystem.MACOS) { + throw new IllegalArgumentException("Bundle runtime is supported for macOS native packaging only"); + } + } + + static void create(TestSpec.Builder specBuilder, Consumer sink) { + Objects.requireNonNull(sink); + specBuilder.create(skipImagePackageType(spec -> { + if (spec.os() != OperatingSystem.MACOS) { + sink.accept(new RuntimeTestSpec(RuntimeType.IMAGE, spec)); + } else { + sink.accept(new RuntimeTestSpec(RuntimeType.IMAGE, spec)); + + if (spec.findVersionSource(CmdlineVersionSource.class).isPresent()) { + // Disable "AppVersionTest.testRuntime(mac_bundle; versions=[--app-version=[3.1], plist=[1.22.333]]; expect=[MAC_DMG, MAC_PKG]:3.1)" test for now + // as it will fail because jpackage halfway ignores the "--app-version" + // and any option in general that makes its way into the Info.plist file. + // When building a runtime from the predefined runtime bundle, + // jpackage copies the Info.plist file from the predefined runtime bundle into the output bundle verbatim. + return; + } + + var plistVersionSource = new InheritPListVersionSource(MAC_PREDEFINED_RUNTIME_BUNDLE_VERSION); + var specBuilderCopy = specBuilder.copy().versionSource(plistVersionSource).withTypes(PackageType.ALL_MAC); + if (spec.findVersionSource(CmdlineVersionSource.class).isEmpty()) { + specBuilderCopy.expect(plistVersionSource); + } + specBuilderCopy.create(skipImagePackageType(augmentedSpec -> { + sink.accept(new RuntimeTestSpec(RuntimeType.MAC_BUNDLE, augmentedSpec)); + })); + } + })); + } + + Optional findVersionSource(Class versionSourceType) { + return spec.findVersionSource(versionSourceType); + } + + void applyTo(JPackageCommand cmd) { + switch (type) { + case MAC_BUNDLE_PLIST_FILE_MALFORMED, MAC_BUNDLE_PLIST_WITHOUT_VERSION -> { + // Don't validate signature of the output bundle. If the Info.plist file is malformed, it will fail with the error: + // + // [22:59:25.374] TRACE: Command [/usr/bin/codesign --verify --deep --strict --verbose=2 RuntimeAppVersionTest.jdk](6) exited with exit code 3 and the following output: + // [22:59:25.375] TRACE: 2026-03-05 22:59:25.353 codesign[11351:61024] There was an error parsing the Info.plist for the bundle at URL <0x7f81e140ae70>: NSCocoaErrorDomain - 3840 + // [22:59:25.376] TRACE: 2026-03-05 22:59:25.370 codesign[11351:61024] There was an error parsing the Info.plist for the bundle at URL <0x7f81e1413b90>: NSCocoaErrorDomain - 3840 + // [22:59:25.376] TRACE: --prepared:RuntimeAppVersionTest.jdk/Contents/MacOS/libjli.dylib + // [22:59:25.377] TRACE: --validated:RuntimeAppVersionTest.jdk/Contents/MacOS/libjli.dylib + // + cmd.excludeStandardAsserts(StandardAssert.MAC_BUNDLE_UNSIGNED_SIGNATURE); + // This one will also fail. + cmd.excludeStandardAsserts(StandardAssert.MAC_RUNTIME_PLIST_JDK_KEY); + } + default -> { + // NOP + } + } + spec.applyTo(cmd); + } + + void validateVersion(ConfigurationTarget cfg) { + switch (type) { + case MAC_BUNDLE_PLIST_FILE_MALFORMED, MAC_BUNDLE_PLIST_WITHOUT_VERSION -> { + cfg.addInstallVerifier(cmd -> { + final var bundleRoot = cmd.isImagePackageType() ? cmd.outputBundle() + : cmd.pathToUnpackedPackageFile(cmd.appInstallationDirectory()); + final var infoPlist = new MacBundle(bundleRoot).infoPlistFile(); + TKit.assertFileExists(infoPlist); + TKit.trace(String.format("Bundle version property in [%s] file is unavailable. Skip validation", infoPlist)); + }); + } + default -> { + spec.validateVersion(cfg); + } + } + } + + Path createRuntime() throws IOException { + return createRuntime(type, findVersionSource(RuntimeReleaseFileVersionSource.class).map(VersionSource::version)); + } + + @Override + public String toString() { + return new StringBuilder().append(type.name().toLowerCase()).append("; ").append(spec).toString(); + } + + private record InheritPListVersionSource(String version) implements VersionSource { + + InheritPListVersionSource { + Objects.requireNonNull(version); + } + + @Override + public VersionSource copyWithVersion(String v) { + return new InheritPListVersionSource(v); + } + + @Override + public String toString() { + return String.format("plist=[%s]", version); + } + } + + private Path createRuntime(RuntimeType type, Optional releaseFileVersion) throws IOException { + Objects.requireNonNull(type); + Objects.requireNonNull(releaseFileVersion); + + Path predefinedRuntimeDir; + Path runtimeDir = switch (type) { + case IMAGE -> { + predefinedRuntimeDir = JPackageCommand.createInputRuntimeImage(RUNTIME_TYPE_FAKE); + yield predefinedRuntimeDir; + } + case MAC_BUNDLE -> { + releaseFileVersion.ifPresent(ver -> { + if (ver.equals(MAC_PREDEFINED_RUNTIME_BUNDLE_VERSION)) { + // The value of `JAVA_VERSION` property in the "release" file of the runtime + // and the value of the `CFBundleVersion` property in the plist file should be different + // to test corner cases of version picking logic. + throw new IllegalArgumentException(); + } + }); + + // Create macOS bundle with `MAC_PREDEFINED_RUNTIME_BUNDLE_VERSION` version. + // The idea is to have different values of `JAVA_VERSION` property in the "release" file + // of the runtime and the value of the `CFBundleVersion` property in the plist file. + predefinedRuntimeDir = MacHelper.buildRuntimeBundle().type(RUNTIME_TYPE_FAKE).mutator(cmd -> { + cmd.setArgumentValue("--app-version", MAC_PREDEFINED_RUNTIME_BUNDLE_VERSION); + }).create(); + yield MacBundle.fromPath(predefinedRuntimeDir).orElseThrow().homeDir(); + } + case MAC_BUNDLE_PLIST_FILE_MALFORMED, MAC_BUNDLE_PLIST_WITHOUT_VERSION -> { + predefinedRuntimeDir = MacHelper.buildRuntimeBundle().type(RUNTIME_TYPE_FAKE).create(); + var plistFile = new MacBundle(predefinedRuntimeDir).infoPlistFile(); + + switch (type) { + case MAC_BUNDLE_PLIST_FILE_MALFORMED -> { + TKit.trace(String.format("Create invalid plist file [%s]", plistFile)); + } + case MAC_BUNDLE_PLIST_WITHOUT_VERSION -> { + TKit.trace(String.format("Create empty plist file [%s]", plistFile)); + } + default -> { + throw new AssertionError(); + } + } + + createXml(plistFile, xml -> { + writePList(xml, toXmlConsumer(() -> { + if (type == RuntimeType.MAC_BUNDLE_PLIST_WITHOUT_VERSION) { + writeDict(xml, toXmlConsumer(() -> { + })); + } + })); + }); + yield MacBundle.fromPath(predefinedRuntimeDir).orElseThrow().homeDir(); + } + default -> { + throw new AssertionError(); + } + }; + + releaseFileVersion.ifPresent(ver -> { + TKit.createPropertiesFile( + RuntimeReleaseFile.releaseFilePathInRuntime(runtimeDir), + Map.of("JAVA_VERSION", "\"" + ver + "\"")); + }); + + return predefinedRuntimeDir; + } + + final static String MAC_PREDEFINED_RUNTIME_BUNDLE_VERSION = "1.22.333"; + } + + private static Consumer skipImagePackageType(Consumer consumer) { + Objects.requireNonNull(consumer); + return spec -> { + if (!spec.isImagePackageType()) { + consumer.accept(spec); + } + }; + } } diff --git a/test/jdk/tools/jpackage/share/RuntimePackageTest.java b/test/jdk/tools/jpackage/share/RuntimePackageTest.java index a18efce62c6..87fd3496ba5 100644 --- a/test/jdk/tools/jpackage/share/RuntimePackageTest.java +++ b/test/jdk/tools/jpackage/share/RuntimePackageTest.java @@ -23,6 +23,7 @@ import static jdk.internal.util.OperatingSystem.LINUX; import static jdk.internal.util.OperatingSystem.MACOS; +import static jdk.jpackage.test.JPackageCommand.DEFAULT_VERSION; import static jdk.jpackage.test.TKit.assertFalse; import static jdk.jpackage.test.TKit.assertTrue; @@ -81,7 +82,16 @@ public class RuntimePackageTest { @Test public static void test() { - init().run(); + init() + .addInitializer(cmd -> { + // JDK-8357404 enables jpackage to pick a version from the "release" file + // of the predefined runtime when bundling the runtime package. + // This makes the output of this test dependent on the version of the running JDK + // and will be an inconvenience for SQE testing. + // Explicitly specify the package version to fulfill expectations of SQE. + cmd.setArgumentValue("--app-version", DEFAULT_VERSION); + }) + .run(); } @Test(ifOS = MACOS)