Skip to content

Commit 7ac1ac6

Browse files
committed
Test the Spotless step in a build of several projects
MultiProjectTest builds the layout the docs recommend: the plugins declared in the root project, applied to two subprojects, the root project without java. It fails on two counts. With --parallel, a subproject's spotlessJava resolves the root project's palantirJavaFormat without the root project's lock, and Gradle refuses: "Resolution of the configuration ':palantirJavaFormat' was attempted without an exclusive lock". A root project with java and Spotless of its own fails the same way. Without java on the root project, palantirJavaFormat asks for no attributes, and Gradle cannot pick a variant of a library that has one for the JRE and one for Android, as Guava does. The formatter depends on Guava, so the default formatter fails to resolve. The test uses a library of its own with two such variants, in a repository it writes under build/, so that it runs offline.
1 parent 0ce3cbf commit 7ac1ac6

2 files changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package com.palantir.javaformat.gradle;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
import com.palantir.javaformat.gradle.testing.GradleTestProject;
20+
import java.io.File;
21+
import java.io.IOException;
22+
import java.io.UncheckedIOException;
23+
import java.nio.file.FileAlreadyExistsException;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import java.util.List;
27+
import java.util.jar.Attributes;
28+
import java.util.jar.JarOutputStream;
29+
import java.util.jar.Manifest;
30+
import org.junit.jupiter.api.Test;
31+
import org.junit.jupiter.api.io.TempDir;
32+
import org.junit.jupiter.params.Parameter;
33+
import org.junit.jupiter.params.ParameterizedClass;
34+
import org.junit.jupiter.params.provider.ValueSource;
35+
36+
/**
37+
* The formatter in a build of several projects, laid out as the docs recommend: the plugins declared in the root
38+
* project, applied to the subprojects, and the formatter resolved through the root project's palantirJavaFormat.
39+
*
40+
* <p>Every test checks the files the build leaves: a subproject whose formatter went missing is not formatted, though
41+
* its task may well succeed.
42+
*/
43+
@ParameterizedClass(name = "configuration cache {0}")
44+
@ValueSource(booleans = {true, false})
45+
final class MultiProjectTest {
46+
47+
// Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape.
48+
private static final String CLASSPATH_FILE =
49+
new File("build/impl.classpath").getAbsolutePath().replace('\\', '/');
50+
51+
/**
52+
* A Maven repository with test:variants:1.0. Not in a temporary directory: the daemon keeps the formatter's jars
53+
* open after the build, and Windows does not delete an open file.
54+
*/
55+
private static final Path VARIANTS_REPO = Path.of("build/variants-repo");
56+
57+
private static final List<String> SUBPROJECTS = List.of("a", "b");
58+
private static final String MAIN_JAVA = "src/main/java/Main.java";
59+
private static final String UNFORMATTED = "class Main {\n int x;\n}\n";
60+
private static final String FORMATTED = "class Main {\n int x;\n}\n";
61+
62+
/** The subprojects apply the plugins, and the formatter is the jars this build made, not a release. */
63+
private static final String SUBPROJECTS_AND_FORMATTER = """
64+
subprojects {
65+
apply plugin: 'java'
66+
apply plugin: 'dev.openjavaformat.java-format'
67+
apply plugin: 'com.diffplug.spotless'
68+
}
69+
70+
// Below subprojects: applying the plugin there is what gives the root project palantirJavaFormat.
71+
dependencies {
72+
palantirJavaFormat files(file("%s").text.split(File.pathSeparator))
73+
}
74+
""";
75+
76+
@Parameter
77+
private boolean configurationCache;
78+
79+
@TempDir
80+
private Path projectDir;
81+
82+
@Test
83+
void spotlessApply_formats_every_subproject_in_a_parallel_build() {
84+
var project = project().writeFile("a/" + MAIN_JAVA, UNFORMATTED).writeFile("b/" + MAIN_JAVA, UNFORMATTED);
85+
86+
// In parallel, a subproject's task runs without the root project's lock, which resolving the root project's
87+
// configuration needs.
88+
project.succeeds("spotlessApply", "--parallel");
89+
90+
assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA);
91+
}
92+
93+
@Test
94+
void spotlessApply_formats_the_root_project_and_every_subproject_when_the_root_has_java() {
95+
// The root project's own java variants must not stand in for the formatter's.
96+
var project = new GradleTestProject(projectDir)
97+
.configurationCache(configurationCache)
98+
.plugins("java", "dev.openjavaformat.java-format", "com.diffplug.spotless")
99+
.subprojects(SUBPROJECTS.toArray(String[]::new))
100+
.withJavacInternalExports()
101+
.buildGradle(SUBPROJECTS_AND_FORMATTER, CLASSPATH_FILE)
102+
.writeFile(MAIN_JAVA, UNFORMATTED)
103+
.writeFile("a/" + MAIN_JAVA, UNFORMATTED)
104+
.writeFile("b/" + MAIN_JAVA, UNFORMATTED);
105+
106+
project.succeeds("spotlessApply", "--parallel");
107+
108+
assertFormatted(project, MAIN_JAVA, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA);
109+
}
110+
111+
@Test
112+
void formats_with_a_formatter_dependency_that_has_variants_when_the_root_has_no_java() {
113+
writeLibraryWithVariants();
114+
// idea: updateOpenJavaFormatXml runs with every build and resolves palantirJavaFormat in the root project.
115+
var project = project()
116+
.plugins("idea")
117+
.buildGradle("""
118+
allprojects {
119+
repositories {
120+
maven { url = file("%s") }
121+
}
122+
}
123+
dependencies {
124+
palantirJavaFormat 'test:variants:1.0'
125+
}
126+
""", VARIANTS_REPO.toAbsolutePath().toString().replace('\\', '/'))
127+
.writeFile("a/" + MAIN_JAVA, UNFORMATTED)
128+
.writeFile("b/" + MAIN_JAVA, UNFORMATTED);
129+
130+
project.succeeds("spotlessApply");
131+
132+
assertFormatted(project, "a/" + MAIN_JAVA, "b/" + MAIN_JAVA);
133+
assertThat(project.readFile(".idea/open-java-format.xml"))
134+
.contains("variants-1.0.jar")
135+
.doesNotContain("variants-1.0-android.jar");
136+
}
137+
138+
/** The layout of the docs, with no java on the root project. */
139+
private GradleTestProject project() {
140+
return new GradleTestProject(projectDir)
141+
.configurationCache(configurationCache)
142+
.pluginsNotApplied("dev.openjavaformat.java-format", "com.diffplug.spotless")
143+
.subprojects(SUBPROJECTS.toArray(String[]::new))
144+
.withJavacInternalExports()
145+
.buildGradle(SUBPROJECTS_AND_FORMATTER, CLASSPATH_FILE);
146+
}
147+
148+
private static void assertFormatted(GradleTestProject project, String... files) {
149+
for (var file : files) {
150+
// Spotless writes the platform's line endings, CRLF on Windows.
151+
assertThat(project.readFile(file)).as(file).isEqualToNormalizingNewlines(FORMATTED);
152+
}
153+
}
154+
155+
/**
156+
* A library with two runtime variants, for the JRE and for Android, as Guava publishes them: Gradle picks one only
157+
* for a configuration that asks for a library of a standard JVM. The jars are empty.
158+
*/
159+
private static void writeLibraryWithVariants() {
160+
var dir = VARIANTS_REPO.resolve("test/variants/1.0");
161+
try {
162+
Files.createDirectories(dir);
163+
writeEmptyJar(dir.resolve("variants-1.0.jar"));
164+
writeEmptyJar(dir.resolve("variants-1.0-android.jar"));
165+
write(dir.resolve("variants-1.0.pom"), """
166+
<?xml version="1.0" encoding="UTF-8"?>
167+
<project xmlns="http://maven.apache.org/POM/4.0.0">
168+
<!-- do_not_remove: published-with-gradle-metadata -->
169+
<modelVersion>4.0.0</modelVersion>
170+
<groupId>test</groupId>
171+
<artifactId>variants</artifactId>
172+
<version>1.0</version>
173+
</project>
174+
""");
175+
write(dir.resolve("variants-1.0.module"), """
176+
{
177+
"formatVersion": "1.1",
178+
"component": { "group": "test", "module": "variants", "version": "1.0" },
179+
"variants": [%s, %s]
180+
}
181+
""".formatted(
182+
variant("jreRuntimeElements", "standard-jvm", "variants-1.0.jar"),
183+
variant("androidRuntimeElements", "android", "variants-1.0-android.jar")));
184+
} catch (IOException e) {
185+
throw new UncheckedIOException(e);
186+
}
187+
}
188+
189+
private static String variant(String name, String environment, String jar) {
190+
return """
191+
{
192+
"name": "%s",
193+
"attributes": {
194+
"org.gradle.category": "library",
195+
"org.gradle.dependency.bundling": "external",
196+
"org.gradle.jvm.environment": "%s",
197+
"org.gradle.libraryelements": "jar",
198+
"org.gradle.usage": "java-runtime"
199+
},
200+
"files": [{ "name": "%s", "url": "%s" }]
201+
}
202+
""".formatted(name, environment, jar, jar);
203+
}
204+
205+
/** Written once: on Windows a daemon of an earlier test may hold the jars open, and they never change. */
206+
private static void writeEmptyJar(Path jar) throws IOException {
207+
var manifest = new Manifest();
208+
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
209+
try (var out = new JarOutputStream(Files.newOutputStream(Files.createFile(jar)), manifest)) {
210+
out.flush();
211+
} catch (FileAlreadyExistsException e) {
212+
// An earlier test wrote it.
213+
}
214+
}
215+
216+
private static void write(Path file, String content) throws IOException {
217+
if (!Files.exists(file)) {
218+
Files.writeString(file, content);
219+
}
220+
}
221+
}

