diff --git a/grails-bootstrap/src/cli/groovy/grails/build/logging/GrailsConsole.java b/grails-bootstrap/src/cli/groovy/grails/build/logging/GrailsConsole.java index b8976dce8bb..63c818f5b94 100644 --- a/grails-bootstrap/src/cli/groovy/grails/build/logging/GrailsConsole.java +++ b/grails-bootstrap/src/cli/groovy/grails/build/logging/GrailsConsole.java @@ -43,6 +43,8 @@ import org.jline.reader.impl.history.DefaultHistory; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import grails.util.Environment; import org.grails.build.logging.GrailsConsoleErrorPrintStream; @@ -62,6 +64,8 @@ */ public class GrailsConsole implements ConsoleLogger { + private static final Logger LOG = LoggerFactory.getLogger(GrailsConsole.class); + private static GrailsConsole instance; public static final String ENABLE_TERMINAL = "grails.console.enable.terminal"; @@ -442,7 +446,11 @@ public static GrailsConsole createInstance() throws IOException { Class klass = (Class) Class.forName(className); return klass.getDeclaredConstructor().newInstance(); } catch (Exception e) { - e.printStackTrace(); + if (LOG.isErrorEnabled()) { + LOG.error("Unable to create configured Grails console " + className, e); + } else { + e.printStackTrace(); + } } } return new GrailsConsole(); diff --git a/grails-bootstrap/src/test-cli/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy b/grails-bootstrap/src/test-cli/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy new file mode 100644 index 00000000000..e26b9027ea2 --- /dev/null +++ b/grails-bootstrap/src/test-cli/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy @@ -0,0 +1,122 @@ +/* + * 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 grails.build.logging + +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeUnit + +import org.slf4j.LoggerFactory +import spock.lang.Specification +import spock.lang.TempDir + +class GrailsConsoleLoggingSpec extends Specification { + + @TempDir + File tempDir + + void "invalid configured console failure is visible with #loggingDescription"() { + given: + String output = runFixture(apiOnly) + + expect: + apiOnly ? output.contains('ClassNotFoundException: invalid.console.ClassName') : + output.count('Unable to create configured Grails console invalid.console.ClassName') == 1 + output.readLines().last() == 'verified' + + where: + apiOnly | loggingDescription + true | 'an API-only SLF4J classpath' + false | 'an ERROR-capable SLF4J provider' + } + + private String runFixture(boolean apiOnly) { + File argumentsFile = new File(tempDir, "grails-console-${apiOnly}.args") + File outputFile = new File(tempDir, "grails-console-${apiOnly}.output") + argumentsFile.text = (jacocoAgentArguments() + [ + '-cp', + testRuntimeClasspath(apiOnly), + GrailsConsoleLoggingSpec.name, + apiOnly as String + ]).join('\n') + Process process = new ProcessBuilder( + new File(System.getProperty('java.home'), 'bin/java').absolutePath, + "@${argumentsFile.absolutePath}" + ).redirectErrorStream(true).redirectOutput(outputFile).start() + process.outputStream.close() + try { + awaitProcess(process, 'GrailsConsole logging fixture') + String output = outputFile.getText('UTF-8').trim() + assert process.exitValue() == 0 : output + output + } finally { + if (process.isAlive()) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + } + } + + private static void awaitProcess(Process process, String fixtureName) { + if (process.waitFor(30, TimeUnit.SECONDS)) { + return + } + process.destroyForcibly() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + throw new AssertionError("${fixtureName} did not terminate after timing out") + } + throw new AssertionError("${fixtureName} timed out after 30 seconds") + } + + private static String testRuntimeClasspath(boolean apiOnly) { + String[] entries = System.getProperty('java.class.path').split(File.pathSeparator) + if (!apiOnly) { + return entries.join(File.pathSeparator) + } + entries.findAll { !it.contains('slf4j-simple') && !it.contains('logback-classic') }.join(File.pathSeparator) + } + + private static List jacocoAgentArguments() { + ManagementFactory.runtimeMXBean.inputArguments.findAll { + it.startsWith('-javaagent:') && it.contains('jacoco') + } + } + + static void main(String[] args) { + boolean apiOnly = Boolean.parseBoolean(args[0]) + System.setProperty('grails.console.class', 'invalid.console.ClassName') + System.setProperty(GrailsConsole.ENABLE_INTERACTIVE, 'false') + System.setProperty(GrailsConsole.ENABLE_TERMINAL, 'false') + GrailsConsole console + Throwable failure + try { + assert LoggerFactory.getLogger(GrailsConsole).errorEnabled == !apiOnly + console = GrailsConsole.createInstance() + assert console.class == GrailsConsole + } catch (Throwable throwable) { + failure = throwable + } finally { + console?.restoreOriginalSystemOutAndErr() + } + if (failure != null) { + failure.printStackTrace() + System.exit(1) + } + println 'verified' + } +} diff --git a/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java b/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java index 39f2a032d15..f33a2de8a6d 100644 --- a/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java +++ b/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java @@ -21,8 +21,8 @@ import groovy.lang.GroovySystem; import groovy.lang.MetaClassRegistry; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; import org.springframework.core.io.Resource; @@ -43,7 +43,7 @@ */ public class ProfilingGrailsPluginManager extends DefaultGrailsPluginManager { - private static final Log LOG = LogFactory.getLog(DefaultGrailsPluginManager.class); + private static final Logger LOG = LoggerFactory.getLogger(DefaultGrailsPluginManager.class); public ProfilingGrailsPluginManager(GrailsApplication application, PluginDiscovery pluginDiscovery) { super(application, pluginDiscovery); @@ -110,15 +110,15 @@ private static PluginDiscovery resolveAndResetDiscovery(GrailsApplication applic @Override public void loadPlugins() throws PluginException { long time = System.currentTimeMillis(); - System.out.println("Loading plugins started"); + LOG.info("Loading plugins started"); super.loadPlugins(); - System.out.println("Loading plugins took " + (System.currentTimeMillis() - time)); + LOG.info("Loading plugins took {}", System.currentTimeMillis() - time); } @Override public void doDynamicMethods() { long time = System.currentTimeMillis(); - System.out.println("doWithDynamicMethods started"); + LOG.info("doWithDynamicMethods started"); checkInitialised(); // remove common meta classes just to be sure MetaClassRegistry registry = GroovySystem.getMetaClassRegistry(); @@ -129,57 +129,57 @@ public void doDynamicMethods() { if (plugin.supportsCurrentScopeAndEnvironment()) { try { long pluginTime = System.currentTimeMillis(); - System.out.println("doWithDynamicMethods for plugin [" + plugin.getName() + "] started"); + LOG.info("doWithDynamicMethods for plugin [{}] started", plugin.getName()); plugin.doWithDynamicMethods(applicationContext); - System.out.println("doWithDynamicMethods for plugin [" + plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime)); + LOG.info("doWithDynamicMethods for plugin [{}] took {}", plugin.getName(), System.currentTimeMillis() - pluginTime); } catch (Throwable t) { throw new GrailsConfigurationException("Error configuring dynamic methods for plugin " + plugin + ": " + t.getMessage(), t); } } } - System.out.println("doWithDynamicMethods took " + (System.currentTimeMillis() - time)); + LOG.info("doWithDynamicMethods took {}", System.currentTimeMillis() - time); } @Override public void doRuntimeConfiguration(RuntimeSpringConfiguration springConfig) { long time = System.currentTimeMillis(); - System.out.println("doWithSpring started"); + LOG.info("doWithSpring started"); checkInitialised(); for (GrailsPlugin plugin : getAllPlugins()) { if (plugin.supportsCurrentScopeAndEnvironment()) { long pluginTime = System.currentTimeMillis(); - System.out.println("doWithSpring for plugin [" + plugin.getName() + "] started"); + LOG.info("doWithSpring for plugin [{}] started", plugin.getName()); plugin.doWithRuntimeConfiguration(springConfig); - System.out.println("doWithSpring for plugin [" + plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime)); + LOG.info("doWithSpring for plugin [{}] took {}", plugin.getName(), System.currentTimeMillis() - pluginTime); } } - System.out.println("doWithSpring took " + (System.currentTimeMillis() - time)); + LOG.info("doWithSpring took {}", System.currentTimeMillis() - time); } @Override public void doPostProcessing(ApplicationContext ctx) { long time = System.currentTimeMillis(); - System.out.println("doWithApplicationContext started"); + LOG.info("doWithApplicationContext started"); checkInitialised(); for (GrailsPlugin plugin : getAllPlugins()) { if (plugin.supportsCurrentScopeAndEnvironment()) { long pluginTime = System.currentTimeMillis(); - System.out.println("doWithApplicationContext for plugin [" + plugin.getName() + "] started"); + LOG.info("doWithApplicationContext for plugin [{}] started", plugin.getName()); plugin.doWithApplicationContext(ctx); - System.out.println("doWithApplicationContext for plugin [" + plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime)); + LOG.info("doWithApplicationContext for plugin [{}] took {}", plugin.getName(), System.currentTimeMillis() - pluginTime); } } - System.out.println("doWithApplicationContext took " + (System.currentTimeMillis() - time)); + LOG.info("doWithApplicationContext took {}", System.currentTimeMillis() - time); } @Override public void doArtefactConfiguration() { long time = System.currentTimeMillis(); - System.out.println("doArtefactConfiguration started"); + LOG.info("doArtefactConfiguration started"); super.doArtefactConfiguration(); - System.out.println("doArtefactConfiguration took " + (System.currentTimeMillis() - time)); + LOG.info("doArtefactConfiguration took {}", System.currentTimeMillis() - time); } } diff --git a/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy new file mode 100644 index 00000000000..2021eae057f --- /dev/null +++ b/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy @@ -0,0 +1,161 @@ +/* + * 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.grails.plugins + +import ch.qos.logback.classic.Level +import grails.core.DefaultGrailsApplication +import grails.plugins.DefaultGrailsPluginManager +import org.apache.grails.core.plugins.DefaultPluginDiscovery +import org.apache.grails.core.plugins.PluginDiscovery +import org.apache.grails.core.testing.support.LogCapture +import org.grails.spring.DefaultRuntimeSpringConfiguration +import org.springframework.context.support.GenericApplicationContext +import org.springframework.core.env.StandardEnvironment +import spock.lang.Specification + +class ProfilingGrailsPluginManagerSpec extends Specification { + + void "plugin loading emits INFO profiling messages instead of standard output"() { + given: + def application = new DefaultGrailsApplication() + application.mainContext = new GenericApplicationContext() + def discovery = new DefaultPluginDiscovery(new Class[0]) + discovery.loadPluginsFromClasspath = false + discovery.init(new StandardEnvironment()) + def manager = new ProfilingGrailsPluginManager(application, discovery) + def originalOut = System.out + def capturedOut = new ByteArrayOutputStream() + System.setOut(new PrintStream(capturedOut, true)) + def logCapture = new LogCapture(DefaultGrailsPluginManager, Level.INFO) + + when: + manager.loadPlugins() + + then: + !capturedOut.toString().contains('Loading plugins') + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.INFO && + it.formattedMessage == 'Loading plugins started' + } + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.INFO && + it.formattedMessage ==~ /Loading plugins took \d+/ + } + + cleanup: + System.setOut(originalOut) + logCapture.close() + } + + void "configuration phases emit INFO profiling messages for each plugin"() { + given: + def gcl = new GroovyClassLoader() + def probeClass = gcl.parseClass(''' +class ProfilingProbeGrailsPlugin { + def version = "1.0.0" +} +''') + def application = new DefaultGrailsApplication() + def mainContext = new GenericApplicationContext() + application.mainContext = mainContext + def discovery = new DefaultPluginDiscovery(new Class[]{probeClass}) + discovery.loadPluginsFromClasspath = false + discovery.init(new StandardEnvironment()) + def manager = new ProfilingGrailsPluginManager(application, discovery) + def originalOut = System.out + def capturedOut = new ByteArrayOutputStream() + System.setOut(new PrintStream(capturedOut, true)) + def logCapture = new LogCapture(DefaultGrailsPluginManager, Level.INFO) + + when: + manager.loadPlugins() + manager.applicationContext = mainContext + manager.doArtefactConfiguration() + manager.doRuntimeConfiguration(new DefaultRuntimeSpringConfiguration()) + manager.doDynamicMethods() + manager.doPostProcessing(mainContext) + + then: + !capturedOut.toString().contains('doWith') + !capturedOut.toString().contains('doArtefactConfiguration') + [ + 'doArtefactConfiguration started', + 'doWithSpring started', + 'doWithSpring for plugin [profilingProbe] started', + 'doWithDynamicMethods started', + 'doWithDynamicMethods for plugin [profilingProbe] started', + 'doWithApplicationContext started', + 'doWithApplicationContext for plugin [profilingProbe] started' + ].every { message -> + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.INFO && + it.formattedMessage == message + } + } + ['doArtefactConfiguration', 'doWithSpring', 'doWithDynamicMethods', 'doWithApplicationContext'].every { phase -> + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.INFO && + it.formattedMessage ==~ /${phase} took \d+/ + } && + (phase == 'doArtefactConfiguration' || + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.INFO && + it.formattedMessage ==~ /${phase} for plugin \[profilingProbe\] took \d+/ + }) + } + + cleanup: + System.setOut(originalOut) + logCapture.close() + } + + void "deprecated constructors log their warning in the historical category"() { + given: + def application = new DefaultGrailsApplication() + def applicationContext = new GenericApplicationContext() + def discovery = Mock(PluginDiscovery) + applicationContext.beanFactory.registerSingleton(PluginDiscovery.BEAN_NAME, discovery) + applicationContext.refresh() + application.mainContext = applicationContext + def logCapture = new LogCapture(DefaultGrailsPluginManager, Level.WARN) + + when: + def manager = new ProfilingGrailsPluginManager(new Class[0], application) + + then: + manager + 1 * discovery.reset() + 1 * discovery.setPluginClasses(_) + 1 * discovery.init(_) + logCapture.events.any { + it.loggerName == DefaultGrailsPluginManager.name && + it.level == Level.WARN && + it.formattedMessage.startsWith('Using deprecated DefaultGrailsPluginManager constructor.') + } + + cleanup: + logCapture.close() + applicationContext.close() + } +} diff --git a/grails-data-hibernate5/grails-plugin/build.gradle b/grails-data-hibernate5/grails-plugin/build.gradle index 2aa04bfe4c9..4de116dd266 100644 --- a/grails-data-hibernate5/grails-plugin/build.gradle +++ b/grails-data-hibernate5/grails-plugin/build.gradle @@ -98,6 +98,7 @@ dependencies { } testRuntimeOnly 'org.springframework:spring-aop' testRuntimeOnly 'org.springframework:spring-expression' + testRuntimeOnly 'org.slf4j:slf4j-simple' testRuntimeOnly 'org.yaml:snakeyaml' } @@ -105,4 +106,4 @@ apply { from rootProject.layout.projectDirectory.file('gradle/hibernate5-test-config.gradle') from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') -} \ No newline at end of file +} diff --git a/grails-data-hibernate5/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate5/cli/SchemaExportCommand.groovy b/grails-data-hibernate5/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate5/cli/SchemaExportCommand.groovy index d3499fcd6bf..2b2ba51cbc5 100644 --- a/grails-data-hibernate5/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate5/cli/SchemaExportCommand.groovy +++ b/grails-data-hibernate5/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate5/cli/SchemaExportCommand.groovy @@ -23,6 +23,9 @@ import groovy.transform.CompileStatic import org.hibernate.engine.spi.SessionFactoryImplementor import org.hibernate.tool.hbm2ddl.SchemaExport as HibernateSchemaExport import org.hibernate.tool.schema.TargetType +import org.hibernate.tool.schema.spi.SchemaManagementException +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.apache.grails.core.cli.ApplicationCommand import org.apache.grails.core.cli.ExecutionContext @@ -40,6 +43,8 @@ import org.grails.orm.hibernate.HibernateDatastore @CompileStatic class SchemaExportCommand implements ApplicationCommand { + private static final Logger LOG = LoggerFactory.getLogger(SchemaExportCommand) + final String description = 'Creates a DDL file of the database schema' Boolean skipBootstrap = true @@ -90,14 +95,28 @@ class SchemaExportCommand implements ApplicationCommand { targetTypes = EnumSet.of(TargetType.SCRIPT) } - schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry) + try { + schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry) + } catch (SchemaManagementException e) { + reportFailure(e) + return false + } if (schemaExport.exceptions) { def e = (Exception) schemaExport.exceptions[0] - e.printStackTrace() + reportFailure(e) return false } return true } + private static void reportFailure(Exception exception) { + if (LOG.errorEnabled) { + LOG.error('Unable to export database schema', exception) + } + else { + exception.printStackTrace(System.err) + } + } + } diff --git a/grails-data-hibernate5/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy b/grails-data-hibernate5/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy new file mode 100644 index 00000000000..f454651ace2 --- /dev/null +++ b/grails-data-hibernate5/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy @@ -0,0 +1,140 @@ +/* + * 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 grails.plugin.hibernate.commands + +import java.util.concurrent.TimeUnit + +import grails.gorm.annotation.Entity +import org.apache.grails.core.cli.ExecutionContext +import org.apache.grails.data.hibernate5.cli.SchemaExportCommand +import org.grails.build.parsing.CommandLine +import org.grails.orm.hibernate.HibernateDatastore +import org.slf4j.LoggerFactory +import org.springframework.context.ConfigurableApplicationContext +import spock.lang.Specification +import spock.lang.TempDir + +class SchemaExportCommandSpec extends Specification { + + @TempDir + File tempDir + + void "schema export failure is visible with #loggingDescription"() { + given: + String output = runFixture(apiOnly) + + expect: + output.contains('result=false') + if (apiOnly) { + assert output.contains('SchemaManagementException') + assert !output.contains('Unable to export database schema') + } + else { + assert output.count('Unable to export database schema') == 1 + assert output.contains('ERROR') + assert output.contains('SchemaManagementException') + } + output.readLines().last() == 'verified' + + where: + apiOnly | loggingDescription + true | 'an API-only SLF4J classpath' + false | 'an ERROR-capable SLF4J provider' + } + + private String runFixture(boolean apiOnly) { + File argumentsFile = new File(tempDir, "schema-export-${apiOnly}.args") + File outputFile = new File(tempDir, "schema-export-${apiOnly}.output") + argumentsFile.text = """\ + -cp + ${testRuntimeClasspath(apiOnly)} + ${SchemaExportCommandSpec.name} + ${apiOnly} + """.stripIndent().trim() + Process process = new ProcessBuilder( + new File(System.getProperty('java.home'), 'bin/java').absolutePath, + "@${argumentsFile.absolutePath}" + ).redirectErrorStream(true).redirectOutput(outputFile).start() + process.outputStream.close() + try { + awaitProcess(process, 'SchemaExportCommand logging fixture') + String output = outputFile.getText('UTF-8').trim() + assert process.exitValue() == 0 : output + output + } finally { + if (process.isAlive()) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + } + } + + private static void awaitProcess(Process process, String fixtureName) { + if (process.waitFor(60, TimeUnit.SECONDS)) { + return + } + process.destroyForcibly() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + throw new AssertionError("${fixtureName} did not terminate after timing out") + } + throw new AssertionError("${fixtureName} timed out after 60 seconds") + } + + private static String testRuntimeClasspath(boolean apiOnly) { + String[] entries = System.getProperty('java.class.path').split(File.pathSeparator) + if (!apiOnly) { + return entries.join(File.pathSeparator) + } + entries.findAll { !it.contains('slf4j-simple') && !it.contains('logback-classic') }.join(File.pathSeparator) + } + + static void main(String[] args) { + boolean apiOnly = Boolean.parseBoolean(args[0]) + def hibernateDatastore = new HibernateDatastore(Hibernate5SchemaExportLoggingEntity) + def applicationContext = [ + getBean: { String name, Class type -> + assert name == 'hibernateDatastore' + assert type == HibernateDatastore + hibernateDatastore + } + ] as ConfigurableApplicationContext + def command = new SchemaExportCommand(applicationContext: applicationContext) + def targetDirectory = File.createTempDir('hibernate5-schema-export', '') + def commandLine = [ + getRemainingArgs: { [targetDirectory.absolutePath] }, + getUndeclaredOptions: { [:] } + ] as CommandLine + try { + assert LoggerFactory.getLogger(SchemaExportCommand).errorEnabled == !apiOnly + boolean result = command.handle(new ExecutionContext(commandLine)) + println "result=${result}" + assert !result + } finally { + hibernateDatastore?.close() + targetDirectory?.deleteDir() + } + println 'verified' + } +} + +@Entity +class Hibernate5SchemaExportLoggingEntity { + Long id + String name +} diff --git a/grails-data-hibernate7/grails-plugin/build.gradle b/grails-data-hibernate7/grails-plugin/build.gradle index 01e9a4df2e4..8f85187dab5 100644 --- a/grails-data-hibernate7/grails-plugin/build.gradle +++ b/grails-data-hibernate7/grails-plugin/build.gradle @@ -96,6 +96,7 @@ dependencies { testRuntimeOnly 'org.hibernate.orm:hibernate-jcache' testRuntimeOnly 'org.springframework:spring-aop' testRuntimeOnly 'org.springframework:spring-expression' + testRuntimeOnly 'org.slf4j:slf4j-simple' testRuntimeOnly 'org.yaml:snakeyaml' } diff --git a/grails-data-hibernate7/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate7/cli/SchemaExportCommand.groovy b/grails-data-hibernate7/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate7/cli/SchemaExportCommand.groovy index 69eacd0677a..96c5745ca52 100644 --- a/grails-data-hibernate7/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate7/cli/SchemaExportCommand.groovy +++ b/grails-data-hibernate7/grails-plugin/src/cli/groovy/org/apache/grails/data/hibernate7/cli/SchemaExportCommand.groovy @@ -23,6 +23,9 @@ import groovy.transform.CompileStatic import org.hibernate.engine.spi.SessionFactoryImplementor import org.hibernate.tool.hbm2ddl.SchemaExport as HibernateSchemaExport import org.hibernate.tool.schema.TargetType +import org.hibernate.tool.schema.spi.SchemaManagementException +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.apache.grails.core.cli.ApplicationCommand import org.apache.grails.core.cli.ExecutionContext @@ -39,6 +42,8 @@ import org.grails.orm.hibernate.HibernateDatastore @CompileStatic class SchemaExportCommand implements ApplicationCommand { + private static final Logger LOG = LoggerFactory.getLogger(SchemaExportCommand) + final String description = 'Creates a DDL file of the database schema' Boolean skipBootstrap = true @@ -89,14 +94,28 @@ class SchemaExportCommand implements ApplicationCommand { targetTypes = EnumSet.of(TargetType.SCRIPT) } - schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry) + try { + schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry) + } catch (SchemaManagementException e) { + reportFailure(e) + return false + } if (schemaExport.exceptions) { def e = (Exception) schemaExport.exceptions[0] - e.printStackTrace() + reportFailure(e) return false } return true } + private static void reportFailure(Exception exception) { + if (LOG.errorEnabled) { + LOG.error('Unable to export database schema', exception) + } + else { + exception.printStackTrace(System.err) + } + } + } diff --git a/grails-data-hibernate7/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy b/grails-data-hibernate7/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy new file mode 100644 index 00000000000..6300d18eb61 --- /dev/null +++ b/grails-data-hibernate7/grails-plugin/src/test-cli/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy @@ -0,0 +1,140 @@ +/* + * 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 grails.plugin.hibernate.commands + +import java.util.concurrent.TimeUnit + +import grails.gorm.annotation.Entity +import org.apache.grails.core.cli.ExecutionContext +import org.apache.grails.data.hibernate7.cli.SchemaExportCommand +import org.grails.build.parsing.CommandLine +import org.grails.orm.hibernate.HibernateDatastore +import org.slf4j.LoggerFactory +import org.springframework.context.ConfigurableApplicationContext +import spock.lang.Specification +import spock.lang.TempDir + +class SchemaExportCommandSpec extends Specification { + + @TempDir + File tempDir + + void "schema export failure is visible with #loggingDescription"() { + given: + String output = runFixture(apiOnly) + + expect: + output.contains('result=false') + if (apiOnly) { + assert output.contains('SchemaManagementException') + assert !output.contains('Unable to export database schema') + } + else { + assert output.count('Unable to export database schema') == 1 + assert output.contains('ERROR') + assert output.contains('SchemaManagementException') + } + output.readLines().last() == 'verified' + + where: + apiOnly | loggingDescription + true | 'an API-only SLF4J classpath' + false | 'an ERROR-capable SLF4J provider' + } + + private String runFixture(boolean apiOnly) { + File argumentsFile = new File(tempDir, "schema-export-${apiOnly}.args") + File outputFile = new File(tempDir, "schema-export-${apiOnly}.output") + argumentsFile.text = """\ + -cp + ${testRuntimeClasspath(apiOnly)} + ${SchemaExportCommandSpec.name} + ${apiOnly} + """.stripIndent().trim() + Process process = new ProcessBuilder( + new File(System.getProperty('java.home'), 'bin/java').absolutePath, + "@${argumentsFile.absolutePath}" + ).redirectErrorStream(true).redirectOutput(outputFile).start() + process.outputStream.close() + try { + awaitProcess(process, 'SchemaExportCommand logging fixture') + String output = outputFile.getText('UTF-8').trim() + assert process.exitValue() == 0 : output + output + } finally { + if (process.isAlive()) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + } + } + + private static void awaitProcess(Process process, String fixtureName) { + if (process.waitFor(60, TimeUnit.SECONDS)) { + return + } + process.destroyForcibly() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + throw new AssertionError("${fixtureName} did not terminate after timing out") + } + throw new AssertionError("${fixtureName} timed out after 60 seconds") + } + + private static String testRuntimeClasspath(boolean apiOnly) { + String[] entries = System.getProperty('java.class.path').split(File.pathSeparator) + if (!apiOnly) { + return entries.join(File.pathSeparator) + } + entries.findAll { !it.contains('slf4j-simple') && !it.contains('logback-classic') }.join(File.pathSeparator) + } + + static void main(String[] args) { + boolean apiOnly = Boolean.parseBoolean(args[0]) + def hibernateDatastore = new HibernateDatastore(Hibernate7SchemaExportLoggingEntity) + def applicationContext = [ + getBean: { String name, Class type -> + assert name == 'hibernateDatastore' + assert type == HibernateDatastore + hibernateDatastore + } + ] as ConfigurableApplicationContext + def command = new SchemaExportCommand(applicationContext: applicationContext) + def targetDirectory = File.createTempDir('hibernate7-schema-export', '') + def commandLine = [ + getRemainingArgs: { [targetDirectory.absolutePath] }, + getUndeclaredOptions: { [:] } + ] as CommandLine + try { + assert LoggerFactory.getLogger(SchemaExportCommand).errorEnabled == !apiOnly + boolean result = command.handle(new ExecutionContext(commandLine)) + println "result=${result}" + assert !result + } finally { + hibernateDatastore?.close() + targetDirectory?.deleteDir() + } + println 'verified' + } +} + +@Entity +class Hibernate7SchemaExportLoggingEntity { + Long id + String name +} diff --git a/grails-forge/grails-forge-core/build.gradle b/grails-forge/grails-forge-core/build.gradle index 311b2025d30..aab9eafef5e 100644 --- a/grails-forge/grails-forge-core/build.gradle +++ b/grails-forge/grails-forge-core/build.gradle @@ -72,7 +72,7 @@ dependencies { exclude group: 'org.apache.groovy', module: 'groovy-all' } - testRuntimeOnly "ch.qos.logback:logback-classic" + testImplementation "ch.qos.logback:logback-classic" testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion" testImplementation 'org.apache.groovy:groovy-test' } @@ -132,4 +132,3 @@ nohttp { it.dependsOn(copyGrailsWrapper, grailsVersionInfoTask) } } - diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java index 87a9f5c1ef8..0ed5422baa0 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java @@ -22,6 +22,8 @@ import io.micronaut.core.io.ResourceResolver; import io.micronaut.core.util.StringUtils; import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Node; import org.w3c.dom.NodeList; @@ -41,6 +43,8 @@ @Singleton public class PomDependencyVersionResolver implements CoordinateResolver { + private static final Logger LOG = LoggerFactory.getLogger(PomDependencyVersionResolver.class); + private static final String NODE_NAME_TEXT = "#text"; private final Map coordinates; @@ -94,7 +98,7 @@ public PomDependencyVersionResolver(ResourceResolver resourceResolver) { } } } catch (IOException | SAXException | ParserConfigurationException e) { - e.printStackTrace(); + LOG.warn("Unable to read dependency versions from " + url, e); } } this.coordinates = coordinates; diff --git a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy index 2625691155e..0e0297c3380 100644 --- a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy +++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy @@ -17,9 +17,15 @@ * under the License. */ - package org.grails.forge.build.dependencies +package org.grails.forge.build.dependencies +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import io.micronaut.context.ApplicationContext +import io.micronaut.core.io.ResourceResolver +import org.slf4j.LoggerFactory import spock.lang.AutoCleanup import spock.lang.Shared import spock.lang.Specification @@ -39,4 +45,35 @@ class PomDependencyVersionResolverSpec extends Specification { expect: pomDependencyVersionResolver.coordinates } + + void "PomDependencyVersionResolver skips malformed POM resources"() { + given: + def malformedPom = File.createTempFile('malformed-pom', '.xml') + malformedPom.text = '' + def resourceResolver = Mock(ResourceResolver) + resourceResolver.getResources('classpath:pom.xml') >> [malformedPom.toURI().toURL()].stream() + Logger logger = (Logger) LoggerFactory.getLogger(PomDependencyVersionResolver) + def originalLevel = logger.level + def appender = new ListAppender() + appender.start() + logger.addAppender(appender) + logger.level = Level.WARN + + when: + def resolver = new PomDependencyVersionResolver(resourceResolver) + + then: + resolver.coordinates.isEmpty() + !resolver.resolve('missing-artifact').present + appender.list.any { + it.level == Level.WARN && + it.formattedMessage.contains('Unable to read dependency versions from') && + it.throwableProxy.className == 'org.xml.sax.SAXParseException' + } + + cleanup: + logger.detachAppender(appender) + logger.level = originalLevel + malformedPom?.delete() + } } diff --git a/grails-shell-cli/build.gradle b/grails-shell-cli/build.gradle index 4378bb29521..dcd2dc34b4a 100644 --- a/grails-shell-cli/build.gradle +++ b/grails-shell-cli/build.gradle @@ -120,7 +120,12 @@ dependencies { api 'org.slf4j:jcl-over-slf4j' // Testing - testImplementation 'org.slf4j:slf4j-simple' + // org.slf4j:slf4j-simple is already on the test classpath via the runtimeOnly + // dependency above. It coexists here with logback-classic (needed for Logback-appender + // assertions in forked-process specs), so both SLF4J providers are present at once - + // any new forked-process fixture must explicitly pin one via + // -Dslf4j.provider= to avoid SLF4J's ambiguous-binding provider selection. + testImplementation 'ch.qos.logback:logback-classic' testImplementation 'org.spockframework:spock-core' testRuntimeOnly 'net.bytebuddy:byte-buddy' // Required by Spock's mocking support diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy index e43feeba169..a7de3392b77 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy @@ -30,6 +30,8 @@ import org.jline.reader.EndOfFileException import org.jline.reader.UserInterruptException import org.jline.reader.impl.completer.ArgumentCompleter import org.jline.terminal.Terminal +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.gradle.tooling.BuildActionExecuter import org.gradle.tooling.BuildCancelledException import org.gradle.tooling.ProjectConnection @@ -79,6 +81,8 @@ import org.grails.exceptions.ExceptionUtils @CompileStatic class GrailsCli { + private static final Logger LOG = LoggerFactory.getLogger(GrailsCli) + static final String ARG_SPLIT_PATTERN = /(? appender = new ListAppender<>() + appender.start() + logger.addAppender(appender) + PrintStream originalErr = System.err + ByteArrayOutputStream stderr = new ByteArrayOutputStream() + System.setErr(new PrintStream(stderr, true)) + try { + System.setProperty('user.home', homeDirectory) + Class.forName('org.grails.cli.GrailsCli') + assert appender.list.size() == 1 + ILoggingEvent event = appender.list[0] + assert event.loggerName == 'org.grails.cli.GrailsCli' + assert event.level == Level.ERROR + assert event.formattedMessage.contains('Problem loading') + assert event.throwableProxy.className.contains('MultipleCompilationErrorsException') + assert stderr.toString('UTF-8').contains('ERROR: Problem loading') + } + finally { + System.setErr(originalErr) + logger.detachAppender(appender) + } + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy new file mode 100644 index 00000000000..30f3cfca891 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy @@ -0,0 +1,101 @@ +/* + * 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.grails.cli + +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeUnit + +import spock.lang.Specification +import spock.lang.TempDir + +class GrailsCliSpec extends Specification { + + @TempDir + File tempDir + + void "shared settings parse failures are logged and reported to stderr"() { + given: + File grailsHome = new File(tempDir, '.grails') + assert grailsHome.mkdirs() + File settingsFile = new File(grailsHome, 'settings.groovy') + settingsFile.text = 'invalid = [' + + expect: + loadGrailsCli(tempDir) == 'verified' + } + + private String loadGrailsCli(File homeDirectory) { + File argumentsFile = new File(tempDir, 'grails-cli.args') + argumentsFile.text = (jacocoAgentArguments() + [ + '-cp', + testRuntimeClasspath(), + GrailsCliSpec.name, + homeDirectory.absolutePath + ]).join('\n') + Process process = new ProcessBuilder( + new File(System.getProperty('java.home'), 'bin/java').absolutePath, + "@${argumentsFile.absolutePath}" + ).redirectErrorStream(true).start() + process.outputStream.close() + try { + awaitProcess(process, 'Grails CLI fixture') + String output = process.inputStream.withCloseable { it.getText('UTF-8').trim() } + assert process.exitValue() == 0 : output + output.readLines().last() + } finally { + if (process.isAlive()) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + process.inputStream.close() + process.errorStream.close() + } + } + + private static void awaitProcess(Process process, String fixtureName) { + if (process.waitFor(30, TimeUnit.SECONDS)) { + return + } + process.destroyForcibly() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + throw new AssertionError("${fixtureName} did not terminate after timing out") + } + throw new AssertionError("${fixtureName} timed out after 30 seconds") + } + + private static String testRuntimeClasspath() { + [ + System.getProperty('java.class.path'), + new File('grails-shell-cli/build/resources/test').absolutePath + ].join(File.pathSeparator) + } + + private static List jacocoAgentArguments() { + ManagementFactory.runtimeMXBean.inputArguments.findAll { + it.startsWith('-javaagent:') && it.contains('jacoco') + } + } + + static void main(String[] args) { + System.setProperty('slf4j.provider', 'ch.qos.logback.classic.spi.LogbackServiceProvider') + GrailsCliLoggingVerifier verifier = new GrailsCliLoggingVerifier(args[0]) + verifier.verify() + println 'verified' + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy new file mode 100644 index 00000000000..b48d93b738c --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy @@ -0,0 +1,195 @@ +/* + * 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.grails.cli.command.run + +import java.util.concurrent.TimeUnit +import java.util.logging.Level + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.grails.cli.compiler.GroovyCompilerScope +import org.slf4j.LoggerFactory + +class LifecycleVerifier { + + private final String failureMode + private final boolean loggingEnabled + + LifecycleVerifier(String failureMode, boolean loggingEnabled) { + this.failureMode = failureMode + this.loggingEnabled = loggingEnabled + } + + void verify() { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory() + context.reset() + Logger rootLogger = context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME) + rootLogger.level = loggingEnabled ? ch.qos.logback.classic.Level.DEBUG : ch.qos.logback.classic.Level.OFF + Logger runnerLogger = context.getLogger(SpringApplicationRunner.name) + ListAppender appender = new ListAppender<>() + appender.start() + runnerLogger.addAppender(appender) + PrintStream originalErr = System.err + ByteArrayOutputStream stderr = new ByteArrayOutputStream() + System.setErr(new PrintStream(stderr, true)) + File source = File.createTempFile('runner-lifecycle', '.groovy') + try { + verifyLifecycle(source, stderr, appender) + } + finally { + System.setErr(originalErr) + runnerLogger.detachAppender(appender) + source.delete() + } + } + + private void verifyLifecycle(File source, ByteArrayOutputStream stderr, ListAppender appender) { + source.text = successfulApplicationSource() + SpringApplicationRunner runner = new SpringApplicationRunner(configuration(failureMode == 'reload'), [source.toURI().toString()] as String[]) + if (failureMode == 'reload') { + runner.compileAndRun() + Thread.sleep(1100) + source.text = invalidApplicationSource() + assert source.setLastModified(System.currentTimeMillis() + 2000) + waitForFailure(stderr, appender) + } + else { + source.text = failureMode == 'launch' ? launchFailureSource() : shutdownFailureSource() + runner.compileAndRun() + if (failureMode == 'shutdown') { + runner.stop() + } + } + assertFailure(stderr.toString('UTF-8'), appender.list) + } + + private void waitForFailure(ByteArrayOutputStream stderr, ListAppender appender) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && stderr.size() == 0 && appender.list.empty) { + Thread.sleep(50) + } + assert stderr.size() > 0 || !appender.list.empty + } + + private void assertFailure(String stderr, List events) { + if (loggingEnabled) { + assert stderr.empty + assert events.size() == 1 + ILoggingEvent event = events[0] + assert event.loggerName == SpringApplicationRunner.name + assert event.level == (failureMode == 'shutdown' ? ch.qos.logback.classic.Level.WARN : ch.qos.logback.classic.Level.ERROR) + assert event.formattedMessage == expectedMessage() + assert event.throwableProxy != null + } + else { + assert events.empty + assert stderr.contains(expectedFailure()) + assert stderr.count(failureHeader()) == 1 + } + } + + private String expectedMessage() { + switch (failureMode) { + case 'launch': + return 'Unable to launch application' + case 'reload': + return 'Unable to compile and run application after a file change' + default: + return 'Unable to close application context' + } + } + + private String expectedFailure() { + failureMode == 'reload' ? 'ReloadFailure' : "${failureMode} failure" + } + + private String failureHeader() { + failureMode == 'reload' ? 'MultipleCompilationErrorsException' : expectedFailure() + } + + private SpringApplicationRunnerConfiguration configuration(boolean watch) { + [ + getScope : { GroovyCompilerScope.DEFAULT }, + isGuessImports : { false }, + isGuessDependencies : { false }, + isAutoconfigure : { false }, + getClasspath : { ['.'] as String[] }, + getRepositoryConfiguration: { [] }, + isQuiet : { true }, + isWatchForFileChanges : { watch }, + getLogLevel : { loggingEnabled ? Level.INFO : Level.OFF } + ] as SpringApplicationRunnerConfiguration + } + + private static String successfulApplicationSource() { + applicationSource('new Object()') + } + + private static String launchFailureSource() { + applicationSource("throw new IllegalStateException('launch failure')") + } + + private static String shutdownFailureSource() { + """\ + package org.springframework.boot + + class SpringApplication { + SpringApplication(Class[] sources) { + } + + void setDefaultProperties(Map defaultProperties) { + } + + Object run(String[] args) { + new FailingContext() + } + } + + class FailingContext { + void close() { + throw new IllegalStateException('shutdown failure') + } + } + """.stripIndent() + } + + private static String invalidApplicationSource() { + 'class Broken extends ReloadFailure { }\n' + } + + private static String applicationSource(String runBody) { + """\ + package org.springframework.boot + + class SpringApplication { + SpringApplication(Class[] sources) { + } + + void setDefaultProperties(Map defaultProperties) { + } + + Object run(String[] args) { + ${runBody} + } + } + """.stripIndent() + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy new file mode 100644 index 00000000000..96a55666490 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy @@ -0,0 +1,133 @@ +/* + * 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.grails.cli.command.run + +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeUnit + +import spock.lang.Specification +import spock.lang.TempDir + +class SpringApplicationRunnerSpec extends Specification { + + @TempDir + File tempDir + + void "ROOT OFF prints #failureMode failures to stderr"() { + expect: + runFixture(failureMode, false) == 'verified' + + where: + failureMode << ['launch', 'reload', 'shutdown'] + } + + void "enabled logging captures #failureMode failures without stderr duplication"() { + expect: + runFixture(failureMode, true) == 'verified' + + where: + failureMode << ['launch', 'reload', 'shutdown'] + } + + private String runFixture(String failureMode, boolean loggingEnabled) { + File argumentsFile = new File(tempDir, "${failureMode}-${loggingEnabled}.args") + argumentsFile.text = (jacocoAgentArguments() + [ + '-cp', + testRuntimeClasspath(), + SpringApplicationRunnerSpec.name, + failureMode, + loggingEnabled as String + ]).join('\n') + Process process = new ProcessBuilder( + new File(System.getProperty('java.home'), 'bin/java').absolutePath, + "@${argumentsFile.absolutePath}" + ).redirectErrorStream(true).start() + process.outputStream.close() + try { + awaitProcess(process, 'Spring application runner fixture') + String output = process.inputStream.withCloseable { it.getText('UTF-8').trim() } + assert process.exitValue() == 0 : output + output.readLines().last() + } finally { + if (process.isAlive()) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + process.inputStream.close() + process.errorStream.close() + } + } + + private static void awaitProcess(Process process, String fixtureName) { + if (process.waitFor(30, TimeUnit.SECONDS)) { + return + } + process.destroyForcibly() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + throw new AssertionError("${fixtureName} did not terminate after timing out") + } + throw new AssertionError("${fixtureName} timed out after 30 seconds") + } + + private static String testRuntimeClasspath() { + [ + System.getProperty('java.class.path'), + new File('grails-shell-cli/build/resources/test').absolutePath + ].join(File.pathSeparator) + } + + private static List jacocoAgentArguments() { + ManagementFactory.runtimeMXBean.inputArguments.findAll { + it.startsWith('-javaagent:') && it.contains('jacoco') + } + } + + static void main(String[] args) { + String originalUserHome = System.getProperty('user.home') + File userHome = File.createTempDir('spring-application-runner', '') + String failureMode = args[0] + boolean loggingEnabled = Boolean.parseBoolean(args[1]) + int reloadExitCode = 0 + try { + System.setProperty('user.home', userHome.absolutePath) + System.setProperty('slf4j.provider', 'ch.qos.logback.classic.spi.LogbackServiceProvider') + LifecycleVerifier verifier = new LifecycleVerifier(failureMode, loggingEnabled) + try { + verifier.verify() + println 'verified' + } + catch (Throwable throwable) { + throwable.printStackTrace() + if (failureMode == 'reload') { + reloadExitCode = 1 + } + else { + throw throwable + } + } + } + finally { + System.setProperty('user.home', originalUserHome) + userHome.deleteDir() + } + if (failureMode == 'reload') { + System.exit(reloadExitCode) + } + } +} diff --git a/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml b/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml new file mode 100644 index 00000000000..c27818f3bd4 --- /dev/null +++ b/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + test + spring-boot-dependencies + 1.0.0 + + + +