This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch 7.1.x in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 4af0ab3e1e268adc53f4c7e16938e4b8f2853e52 Merge: a9490b2f7a 5c21bd0d8c Author: James Fredley <[email protected]> AuthorDate: Thu Jun 11 17:49:17 2026 -0400 Merge origin/7.0.x into 7.1.x Assisted-by: opencode:gpt-5.5 oracle .asf.yaml | 12 ++ dependencies.gradle | 4 +- .../groovy/grails/plugins/VersionComparator.groovy | 225 +++++++++++++++------ .../grails/plugins/VersionComparatorSpec.groovy | 70 +++++++ .../grails/core/plugins/PluginUtilsSpec.groovy | 10 + 5 files changed, 260 insertions(+), 61 deletions(-) diff --cc grails-core/src/test/groovy/org/apache/grails/core/plugins/PluginUtilsSpec.groovy index 8b7fbca454,0000000000..6e5dde002a mode 100644,000000..100644 --- a/grails-core/src/test/groovy/org/apache/grails/core/plugins/PluginUtilsSpec.groovy +++ b/grails-core/src/test/groovy/org/apache/grails/core/plugins/PluginUtilsSpec.groovy @@@ -1,365 -1,0 +1,375 @@@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.core.plugins + +import spock.lang.Specification +import spock.lang.Unroll + +import org.grails.plugins.DefaultGrailsPlugin + +/** + * Test suite for PluginUtils utility methods + */ +class PluginUtilsSpec extends Specification { + + @Unroll + def "isPluginVersionCompatible checks that plugin with grailsVersion=#pluginGrailsVersion is compatible with grails #grailsVersion"() { + expect: + PluginUtils.isPluginVersionCompatible( + '1.0.0', // pluginVersion + pluginGrailsVersion, // pluginSupportedVersion + grailsVersion, // grailsVersion + 'test-plugin' // pluginDescription + ) == isCompatible + + where: + grailsVersion | pluginGrailsVersion || isCompatible + '1.0' | '3.3.1 > *' || false + '2.5' | '3.0.1' || false + '3.0.0' | '3.3.10 > *' || false + '3.3.10' | '4.0.0 > *' || false + '4.0.1' | '3.0.0.BUILD-SNAPSHOT > *' || true + '4.0.1' | '4.0.1' || true + '4.0.1' | '3.0.1' || false + '4.0.1' | '3.3.1 > *' || true + '4.0.1' | '3.3.10 > *' || true ++ '7.0.0-M2' | '7.0.0-M1 > *' || true ++ '7.0.0-M1' | '7.0.0-M2 > *' || false ++ '7.0.0-RC1' | '7.0.0-M1 > *' || true ++ '7.0.0-M1' | '7.0.0-RC1 > *' || false ++ '7.0.0' | '7.0.0-RC1 > *' || true ++ '7.0.0-RC1' | '7.0.0 > *' || false ++ '7.0.0-SNAPSHOT' | '7.0.0-SNAPSHOT > *' || true ++ '7.0.5-M1' | '7.0.3 > *' || true ++ '7.0.0-M1' | '7.0.0-M1' || true ++ '7.0.0-M2' | '7.0.0-M1' || false + } + + def "isPluginVersionCompatible handles null grailsVersion"() { + when: + def compatible = PluginUtils.isPluginVersionCompatible( + '1.0.0', + '3.3.10 > *', + null, // grailsVersion is null + 'test-plugin' + ) + + then: + compatible + } + + def "isPluginVersionCompatible handles null pluginSupportedVersion"() { + when: + def compatible = PluginUtils.isPluginVersionCompatible( + '1.0.0', + null, // pluginSupportedVersion is null + '4.0.1', + 'test-plugin' + ) + + then: + compatible + } + + def "isPluginVersionCompatible handles @ in pluginSupportedVersion"() { + when: + def compatible = PluginUtils.isPluginVersionCompatible( + '1.0.0', + '4.0.0@', // contains @ + '3.0.0', + 'test-plugin' + ) + + then: + compatible + } + + // Tests for extractPluginMetadata method + + def "extractPluginMetadata extracts loadAfter, loadBefore, and dependsOn"() { + when: + def metadata = PluginUtils.extractPluginMetadata(PluginWithAllOrderingGrailsPlugin) + + then: + metadata + metadata.name == 'pluginWithAllOrdering' + metadata.loadAfterNames == ['alpha', 'beta'] as String[] + metadata.loadBeforeNames == ['gamma'] as String[] + metadata.dependsOnNames == ['delta'] as String[] + } + + def "extractPluginMetadata returns empty arrays for plugin with no ordering declarations"() { + when: + def metadata = PluginUtils.extractPluginMetadata(UtilsTestSimpleGrailsPlugin) + + then: + metadata + metadata.name == 'utilsTestSimple' + metadata.loadAfterNames.length == 0 + metadata.loadBeforeNames.length == 0 + metadata.dependsOnNames.length == 0 + } + + def "extractPluginMetadata returns null for class not ending in GrailsPlugin"() { + expect: + PluginUtils.extractPluginMetadata(String) == null + } + + def "extractPluginMetadata returns null for null input"() { + expect: + PluginUtils.extractPluginMetadata(null) == null + } + + def "extractPluginMetadata handles plugin that throws on instantiation"() { + when: + PluginUtils.extractPluginMetadata(FailingConstructorUtilsGrailsPlugin) + + then: 'throws IllegalStateException when plugin cannot be instantiated' + thrown(IllegalStateException) + } + + def "extractPluginMetadata extracts multiple dependsOn names"() { + when: + def metadata = PluginUtils.extractPluginMetadata(PluginWithMultipleDepsGrailsPlugin) + + then: + metadata + metadata.name == 'pluginWithMultipleDeps' + metadata.dependsOnNames as Set == ['core', 'i18n'] as Set + } + + def "readPluginConfiguration returns null when no plugin.yml or plugin.groovy exists"() { + when: + def resource = PluginUtils.readPluginConfiguration(UtilsTestSimpleGrailsPlugin) + + then: 'no config file exists for this test fixture class' + !resource + } + + def "getConfigurationResource returns resource that does not exist for non-existent path"() { + when: 'a resource is requested for a non-existent path' + def resource = PluginUtils.getConfigurationResource( + UtilsTestSimpleGrailsPlugin, + '/nonexistent.yml' + ) + + then: 'resource is returned' + resource + + and: 'resource does not exist' + !resource.exists() + } + + // Tests for additional public methods + + @Unroll + def "normalizePluginName converts name #input"(String input, String output) { + expect: + PluginUtils.normalizePluginName(input) == output + + where: + input || output + 'my-plugin-name' || 'myPluginName' + 'simple' || 'simple' + 'alreadyCamel' || 'alreadyCamel' + } + + @Unroll + def "getLogicalPluginNameFromClassName extracts plugin name from class name #input"(String input, String output) { + expect: + PluginUtils.getLogicalPluginNameFromClassName(input) == output + + where: + input || output + 'MyPluginGrailsPlugin' || 'myPlugin' + 'CoreGrailsPlugin' || 'core' + 'SimpleGrailsPlugin' || 'simple' + } + + @Unroll + def "isGrailsPluginClassNamedCorrectly validates plugin naming correctly for #clazz"(Class<?> clazz, boolean isValidPluginClassName) { + expect: + PluginUtils.isGrailsPluginClassNamedCorrectly(clazz) == isValidPluginClassName + + where: + clazz || isValidPluginClassName + PluginWithAllOrderingGrailsPlugin || true + UtilsTestSimpleGrailsPlugin || true + String || false + null || false + + } + + @Unroll + def "isGrailsPluginLoadable returns #isLoadable for #clazz"(Class<?> clazz, boolean isLoadable) { + expect: + PluginUtils.isGrailsPluginLoadable(clazz) == isLoadable + + where: + clazz || isLoadable + PluginWithAllOrderingGrailsPlugin || true + DefaultGrailsPlugin || false // DefaultGrailsPlugin is not loadable since it's not a plugin class + String || true // Method returns true for String since it's not abstract and not DefaultGrailsPlugin.class + null || false + } + + def "supportsValueInIncludeExcludeMap returns true for empty map"() { + expect: + PluginUtils.supportsValueInIncludeExcludeMap([:], 'anything') + } + + def "supportsValueInIncludeExcludeMap checks includes/excludes"() { + when: + def mapWithIncludes = ['includes': ['dev', 'test'] as Set] + + then: + with(PluginUtils) { + supportsValueInIncludeExcludeMap(mapWithIncludes, 'dev') + !supportsValueInIncludeExcludeMap(mapWithIncludes, 'prod') + } + } + + def "supportsValueInIncludeExcludeMap checks excludes"() { + when: + def mapWithExcludes = ['excludes': ['prod'] as Set] + + then: + with(PluginUtils) { + supportsValueInIncludeExcludeMap(mapWithExcludes, 'dev') + !supportsValueInIncludeExcludeMap(mapWithExcludes, 'prod') + } + } + + def "scanPluginDescriptors returns empty list when no descriptors found"() { + when: + def classNames = PluginUtils.scanPluginDescriptors( + new URLClassLoader([] as URL[], (ClassLoader) null) + ) + + then: + classNames.empty + } + + def "scanPluginDescriptorResources returns empty list when no descriptors found"() { + when: + def descriptors = PluginUtils.scanPluginDescriptorResources( + new URLClassLoader([] as URL[], (ClassLoader) null) + ) + + then: + descriptors.empty + } + + @Unroll + def "getLogicalPluginName derives plugin name #expectedName from plugin class #clazz"(Class<?> clazz, String expectedName) { + expect: + PluginUtils.getLogicalPluginName(clazz) == expectedName + + where: + clazz || expectedName + PluginWithAllOrderingGrailsPlugin || 'pluginWithAllOrdering' + UtilsTestSimpleGrailsPlugin || 'utilsTestSimple' + PluginWithMultipleDepsGrailsPlugin || 'pluginWithMultipleDeps' + } + + def "evaluateIncludeExcludeProperty parses include/exclude map structure"() { + given: + def plugin = new GroovyObject() { + def environments = [ + 'includes': ['dev', 'test'], + 'excludes': ['prod'] + ] + } + + when: + def result = PluginUtils.evaluateIncludeExcludeProperty( + plugin, + 'environments', + { obj -> obj } // identity converter + ) + + then: + result.containsKey('includes') + result.containsKey('excludes') + result['includes'] == ['dev', 'test'] as Set + result['excludes'] == ['prod'] as Set + } + + def "evaluateIncludeExcludeProperty handles string values"() { + given: + def plugin = new GroovyObject() { + def scope = 'development' + } + + when: + def result = PluginUtils.evaluateIncludeExcludeProperty( + plugin, + 'scope', + { obj -> obj } // identity converter + ) + + then: + result.containsKey('includes') + result['includes'] == ['development'] as Set + } + + def "evaluateIncludeExcludeProperty handles list values"() { + given: + def plugin = new GroovyObject() { + def scopes = ['dev', 'test'] + } + + when: + def result = PluginUtils.evaluateIncludeExcludeProperty( + plugin, + 'scopes', + { obj -> obj } // identity converter + ) + + then: + result.containsKey('includes') + result['includes'] == ['dev', 'test'] as Set + } +} + +// Test fixture plugin classes for GrailsPluginUtilsSpec + +class UtilsTestSimpleGrailsPlugin { + def version = '1.0' +} + +class PluginWithAllOrderingGrailsPlugin { + def version = '1.0' + def loadAfter = ['alpha', 'beta'] + def loadBefore = ['gamma'] + def dependsOn = [delta: '1.0'] +} + +class FailingConstructorUtilsGrailsPlugin { + def version = '1.0' + + FailingConstructorUtilsGrailsPlugin() { + throw new RuntimeException('Intentional failure for testing') + } +} + +class PluginWithMultipleDepsGrailsPlugin { + def version = '1.0' + def dependsOn = [core: '1.0', i18n: '1.0'] +}