‎gradle-open-java-format/src/test/java/com/palantir/javaformat/gradle/testing/GradleTestProject.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ public final class GradleTestProject {
6060

6161
private final Path projectDir;
6262
private final Set<String> plugins = new LinkedHashSet<>();
63+
private final Set<String> pluginsNotApplied = new LinkedHashSet<>();
64+
private final Set<String> subprojects = new LinkedHashSet<>();
6365
private final StringBuilder buildGradle = new StringBuilder();
6466
private final StringBuilder gradleProperties = new StringBuilder();
6567
private boolean configurationCache = true;
@@ -79,6 +81,21 @@ public GradleTestProject plugins(String... ids) {
7981
return this;
8082
}
8183

84+
/**
85+
* Declares these plugins in the plugins block with {@code apply false}, as a multi-project build does: on the build's
86+
* classpath, for the subprojects to apply.
87+
*/
88+
public GradleTestProject pluginsNotApplied(String... ids) {
89+
pluginsNotApplied.addAll(Arrays.asList(ids));
90+
return this;
91+
}
92+
93+
/** Includes these subprojects, in directories of their names. A test writes their files with {@link #writeFile}. */
94+
public GradleTestProject subprojects(String... names) {
95+
subprojects.addAll(Arrays.asList(names));
96+
return this;
97+
}
98+
8299
/** Appends to build.gradle, below the plugins block. {@code %s} placeholders are substituted as in printf. */
83100
public GradleTestProject buildGradle(String format, Object... args) {
84101
buildGradle.append(args.length == 0 ? format : String.format(format, args)).append('\n');
@@ -216,9 +233,11 @@ private static List<File> pluginClasspathWithoutSpotless() {
216233
private void write() {
217234
StringBuilder script = new StringBuilder("plugins {\n");
218235
plugins.forEach(id -> script.append(" id '").append(id).append("'\n"));
236+
pluginsNotApplied.forEach(id -> script.append(" id '").append(id).append("' apply false\n"));
219237
script.append("}\n\nrepositories {\n mavenCentral()\n}\n\n").append(buildGradle);
220238

221239
StringBuilder settings = new StringBuilder("rootProject.name = 'test-project'\n");
240+
subprojects.forEach(name -> settings.append("include '").append(name).append("'\n"));
222241
// Forward slashes: the path goes into a Groovy string, where a Windows backslash would start an escape.
223242
buildCacheDir.ifPresent(dir -> settings.append("\nbuildCache {\n local {\n directory = file('")
224243
.append(dir.toAbsolutePath().toString().replace('\\', '/'))

0 commit comments

Comments
 (0)