diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 07daedc..29c1b77 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,16 +33,14 @@ repositories { rife2 { version.set("1.4.0") useAgent.set(true) - precompiledTemplateTypes.addAll(HTML) } dependencies { - runtimeOnly("org.eclipse.jetty:jetty-server:11.0.13") - runtimeOnly("org.eclipse.jetty:jetty-servlet:11.0.13") - runtimeOnly("org.slf4j:slf4j-simple:2.0.5") + runtimeOnly(libs.bundles.jetty) + runtimeOnly(libs.slf4j.simple) - testImplementation("org.jsoup:jsoup:1.15.3") - testImplementation("org.junit.jupiter:junit-jupiter:5.9.1") + testImplementation(libs.jsoup) + testImplementation(libs.junit.jupiter) } tasks { diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index b29e98f..c7fabff 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-gradle-plugin` + groovy } repositories { @@ -8,6 +9,8 @@ repositories { dependencies { gradleApi() + testImplementation(libs.spock.core) + testImplementation(gradleTestKit()) } gradlePlugin { @@ -18,3 +21,11 @@ gradlePlugin { } } } + +tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + events = setOf(org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED, org.gradle.api.tasks.testing.logging.TestLogEvent.SKIPPED, org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED) + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 7fbbd44..9ac59bd 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -1 +1,9 @@ rootProject.name = "build-logic" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/build-logic/src/main/java/com/uwyn/rife2/gradle/Rife2Plugin.java b/build-logic/src/main/java/com/uwyn/rife2/gradle/Rife2Plugin.java index 9a1b5fe..2777742 100644 --- a/build-logic/src/main/java/com/uwyn/rife2/gradle/Rife2Plugin.java +++ b/build-logic/src/main/java/com/uwyn/rife2/gradle/Rife2Plugin.java @@ -45,6 +45,7 @@ public class Rife2Plugin implements Plugin { public static final String DEFAULT_GENERATED_RIFE2_CLASSES_DIR = "generated/classes/rife2"; public static final String RIFE2_GROUP = "rife2"; public static final String WEBAPP_SRCDIR = "src/main/webapp"; + public static final String PRECOMPILE_TEMPLATES_TASK_NAME = "precompileTemplates"; @Override public void apply(Project project) { @@ -62,7 +63,7 @@ public class Rife2Plugin implements Plugin { configurations.getByName(JavaPlugin.IMPLEMENTATION_CONFIGURATION_NAME).extendsFrom(rife2Configuration); var precompileTemplates = registerPrecompileTemplateTask(project, rife2CompilerClasspath, rife2Extension); - createRife2DevelopmentOnlyConfiguration(project, configurations, dependencyHandler, precompileTemplates); + createRife2DevelopmentOnlyConfiguration(project, configurations, dependencyHandler); exposePrecompiledTemplatesToTestTask(project, configurations, dependencyHandler, precompileTemplates); configureAgent(project, plugins, rife2Extension, rife2AgentClasspath); TaskProvider uberJarTask = registerUberJarTask(project, plugins, javaPluginExtension, rife2Extension, tasks, precompileTemplates); @@ -118,14 +119,13 @@ public class Rife2Plugin implements Plugin { private void createRife2DevelopmentOnlyConfiguration(Project project, ConfigurationContainer configurations, - DependencyHandler dependencies, - TaskProvider precompileTemplatesTask) { + DependencyHandler dependencies) { var rife2DevelopmentOnly = configurations.create("rife2DevelopmentOnly", conf -> { conf.setDescription("Dependencies which should only be visible when running the application in development mode (and not in tests)."); conf.setCanBeConsumed(false); conf.setCanBeResolved(false); }); - rife2DevelopmentOnly.getDependencies().add(dependencies.create(project.files(precompileTemplatesTask))); + rife2DevelopmentOnly.getDependencies().add(dependencies.create(project.files(DEFAULT_TEMPLATES_DIR))); configurations.getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME).extendsFrom(rife2DevelopmentOnly); } @@ -174,6 +174,7 @@ public class Rife2Plugin implements Plugin { rife2.getUseAgent().convention(false); rife2.getUberMainClass().convention(project.getExtensions().getByType(JavaApplication.class).getMainClass() .map(mainClass -> mainClass + "Uber")); + rife2.getPrecompiledTemplateTypes().convention(Collections.singletonList(TemplateType.HTML)); return rife2; } @@ -216,7 +217,7 @@ public class Rife2Plugin implements Plugin { private static TaskProvider registerPrecompileTemplateTask(Project project, Configuration rife2CompilerClasspath, Rife2Extension rife2Extension) { - return project.getTasks().register("precompileTemplates", PrecompileTemplates.class, task -> { + return project.getTasks().register(PRECOMPILE_TEMPLATES_TASK_NAME, PrecompileTemplates.class, task -> { task.setGroup(RIFE2_GROUP); task.setDescription("Pre-compiles the templates."); task.getVerbose().convention(true); diff --git a/build-logic/src/test-projects/minimal/build.gradle b/build-logic/src/test-projects/minimal/build.gradle new file mode 100644 index 0000000..697f9f5 --- /dev/null +++ b/build-logic/src/test-projects/minimal/build.gradle @@ -0,0 +1,40 @@ +import com.uwyn.rife2.gradle.TemplateType.* + +plugins { + id("application") + id("com.uwyn.rife2") +} + +base { + archivesName = "hello" + version = 1.0 + group = "com.example" +} + +application { + mainClass = "hello.App" +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +rife2 { + version = "1.4.0" + useAgent = true +} + +dependencies { + runtimeOnly("org.eclipse.jetty:jetty-server:11.0.13") + runtimeOnly("org.eclipse.jetty:jetty-servlet:11.0.13") + runtimeOnly("org.slf4j:slf4j-simple:2.0.5") + + testImplementation("org.jsoup:jsoup:1.15.3") + testImplementation("org.junit.jupiter:junit-jupiter:5.9.1") +} diff --git a/build-logic/src/test-projects/minimal/settings.gradle b/build-logic/src/test-projects/minimal/settings.gradle new file mode 100644 index 0000000..e87ab3c --- /dev/null +++ b/build-logic/src/test-projects/minimal/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'minimal' diff --git a/build-logic/src/test-projects/minimal/src/main/java/hello/App.java b/build-logic/src/test-projects/minimal/src/main/java/hello/App.java new file mode 100644 index 0000000..43a1870 --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/java/hello/App.java @@ -0,0 +1,16 @@ +package hello; + +import rife.engine.*; + +public class App extends Site { + public void setup() { + var hello = get("/hello", c -> c.print(c.template("hello"))); + get("/", c -> c.redirect(hello)); + } + + public static void main(String[] args) { + new Server() + .staticResourceBase("src/main/webapp") + .start(new App()); + } +} diff --git a/build-logic/src/test-projects/minimal/src/main/java/hello/AppUber.java b/build-logic/src/test-projects/minimal/src/main/java/hello/AppUber.java new file mode 100644 index 0000000..8dbf6a6 --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/java/hello/AppUber.java @@ -0,0 +1,11 @@ +package hello; + +import rife.engine.Server; + +public class AppUber extends App { + public static void main(String[] args) { + new Server() + .staticUberJarResourceBase("webapp") + .start(new AppUber()); + } +} \ No newline at end of file diff --git a/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/reflect-config.json b/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 0000000..9b0c3be --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,6 @@ +[ +{ + "name":"rife.template.html.hello", + "methods":[{"name":"","parameterTypes":[] }] +} +] diff --git a/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/resource-config.json b/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/resource-config.json new file mode 100644 index 0000000..ad0b0a3 --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/resources/META-INF/native-image/resource-config.json @@ -0,0 +1,8 @@ +{ + "resources":{ + "includes":[ + {"pattern":"^webapp/.*$"} + ] + }, + "bundles":[] +} diff --git a/build-logic/src/test-projects/minimal/src/main/templates/hello.html b/build-logic/src/test-projects/minimal/src/main/templates/hello.html new file mode 100644 index 0000000..59ff81b --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/templates/hello.html @@ -0,0 +1,11 @@ + + + + + <!--v title-->Hello<!--/v--> + + + +

Hello World

+ + diff --git a/build-logic/src/test-projects/minimal/src/main/webapp/css/style.css b/build-logic/src/test-projects/minimal/src/main/webapp/css/style.css new file mode 100644 index 0000000..52bf6c7 --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/main/webapp/css/style.css @@ -0,0 +1,21 @@ +:root { + /* fonts */ + --main-font: sans-serif; + + /* font sizes */ + --main-font-size: 18px; + + /* colors */ + --main-background-color: #0d0d0d; + --main-text-color: #d0d0d0; + + /* margins and padding */ + --content-padding: 2em; +} +body { + background: var(--main-background-color); + font-family: var(--main-font); + font-style: var(--main-font-size); + color: var(--main-text-color); + padding: var(--content-padding); +} \ No newline at end of file diff --git a/build-logic/src/test-projects/minimal/src/test/java/hello/AppTest.java b/build-logic/src/test-projects/minimal/src/test/java/hello/AppTest.java new file mode 100644 index 0000000..45a0763 --- /dev/null +++ b/build-logic/src/test-projects/minimal/src/test/java/hello/AppTest.java @@ -0,0 +1,24 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ +package hello; + +import org.junit.jupiter.api.Test; +import rife.test.MockConversation; + +import static org.junit.jupiter.api.Assertions.*; + +public class AppTest { + @Test + void verifyRoot() { + var m = new MockConversation(new App()); + assertEquals(m.doRequest("/").getStatus(), 302); + } + + @Test + void verifyHello() { + var m = new MockConversation(new App()); + assertEquals("Hello", m.doRequest("/hello") + .getTemplate().getValue("title")); + } +} diff --git a/build-logic/src/test/groovy/com/uwyn/rife2/gradle/AbstractFunctionalTest.groovy b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/AbstractFunctionalTest.groovy new file mode 100644 index 0000000..7629d3f --- /dev/null +++ b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/AbstractFunctionalTest.groovy @@ -0,0 +1,184 @@ +package com.uwyn.rife2.gradle + +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.gradle.util.GFileUtils +import org.gradle.util.GradleVersion +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Path + +abstract class AbstractFunctionalTest extends Specification { + + private final String gradleVersion = System.getProperty("gradleVersion", GradleVersion.current().version) + + @TempDir + Path testDirectory + + boolean debug + + private StringWriter outputWriter + private StringWriter errorOutputWriter + private String output + private String errorOutput + + BuildResult result + + Path path(String... pathElements) { + Path cur = testDirectory + pathElements.each { + cur = cur.resolve(it) + } + cur + } + + File file(String... pathElements) { + path(pathElements).toFile() + } + + File getGroovyBuildFile() { + file("build.gradle") + } + + File getBuildFile() { + groovyBuildFile + } + + File getKotlinBuildFile() { + file("build.gradle.kts") + } + + File getGroovySettingsFile() { + file("settings.gradle") + } + + File getKotlinSettingsFile() { + file("settings.gradle.kts") + } + + File getSettingsFile() { + groovySettingsFile + } + + void run(String... args) { + try { + result = newRunner(args) + .build() + } finally { + recordOutputs() + } + } + + void outputContains(String text) { + assert output.normalize().contains(text.normalize()) + } + + void outputDoesNotContain(String text) { + assert !output.normalize().contains(text.normalize()) + } + + void errorOutputContains(String text) { + assert errorOutput.normalize().contains(text.normalize()) + } + + void tasks(@DelegatesTo(value = TaskExecutionGraph, strategy = Closure.DELEGATE_FIRST) Closure spec) { + def graph = new TaskExecutionGraph() + spec.delegate = graph + spec.resolveStrategy = Closure.DELEGATE_FIRST + spec() + } + + private void recordOutputs() { + output = outputWriter.toString() + errorOutput = errorOutputWriter.toString() + } + + private GradleRunner newRunner(String... args) { + outputWriter = new StringWriter() + errorOutputWriter = new StringWriter() + ArrayList autoArgs = computeAutoArgs() + def runner = GradleRunner.create() + .forwardStdOutput(tee(new OutputStreamWriter(System.out), outputWriter)) + .forwardStdError(tee(new OutputStreamWriter(System.err), errorOutputWriter)) + .withPluginClasspath() + .withProjectDir(testDirectory.toFile()) + .withArguments([*autoArgs, *args]) + if (gradleVersion) { + runner.withGradleVersion(gradleVersion) + } + if (debug) { + runner.withDebug(true) + } + runner + } + + private ArrayList computeAutoArgs() { + List autoArgs = [ + "-s", + "--console=verbose" + ] + if (Boolean.getBoolean("config.cache")) { + autoArgs << '--configuration-cache' + } + autoArgs + } + + private static Writer tee(Writer one, Writer two) { + return TeeWriter.of(one, two) + } + + void fails(String... args) { + try { + result = newRunner(args) + .buildAndFail() + } finally { + recordOutputs() + } + } + + private class TaskExecutionGraph { + void succeeded(String... tasks) { + tasks.each { task -> + contains(task) + assert result.task(task).outcome == TaskOutcome.SUCCESS + } + } + + void failed(String... tasks) { + tasks.each { task -> + contains(task) + assert result.task(task).outcome == TaskOutcome.FAILED + } + } + + void skipped(String... tasks) { + tasks.each { task -> + contains(task) + assert result.task(task).outcome == TaskOutcome.SKIPPED + } + } + + void contains(String... tasks) { + tasks.each { task -> + assert result.task(task) != null: "Expected to find task $task in the graph but it was missing" + } + } + + void doesNotContain(String... tasks) { + tasks.each { task -> + assert result.task(task) == null: "Task $task should be missing from the task graph but it was found with an outcome of ${result.task(task).outcome}" + } + } + } + + void usesProject(String name) { + File sampleDir = new File("src/test-projects/$name") + GFileUtils.copyDirectory(sampleDir, testDirectory.toFile()) + } + + File file(String path) { + new File(testDirectory.toFile(), path) + } +} diff --git a/build-logic/src/test/groovy/com/uwyn/rife2/gradle/PackagingTest.groovy b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/PackagingTest.groovy new file mode 100644 index 0000000..e08ff3c --- /dev/null +++ b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/PackagingTest.groovy @@ -0,0 +1,35 @@ +package com.uwyn.rife2.gradle + +import java.nio.file.FileSystems +import java.nio.file.Files + +class PackagingTest extends AbstractFunctionalTest { + def setup() { + usesProject("minimal") + } + + def "#archive contains compiled resources"() { + def jarFile = file(archive).toPath() + when: + run task + + then: "compiles templates are found in the archive" + tasks { + succeeded ":${Rife2Plugin.PRECOMPILE_TEMPLATES_TASK_NAME}" + } + Files.exists(jarFile) + try (def fs = FileSystems.newFileSystem(jarFile, [:])) { + fs.getRootDirectories().each { + Files.walk(it).forEach { path -> + println path + } + } + assert Files.exists(fs.getPath("/rife/template/html/hello.class")) + } + + where: + task | archive + 'jar' | 'build/libs/hello-1.0.jar' + 'uberJar' | 'build/libs/hello-uber-1.0.jar' + } +} diff --git a/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TeeWriter.groovy b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TeeWriter.groovy new file mode 100644 index 0000000..89f60c1 --- /dev/null +++ b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TeeWriter.groovy @@ -0,0 +1,81 @@ +package com.uwyn.rife2.gradle + +import groovy.transform.CompileStatic + +@CompileStatic +class TeeWriter extends Writer { + private final Writer one + private final Writer two + + static TeeWriter of(Writer one, Writer two) { + new TeeWriter(one, two) + } + + private TeeWriter(Writer one, Writer two) { + this.one = one + this.two = two + } + + @Override + void write(int c) throws IOException { + try { + one.write(c) + } finally { + two.write(c) + } + } + + @Override + void write(char[] cbuf) throws IOException { + try { + one.write(cbuf) + } finally { + two.write(cbuf) + } + } + + @Override + void write(char[] cbuf, int off, int len) throws IOException { + try { + one.write(cbuf, off, len) + } finally { + two.write(cbuf, off, len) + } + } + + @Override + void write(String str) throws IOException { + try { + one.write(str) + } finally { + two.write(str) + } + } + + @Override + void write(String str, int off, int len) throws IOException { + try { + one.write(str, off, len) + } finally { + two.write(str, off, len) + } + } + + @Override + void flush() throws IOException { + try { + one.flush() + } finally { + two.flush() + } + } + + @Override + void close() throws IOException { + try { + one.close() + } finally { + two.close() + } + } +} diff --git a/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TemplateCompilationTest.groovy b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TemplateCompilationTest.groovy new file mode 100644 index 0000000..12f14a6 --- /dev/null +++ b/build-logic/src/test/groovy/com/uwyn/rife2/gradle/TemplateCompilationTest.groovy @@ -0,0 +1,58 @@ +package com.uwyn.rife2.gradle + +class TemplateCompilationTest extends AbstractFunctionalTest { + def setup() { + usesProject("minimal") + } + + def "doesn't precompile templates when calling `run`"() { + given: + buildFile << """ + tasks.named("run") { + doFirst { + throw new RuntimeException("force stop") + } + } + """ + when: + fails 'run' + + then: "precompile templates task must not be present in task graph" + errorOutputContains("force stop") + tasks { + doesNotContain ":${Rife2Plugin.PRECOMPILE_TEMPLATES_TASK_NAME}" + } + } + + def "`run` task classpath includes template sources"() { + given: + buildFile << """ + tasks.register("dumpRunClasspath") { + doLast { + tasks.named("run").get().classpath.files.each { + println "Classpath entry: \$it" + } + } + } + """ + + when: + run("dumpRunClasspath") + + then: "template sources must be present in the classpath" + outputContains("Classpath entry: ${file("src/main/templates").absolutePath}") + } + + def "compiles templates when running #task"() { + when: + run task + + then: "precompile templates task must be present in task graph" + tasks { + succeeded ":${Rife2Plugin.PRECOMPILE_TEMPLATES_TASK_NAME}" + } + + where: + task << ['jar', 'test', 'uberJar'] + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..3a8c200 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,17 @@ +[versions] +jetty = "11.0.13" +jsoup = "1.15.3" +junit-jupiter = "5.9.1" +slf4j = "2.0.5" +spock = "2.3-groovy-3.0" + +[libraries] +jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } +jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } + +[bundles] +jetty = [ "jetty-server", "jetty-servlet" ]