diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java index 09ec8ae830c..8fa0f58430e 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java @@ -140,6 +140,25 @@ public class FrontendTools { static final FrontendVersion MIN_NPM_VERSION_FOR_RELEASE_AGE = new FrontendVersion( 11, 10, 0); + // npm 11.17.0 is the first version that supports + // --min-release-age-exclude, which exempts the packages matching a + // minimatch pattern from both --min-release-age and --before. Older + // versions only warn about an unknown configuration and keep blocking + // the packages. + static final FrontendVersion MIN_NPM_VERSION_FOR_RELEASE_AGE_EXCLUDE = new FrontendVersion( + 11, 17, 0); + + // Node.js 24.19.0 is the first release of the supported Node.js line that + // ships npm 11.17.0, which is what a global installation has to be + // upgraded to. The Node.js version Vaadin installs itself is newer. + static final FrontendVersion MIN_NODE_VERSION_FOR_RELEASE_AGE_EXCLUDE = new FrontendVersion( + 24, 19, 0); + + // pnpm 10.17.0 is the first version that supports the + // minimumReleaseAgeExclude setting; pnpm 10.16 ignores it. + static final FrontendVersion MIN_PNPM_VERSION_FOR_RELEASE_AGE_EXCLUDE = new FrontendVersion( + 10, 17, 0); + // pnpm 10.16.0 is the first version that supports the // minimumReleaseAge setting used to delay installation of newly // published packages as a supply-chain mitigation. @@ -661,6 +680,50 @@ Optional getConfiguredSetting(List toolCommand, return Optional.empty(); } + /** + * Reads the values the given npm or pnpm command resolves for a + * configuration key that holds a list, such as + * {@code min-release-age-exclude}. + *

+ * Several keys can be given for a setting that the tool spells differently + * depending on its version; the first one that has a value is used. Both + * tools list such a setting as an array, but a single value written into a + * {@code .npmrc} may also arrive as a comma separated string. Only that + * string is split, as the values of an array are complete on their own and + * may contain a comma themselves, such as the brace expansion + * {@code @acme/{ui,core}}. + * + * @param toolCommand + * the npm or pnpm command to run + * @param workingDirectory + * the directory the configuration is resolved from, so that a + * project {@code .npmrc} is taken into account + * @param keys + * the configuration keys to look for, in order of preference + * @return the configured values, empty if none of the keys has a value or + * the configuration cannot be read + */ + List getConfiguredSettingValues(List toolCommand, + File workingDirectory, String... keys) { + JsonNode config = getResolvedConfiguration(toolCommand, + workingDirectory); + for (String key : keys) { + JsonNode value = config.get(key); + if (value == null || value.isNull()) { + continue; + } + Stream entries = value.isArray() + ? value.valueStream().map(JsonNode::asString) + : Stream.of(value.asString().split(",")); + List values = entries.map(String::trim) + .filter(entry -> !entry.isEmpty()).toList(); + if (!values.isEmpty()) { + return values; + } + } + return List.of(); + } + /** * Reads the configuration the given npm or pnpm command resolves for a * directory by running {@code config list --json}. @@ -750,16 +813,50 @@ public FrontendVersion getNpmVersion() throws UnknownVersionException { * @since 25.2 */ public boolean npmSupportsMinReleaseAge(List npmCommand) { - List versionCmd = new ArrayList<>(npmCommand); + return isAtLeast("npm", npmCommand, MIN_NPM_VERSION_FOR_RELEASE_AGE); + } + + /** + * Checks whether the given npm is new enough to know + * {@code --min-release-age-exclude}, which exempts the packages Vaadin + * publishes itself from the minimum frontend package age. + * + * @param npmCommand + * the npm command to invoke for {@code --version} + * @return {@code true} if the installed npm is new enough; {@code false} if + * it is older or its version cannot be determined + */ + boolean npmSupportsMinReleaseAgeExclude(List npmCommand) { + return isAtLeast("npm", npmCommand, + MIN_NPM_VERSION_FOR_RELEASE_AGE_EXCLUDE); + } + + /** + * Checks whether the given pnpm is new enough to know the + * {@code minimumReleaseAgeExclude} setting, which exempts the packages + * Vaadin publishes itself from the minimum frontend package age. + * + * @param pnpmCommand + * the pnpm command to invoke for {@code --version} + * @return {@code true} if the installed pnpm is new enough; {@code false} + * if it is older or its version cannot be determined + */ + boolean pnpmSupportsMinimumReleaseAgeExclude(List pnpmCommand) { + return isAtLeast("pnpm", pnpmCommand, + MIN_PNPM_VERSION_FOR_RELEASE_AGE_EXCLUDE); + } + + private boolean isAtLeast(String tool, List toolCommand, + FrontendVersion required) { + List versionCmd = new ArrayList<>(toolCommand); versionCmd.add("--version"); // NOSONAR try { - FrontendVersion actual = FrontendUtils.getVersion("npm", - versionCmd); - return actual.isEqualOrNewer(MIN_NPM_VERSION_FOR_RELEASE_AGE); + return FrontendUtils.getVersion(tool, versionCmd) + .isEqualOrNewer(required); } catch (UnknownVersionException e) { getLogger().debug( - "Could not determine npm version; falling back to --before for the minimum frontend package age check", - e); + "Could not determine the {} version; assuming it is older than {}", + tool, required.getFullVersion(), e); return false; } } diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java index 13b0890698c..1c2c2848d8d 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java @@ -1218,6 +1218,13 @@ public Options withCommercialBanner(boolean enableCommercialBanner) { * {@link TaskRunNpmInstall#DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS} is * used. The configuration of bun cannot be read, so the default always * applies for it. + *

+ * The packages Vaadin publishes itself ({@code @vaadin/*}) are exempt from + * the check, so that a project can be built right after a Vaadin release. + * Excluding them requires npm ≥ 11.17.0, which Node.js ≥ 24.19.0 + * ships with, or pnpm ≥ 10.17.0; bun cannot exclude packages on the + * command line, so with bun an installation may fail during the first day + * after a Vaadin release. * * @param minimumFrontendPackageAgeDays * minimum allowed age in days, {@code 0} to disable the check, diff --git a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java index 7e731c0cf53..d85a0a9f955 100644 --- a/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java +++ b/flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java @@ -72,6 +72,21 @@ public class TaskRunNpmInstall implements FallibleCommand { */ static final int DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS = 1; + /** + * The package name pattern that is exempt from the minimum frontend package + * age. The packages Vaadin publishes itself are pinned to the version of + * the platform in use, so a project that is built right after a Vaadin + * release has no older version to fall back to. + */ + static final String MINIMUM_FRONTEND_PACKAGE_AGE_EXCLUDE = "@vaadin/*"; + + /** + * A {@code minimumReleaseAgeExcludes} setting of a {@code bunfig.toml} that + * has a package Vaadin publishes among its values. + */ + private static final Pattern BUNFIG_VAADIN_EXCLUDE = Pattern + .compile("minimumReleaseAgeExcludes\\s*=\\s*\\[[^]]*[\"']@vaadin/"); + private static final String MODULES_YAML = ".modules.yaml"; private static final String NPM_VALIDATION_FAIL_MESSAGE = "%n%n======================================================================================================" @@ -323,8 +338,14 @@ private void runNpmInstall() throws ExecutionFailedException { } } - resolveMinimumFrontendPackageAgeArgument(options, tools, npmExecutable, - logger).ifPresent(npmInstallCommand::add); + MinimumFrontendPackageAge minimumAge = resolveMinimumFrontendPackageAge( + options, tools, npmExecutable, logger); + minimumAge.argument().ifPresent(npmInstallCommand::add); + // Also excluded when the age itself comes from the package manager + // configuration, so that a just released Vaadin version installs + npmInstallCommand.addAll( + resolveMinimumFrontendPackageAgeExcludeArguments(options, tools, + npmExecutable, minimumAge.applies(), logger)); postinstallCommand.add("run"); postinstallCommand.add("postinstall"); @@ -460,12 +481,16 @@ static String getToolName(Options options) { * installing frontend package versions that are too new, or nothing if the * age should not be restricted from here. *

- * A value configured through Vaadin is always used as is, {@code 0} - * disabling the check. When nothing is configured, the package manager is - * asked what it resolves for its own minimum release age setting; if it - * already has one, no argument is passed so that the package manager - * applies its own configuration. Only when neither is configured does + * A value configured through Vaadin is always used as is, {@code 0} adding + * no restriction. When nothing is configured, the package manager is asked + * what it resolves for its own minimum release age setting; if it already + * has one, no argument is passed so that the package manager applies its + * own configuration. Only when neither is configured does * {@link #DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS} apply. + *

+ * An age the package manager resolves for itself is reported as applying + * even when Vaadin is configured with {@code 0}, as a command line argument + * is the only thing Vaadin leaves out in that case. * * @param options * current build options @@ -476,19 +501,26 @@ static String getToolName(Options options) { * the npm, pnpm or bun command used for the install * @param logger * the logger to report the resolved source of the value to - * @return the install argument, or an empty optional if none should be - * passed + * @return whether an age applies at all, and the install argument if one + * has to be passed */ - static Optional resolveMinimumFrontendPackageAgeArgument( + static MinimumFrontendPackageAge resolveMinimumFrontendPackageAge( Options options, FrontendTools tools, List toolCommand, Logger logger) { Integer configuredDays = options.getMinimumFrontendPackageAgeDays(); - if (configuredDays != null && configuredDays == 0) { - return Optional.empty(); - } boolean npmSupportsMinReleaseAge = !options.isEnableBun() && !options.isEnablePnpm() && tools.npmSupportsMinReleaseAge(toolCommand); + if (configuredDays != null && configuredDays == 0) { + // Vaadin adds no restriction of its own, but an age the package + // manager is configured with still applies to the install + return new MinimumFrontendPackageAge( + getPackageManagerConfiguredMinimumReleaseAge(options, tools, + toolCommand, npmSupportsMinReleaseAge) + .filter(TaskRunNpmInstall::blocksSomeVersion) + .isPresent(), + Optional.empty()); + } int days; if (configuredDays != null) { days = configuredDays; @@ -502,12 +534,190 @@ static Optional resolveMinimumFrontendPackageAgeArgument( + "'{}' parameter to override it.", getToolName(options), packageManagerValue.get(), InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS); - return Optional.empty(); + return new MinimumFrontendPackageAge( + blocksSomeVersion(packageManagerValue.get()), + Optional.empty()); } days = DEFAULT_MINIMUM_FRONTEND_PACKAGE_AGE_DAYS; } - return Optional.of(getMinimumFrontendPackageAgeArgument(options, days, - npmSupportsMinReleaseAge)); + return new MinimumFrontendPackageAge(true, + Optional.of(getMinimumFrontendPackageAgeArgument(options, days, + npmSupportsMinReleaseAge))); + } + + /** + * Whether a minimum frontend package age applies to the install, and the + * install argument that makes the package manager apply the age Vaadin + * resolved, when it has to be passed at all. + * + * @param applies + * {@code true} when some version can be blocked for being too + * new, either because of this argument or because of the + * configuration of the package manager + * @param argument + * the install argument to pass, or an empty optional when the + * package manager needs none + */ + record MinimumFrontendPackageAge(boolean applies, + Optional argument) { + } + + /** + * Checks whether a minimum release age the package manager resolved for + * itself blocks some version, which every value but zero does. The + * {@code before} date npm falls back to is not a number and always blocks + * something. + */ + private static boolean blocksSomeVersion(String packageManagerValue) { + try { + return Double.parseDouble(packageManagerValue.trim()) != 0; + } catch (NumberFormatException e) { // NOSONAR + return true; + } + } + + /** + * Resolves the install arguments that exempt the packages Vaadin publishes + * itself from the minimum frontend package age, so that a project can be + * built with a Vaadin version that was released a moment ago. + *

+ *

+ * The patterns the package manager is configured with are passed along, as + * a command line value replaces them instead of adding to them. Only the + * excluded packages themselves are exempt; their own dependencies still + * have to be old enough. + * + * @param options + * current build options + * @param tools + * the frontend tools used to read the package manager version + * and configuration + * @param toolCommand + * the npm, pnpm or bun command used for the install + * @param ageApplies + * {@code false} when no version is blocked for being too new, in + * which case nothing has to be excluded + * @param logger + * the logger to report an unsupported package manager to + * @return the install arguments, empty if none should be passed + */ + static List resolveMinimumFrontendPackageAgeExcludeArguments( + Options options, FrontendTools tools, List toolCommand, + boolean ageApplies, Logger logger) { + if (!ageApplies) { + return List.of(); + } + if (options.isEnableBun()) { + if (!bunfigExcludesVaadinPackages(options.getNpmFolder(), logger)) { + warnAboutPackagesThatCannotBeExcluded(logger, + "bun accepts exclusions only as exact package names in the 'minimumReleaseAgeExcludes' setting of a bunfig.toml", + "List the '" + MINIMUM_FRONTEND_PACKAGE_AGE_EXCLUDE + + "' packages the project depends on in that setting, spelled out one by one, to get the same result as with npm and pnpm."); + } + return List.of(); + } + if (options.isEnablePnpm()) { + if (!tools.pnpmSupportsMinimumReleaseAgeExclude(toolCommand)) { + warnAboutPackagesThatCannotBeExcluded(logger, "pnpm older than " + + FrontendTools.MIN_PNPM_VERSION_FOR_RELEASE_AGE_EXCLUDE + .getFullVersion() + + " ignores the 'minimumReleaseAgeExclude' setting", + "Upgrade pnpm to " + + FrontendTools.MIN_PNPM_VERSION_FOR_RELEASE_AGE_EXCLUDE + .getFullVersion() + + " or newer."); + return List.of(); + } + List arguments = excludeArguments( + "--config.minimum-release-age-exclude=", + tools.getConfiguredSettingValues(toolCommand, + options.getNpmFolder(), "minimumReleaseAgeExclude", + "minimum-release-age-exclude")); + if (arguments.size() == 1) { + // pnpm reads the setting as a list only when the argument is + // given more than once, and pnpm 11 excludes every package + // when it is a single string instead of a list + arguments = List.of(arguments.get(0), arguments.get(0)); + } + return arguments; + } + if (!tools.npmSupportsMinReleaseAgeExclude(toolCommand)) { + warnAboutPackagesThatCannotBeExcluded(logger, "npm older than " + + FrontendTools.MIN_NPM_VERSION_FOR_RELEASE_AGE_EXCLUDE + .getFullVersion() + + " does not know the '--min-release-age-exclude' argument", + "Upgrade npm to " + + FrontendTools.MIN_NPM_VERSION_FOR_RELEASE_AGE_EXCLUDE + .getFullVersion() + + " or newer, which Node.js " + + FrontendTools.MIN_NODE_VERSION_FOR_RELEASE_AGE_EXCLUDE + .getFullVersion() + + " and newer ship with."); + return List.of(); + } + return excludeArguments("--min-release-age-exclude=", + tools.getConfiguredSettingValues(toolCommand, + options.getNpmFolder(), "min-release-age-exclude")); + } + + /** + * Builds one install argument per package name pattern that has to be + * excluded, which are the ones the package manager is configured with plus + * the one Vaadin needs. + */ + private static List excludeArguments(String argumentPrefix, + List configuredPatterns) { + List patterns = new ArrayList<>(configuredPatterns); + if (!patterns.contains(MINIMUM_FRONTEND_PACKAGE_AGE_EXCLUDE)) { + patterns.add(MINIMUM_FRONTEND_PACKAGE_AGE_EXCLUDE); + } + return patterns.stream().map(pattern -> argumentPrefix + pattern) + .toList(); + } + + /** + * Checks whether a {@code bunfig.toml} next to the {@code package.json} + * already lists packages Vaadin publishes in its + * {@code minimumReleaseAgeExcludes} setting, so that a build that has taken + * care of the exclusion is not warned about it on every run. + *

+ * The file is read as it is, as bun has no command for printing its + * resolved configuration. A {@code bunfig.toml} the project does not + * contain itself, such as the one in the home directory, is not seen. + */ + private static boolean bunfigExcludesVaadinPackages(File npmFolder, + Logger logger) { + File bunfig = new File(npmFolder, "bunfig.toml"); + if (!bunfig.isFile()) { + return false; + } + try { + String content = Files.readString(bunfig.toPath()); + return BUNFIG_VAADIN_EXCLUDE.matcher(content).find(); + } catch (IOException | UncheckedIOException e) { + logger.debug("Could not read '{}'", bunfig, e); + return false; + } + } + + private static void warnAboutPackagesThatCannotBeExcluded(Logger logger, + String reason, String remedy) { + logger.warn("The packages Vaadin publishes cannot be excluded from the " + + "minimum frontend package age, as {}. Installing a " + + "Vaadin version during the first day after its " + + "release may therefore fail. {}", reason, remedy); } /** diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java index ad86709938a..8cd60eba0f0 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java @@ -996,6 +996,73 @@ void getConfiguredSetting_pnpm_readsTheConfigurationWithConfigList() } } + @Test + void getConfiguredSettingValues_listAndCommaSeparatedValue_areRead() + throws CommandExecutionException { + try (MockedStatic frontendUtils = Mockito + .mockStatic(FrontendUtils.class)) { + frontendUtils + .when(() -> FrontendUtils.executeCommand(Mockito.anyList(), + Mockito.any())) + .thenReturn( + "{\"min-release-age-exclude\": [\"@acme/*\", \"lit\"]}"); + + assertEquals(List.of("@acme/*", "lit"), + tools.getConfiguredSettingValues(List.of("npm"), + new File(baseDir), "min-release-age-exclude")); + + // a single value written into an .npmrc may also arrive as a + // comma separated string + frontendUtils + .when(() -> FrontendUtils.executeCommand(Mockito.anyList(), + Mockito.any())) + .thenReturn( + "{\"min-release-age-exclude\": \"@acme/*, lit\"}"); + + assertEquals(List.of("@acme/*", "lit"), + tools.getConfiguredSettingValues(List.of("npm"), + new File(baseDir), "min-release-age-exclude")); + } + } + + @Test + void getConfiguredSettingValues_braceExpansionInAList_isKeptTogether() + throws CommandExecutionException { + try (MockedStatic frontendUtils = Mockito + .mockStatic(FrontendUtils.class)) { + // the values of a list are complete on their own, and the comma + // of a brace expansion does not separate two patterns + frontendUtils + .when(() -> FrontendUtils.executeCommand(Mockito.anyList(), + Mockito.any())) + .thenReturn( + "{\"min-release-age-exclude\": [\"@acme/{ui,core}\"]}"); + + assertEquals(List.of("@acme/{ui,core}"), + tools.getConfiguredSettingValues(List.of("npm"), + new File(baseDir), "min-release-age-exclude")); + } + } + + @Test + void getConfiguredSettingValues_keyWithoutValue_isEmpty() + throws CommandExecutionException { + try (MockedStatic frontendUtils = Mockito + .mockStatic(FrontendUtils.class)) { + frontendUtils + .when(() -> FrontendUtils.executeCommand(Mockito.anyList(), + Mockito.any())) + .thenReturn( + "{\"min-release-age-exclude\": null, \"omit\": []}"); + + assertEquals(List.of(), + tools.getConfiguredSettingValues(List.of("npm"), + new File(baseDir), "min-release-age-exclude")); + assertEquals(List.of(), tools.getConfiguredSettingValues( + List.of("npm"), new File(baseDir), "omit")); + } + } + @Test void getConfiguredSetting_firstKeyMissing_fallsBackToTheNextOne() throws CommandExecutionException { diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java index 1472dff5388..005cbf8f46a 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java @@ -795,12 +795,31 @@ void resolveMinimumFrontendPackageAge_nothingConfigured_usesDefault() { } @Test - void resolveMinimumFrontendPackageAge_zeroConfigured_noArgument() { + void resolveMinimumFrontendPackageAge_zeroConfigured_noArgumentAndNoAge() { FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + Options options = new MockOptions(npmFolder) + .withMinimumFrontendPackageAgeDays(0); - assertFalse(resolveMinimumFrontendPackageAgeArgument( - new MockOptions(npmFolder).withMinimumFrontendPackageAgeDays(0), - tools).isPresent()); + // nothing blocks a version, so nothing has to be excluded either + assertMinimumFrontendPackageAge(options, tools, false, null); + + // an age npm is configured with is a value that blocks nothing too + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq(npmFolder), Mockito.eq("min-release-age"))) + .thenReturn(Optional.of("0")); + + assertMinimumFrontendPackageAge(options, tools, false, null); + } + + private void assertMinimumFrontendPackageAge(Options options, + FrontendTools tools, boolean applies, String argument) { + TaskRunNpmInstall.MinimumFrontendPackageAge minimumAge = TaskRunNpmInstall + .resolveMinimumFrontendPackageAge(options, tools, + List.of(TaskRunNpmInstall.getToolName(options)), + LoggerFactory.getLogger(TaskRunNpmInstallTest.class)); + + assertEquals(applies, minimumAge.applies()); + assertEquals(Optional.ofNullable(argument), minimumAge.argument()); } @Test @@ -889,21 +908,260 @@ void resolveMinimumFrontendPackageAge_bun_configurationIsNotRead() { Mockito.anyList(), Mockito.any(), Mockito.any(String[].class)); } + @Test + void resolveMinimumFrontendPackageAge_zeroConfiguredWithNpmrcValue_ageStillApplies() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + // npm keeps applying the 7 days of its own configuration, as Vaadin + // only leaves out the command line argument + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq(npmFolder), Mockito.eq("min-release-age"))) + .thenReturn(Optional.of("7")); + Options options = new MockOptions(npmFolder) + .withMinimumFrontendPackageAgeDays(0); + + TaskRunNpmInstall.MinimumFrontendPackageAge minimumAge = TaskRunNpmInstall + .resolveMinimumFrontendPackageAge(options, tools, + List.of("npm"), + LoggerFactory.getLogger(TaskRunNpmInstallTest.class)); + + assertTrue(minimumAge.applies()); + assertFalse(minimumAge.argument().isPresent()); + // so the packages Vaadin publishes are excluded from it + assertEquals(List.of("--min-release-age-exclude=@vaadin/*"), + TaskRunNpmInstall + .resolveMinimumFrontendPackageAgeExcludeArguments( + options, tools, List.of("npm"), + minimumAge.applies(), new MockLogger())); + } + + @Test + void resolveMinimumFrontendPackageAge_npmrcValueOfZero_noAgeApplies() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + // npm is configured not to block anything, so neither does Vaadin + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq(npmFolder), Mockito.eq("min-release-age"))) + .thenReturn(Optional.of("0")); + + TaskRunNpmInstall.MinimumFrontendPackageAge minimumAge = TaskRunNpmInstall + .resolveMinimumFrontendPackageAge(new MockOptions(npmFolder), + tools, List.of("npm"), + LoggerFactory.getLogger(TaskRunNpmInstallTest.class)); + + assertFalse(minimumAge.applies()); + assertFalse(minimumAge.argument().isPresent()); + } + + @Test + void minimumFrontendPackageAgeExclude_npm_excludesVaadinPackages() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + + assertEquals(List.of("--min-release-age-exclude=@vaadin/*"), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder), tools, logger)); + assertEquals("", logger.getLogs(), + "excluding the packages needs no warning"); + } + + @Test + void minimumFrontendPackageAgeExclude_pnpm_excludesVaadinPackagesAsAList() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + + // a single occurrence stays a string, which pnpm 11 reads as + // excluding every package + assertEquals( + List.of("--config.minimum-release-age-exclude=@vaadin/*", + "--config.minimum-release-age-exclude=@vaadin/*"), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnablePnpm(true), tools, + logger)); + } + + @Test + void minimumFrontendPackageAgeExclude_configuredPatterns_areKept() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + // a command line value replaces the configured list instead of + // adding to it, so the configured patterns are passed along + Mockito.when(tools.getConfiguredSettingValues(Mockito.anyList(), + Mockito.eq(npmFolder), Mockito.eq("min-release-age-exclude"))) + .thenReturn(List.of("@acme/*", "internal-tooling")); + + assertEquals( + List.of("--min-release-age-exclude=@acme/*", + "--min-release-age-exclude=internal-tooling", + "--min-release-age-exclude=@vaadin/*"), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder), tools, new MockLogger())); + } + + @Test + void minimumFrontendPackageAgeExclude_npmTooOld_warnsInsteadOfExcluding() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + // npm older than 11.17 only warns about an unknown configuration + Mockito.when(tools.npmSupportsMinReleaseAgeExclude(Mockito.anyList())) + .thenReturn(false); + MockLogger logger = new MockLogger(); + + assertEquals(List.of(), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder), tools, logger)); + assertWarnsAboutTheFirstDay(logger); + assertTrue( + logger.getLogs().contains("11.17.0") + && logger.getLogs().contains("24.19.0"), + "the warning should name the npm version to upgrade to and " + + "the Node.js version that ships it, was: " + + logger.getLogs()); + } + + @Test + void minimumFrontendPackageAgeExclude_pnpmTooOld_warnsInsteadOfExcluding() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + // pnpm 10.16 ignores the setting + Mockito.when( + tools.pnpmSupportsMinimumReleaseAgeExclude(Mockito.anyList())) + .thenReturn(false); + MockLogger logger = new MockLogger(); + + assertEquals(List.of(), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnablePnpm(true), tools, + logger)); + assertWarnsAboutTheFirstDay(logger); + assertTrue(logger.getLogs().contains("10.17.0"), + "the warning should name the pnpm version to upgrade to, was: " + + logger.getLogs()); + } + + @Test + void minimumFrontendPackageAgeExclude_bun_warnsInsteadOfExcluding() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + + // bun can only exclude packages through bunfig.toml, and only by + // their exact name + assertEquals(List.of(), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnableBun(true), tools, + logger)); + assertWarnsAboutTheFirstDay(logger); + assertTrue( + logger.getLogs().contains("minimumReleaseAgeExcludes") + && logger.getLogs().contains("@vaadin/*"), + "the warning should name the bunfig.toml setting to list the " + + "packages in, was: " + logger.getLogs()); + } + + @Test + void minimumFrontendPackageAgeExclude_bunfigListsThePackages_noWarning() + throws IOException { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + Files.writeString(new File(npmFolder, "bunfig.toml").toPath(), """ + [install] + minimumReleaseAgeExcludes = ["@vaadin/react-components"] + """); + + assertEquals(List.of(), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnableBun(true), tools, + logger)); + assertEquals("", logger.getLogs(), + "a project that lists the packages itself needs no warning"); + } + + @Test + void minimumFrontendPackageAgeExclude_bunfigListsOtherPackages_warns() + throws IOException { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + // the packages Vaadin publishes are not among the excluded ones, + // mentioning them elsewhere in the file is not enough + Files.writeString(new File(npmFolder, "bunfig.toml").toPath(), """ + [install] + # @vaadin/react-components + minimumReleaseAgeExcludes = ["react"] + """); + + assertEquals(List.of(), + resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnableBun(true), tools, + logger)); + assertWarnsAboutTheFirstDay(logger); + } + + @Test + void minimumFrontendPackageAgeExclude_noAgeApplies_noArgumentOrWarning() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + MockLogger logger = new MockLogger(); + + // nothing is blocked, so nothing has to be excluded either + assertEquals(List.of(), + TaskRunNpmInstall + .resolveMinimumFrontendPackageAgeExcludeArguments( + new MockOptions(npmFolder).withEnableBun(true), + tools, List.of("bun"), false, logger)); + assertEquals("", logger.getLogs()); + } + + @Test + void minimumFrontendPackageAgeExclude_npmrcValue_isStillExcludedFrom() { + FrontendTools tools = mockToolsWithoutMinimumReleaseAge(); + Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), + Mockito.eq(npmFolder), Mockito.eq("min-release-age"))) + .thenReturn(Optional.of("7")); + Options options = new MockOptions(npmFolder); + + // the age npm resolves itself is kept, but the packages Vaadin + // publishes are excluded from it + assertFalse(resolveMinimumFrontendPackageAgeArgument(options, tools) + .isPresent()); + assertEquals(List.of("--min-release-age-exclude=@vaadin/*"), + resolveMinimumFrontendPackageAgeExcludeArguments(options, tools, + new MockLogger())); + } + + private void assertWarnsAboutTheFirstDay(MockLogger logger) { + assertTrue(logger.getLogs().contains("first day"), + "the build should be warned that an installation may fail " + + "during the first day after a Vaadin release, was: " + + logger.getLogs()); + } + private FrontendTools mockToolsWithoutMinimumReleaseAge() { FrontendTools tools = Mockito.mock(FrontendTools.class); Mockito.when(tools.npmSupportsMinReleaseAge(Mockito.anyList())) .thenReturn(true); + Mockito.when(tools.npmSupportsMinReleaseAgeExclude(Mockito.anyList())) + .thenReturn(true); + Mockito.when( + tools.pnpmSupportsMinimumReleaseAgeExclude(Mockito.anyList())) + .thenReturn(true); Mockito.when(tools.getConfiguredSetting(Mockito.anyList(), Mockito.any(), Mockito.any(String[].class))) .thenReturn(Optional.empty()); + Mockito.when(tools.getConfiguredSettingValues(Mockito.anyList(), + Mockito.any(), Mockito.any(String[].class))) + .thenReturn(List.of()); return tools; } + private List resolveMinimumFrontendPackageAgeExcludeArguments( + Options options, FrontendTools tools, Logger logger) { + return TaskRunNpmInstall + .resolveMinimumFrontendPackageAgeExcludeArguments(options, + tools, List.of(TaskRunNpmInstall.getToolName(options)), + true, logger); + } + private Optional resolveMinimumFrontendPackageAgeArgument( Options options, FrontendTools tools) { - return TaskRunNpmInstall.resolveMinimumFrontendPackageAgeArgument( - options, tools, List.of("npm"), - LoggerFactory.getLogger(TaskRunNpmInstallTest.class)); + return TaskRunNpmInstall + .resolveMinimumFrontendPackageAge(options, tools, + List.of("npm"), + LoggerFactory.getLogger(TaskRunNpmInstallTest.class)) + .argument(); } @Test diff --git a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunPnpmInstallTest.java b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunPnpmInstallTest.java index 6bf3ce2d8c5..3642442d6d0 100644 --- a/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunPnpmInstallTest.java +++ b/flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunPnpmInstallTest.java @@ -442,6 +442,26 @@ void runPnpmInstall_ciBuild_usesFrozenLockfile() "pnpm install in CI build should not use --no-frozen-lockfile"); } + @Test + void runPnpmInstall_excludesVaadinPackagesFromTheMinimumAge() + throws ExecutionFailedException, IOException { + TaskRunNpmInstall task = createTask(); + getNodeUpdater().modified = true; + + task.execute(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + Mockito.verify(logger).info( + Mockito.eq("using '{}' for frontend package installation"), + captor.capture()); + assertTrue( + captor.getValue().contains( + "--config.minimum-release-age-exclude=@vaadin/*"), + "pnpm install should let the packages Vaadin publishes be " + + "installed regardless of the minimum frontend " + + "package age"); + } + @Override protected String getToolName() { return "pnpm"; diff --git a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java index 389573e7800..3e1170a68b9 100644 --- a/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java +++ b/flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java @@ -223,6 +223,13 @@ public class BuildDevBundleMojo extends AbstractMojo * {@code npm install} behaves the same way. Only when there is no such * value does the check default to {@code 1} day. The configuration of bun * cannot be read, so the default always applies for it. + *

+ * The packages Vaadin publishes itself ({@code @vaadin/*}) are exempt from + * the check, so that a project can be built right after a Vaadin release. + * Excluding them requires npm ≥ 11.17.0, which Node.js ≥ 24.19.0 + * ships with, or pnpm ≥ 10.17.0; bun cannot exclude packages on the + * command line, so with bun an installation may fail during the first day + * after a Vaadin release. */ @Parameter(property = "vaadin." + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS) diff --git a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java index e0909f65171..f0c7f3faf01 100644 --- a/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java +++ b/flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java @@ -170,6 +170,13 @@ public class BuildFrontendMojo extends FlowModeAbstractMojo * {@code npm install} behaves the same way. Only when there is no such * value does the check default to {@code 1} day. The configuration of bun * cannot be read, so the default always applies for it. + *

+ * The packages Vaadin publishes itself ({@code @vaadin/*}) are exempt from + * the check, so that a project can be built right after a Vaadin release. + * Excluding them requires npm ≥ 11.17.0, which Node.js ≥ 24.19.0 + * ships with, or pnpm ≥ 10.17.0; bun cannot exclude packages on the + * command line, so with bun an installation may fail during the first day + * after a Vaadin release. */ @Parameter(property = "vaadin." + InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS) diff --git a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java index bd91c9a1053..ce77accd40e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java @@ -416,6 +416,13 @@ public class InitParameters implements Serializable { * or {@code pnpm-workspace.yaml}) is used, defaulting to {@code 1} day if * there is none. The configuration of bun cannot be read, so the default * always applies for it. + *

+ * The packages Vaadin publishes itself ({@code @vaadin/*}) are exempt from + * the check, so that a project can be built right after a Vaadin release. + * Excluding them requires npm ≥ 11.17.0, which Node.js ≥ 24.19.0 + * ships with, or pnpm ≥ 10.17.0; bun cannot exclude packages on the + * command line, so with bun an installation may fail during the first day + * after a Vaadin release. * * @since 25.1.6 */