|
| 1 | +package tests |
| 2 | + |
| 3 | +import com.sourcegraph.scip_java.CliEnvironment |
| 4 | +import com.sourcegraph.scip_java.ScipJavaApp |
| 5 | +import com.sourcegraph.scip_java.buildtools.ClasspathEntry |
| 6 | +import java.io.ByteArrayOutputStream |
| 7 | +import java.io.PrintStream |
| 8 | +import java.nio.charset.StandardCharsets |
| 9 | +import java.nio.file.Files |
| 10 | +import java.nio.file.Path |
| 11 | +import java.nio.file.Paths |
| 12 | +import java.nio.file.StandardCopyOption |
| 13 | +import java.util.stream.Collectors |
| 14 | +import kotlin.test.assertEquals |
| 15 | +import kotlin.test.assertNotEquals |
| 16 | +import kotlin.test.fail |
| 17 | +import org.junit.jupiter.api.Assumptions.assumeTrue |
| 18 | +import org.junit.jupiter.api.DynamicTest |
| 19 | +import org.junit.jupiter.api.DynamicTest.dynamicTest |
| 20 | + |
| 21 | +/** |
| 22 | + * Base class for build-tool integration tests. Each `check*` helper returns a JUnit 5 |
| 23 | + * [DynamicTest]; suites expose them from a `@TestFactory` method. |
| 24 | + */ |
| 25 | +abstract class BuildToolHarness { |
| 26 | + |
| 27 | + /** Run `scip-java` in-process with stdout/stderr redirected into a buffer. */ |
| 28 | + private fun runScipJava(workingDirectory: Path, arguments: List<String>): Pair<Int, String> { |
| 29 | + val buffer = ByteArrayOutputStream() |
| 30 | + val stream = PrintStream(buffer, true, StandardCharsets.UTF_8.name()) |
| 31 | + val app = ScipJavaApp() |
| 32 | + app.env = |
| 33 | + CliEnvironment( |
| 34 | + workingDirectory = workingDirectory, |
| 35 | + standardOutput = stream, |
| 36 | + standardError = stream, |
| 37 | + ) |
| 38 | + val exit = app.run(arguments) |
| 39 | + return exit to buffer.toString(StandardCharsets.UTF_8.name()) |
| 40 | + } |
| 41 | + |
| 42 | + private fun listScipShards(targetroot: Path): List<Path> { |
| 43 | + if (!Files.isDirectory(targetroot)) return emptyList() |
| 44 | + Files.walk(targetroot).use { stream -> |
| 45 | + return stream.filter { it.toString().endsWith(".scip") }.collect(Collectors.toList()) |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + /** |
| 50 | + * Fresh temp directory for one test, with symlinks resolved. `toRealPath` matters on macOS |
| 51 | + * where the system temp dir lives under `/var` (a symlink to `/private/var`): build tools write |
| 52 | + * canonical paths into their output, so the sourceroot must be canonical too or path prefix |
| 53 | + * checks fail. |
| 54 | + */ |
| 55 | + private fun newTempBase(): Path = Files.createTempDirectory("buildtools").toRealPath() |
| 56 | + |
| 57 | + /** |
| 58 | + * Materialize a test project from the `fixtures/<name>` directory on the test classpath into |
| 59 | + * [target], overwriting existing files (e.g. a stub `build.gradle` left behind by wrapper |
| 60 | + * generation). When [substitutions] is non-empty, each token is textually replaced in every |
| 61 | + * file, which lets a single fixture cover parametrized variants (e.g. the JDK in a toolchain). |
| 62 | + */ |
| 63 | + protected fun copyFixture( |
| 64 | + fixture: String, |
| 65 | + target: Path, |
| 66 | + substitutions: Map<String, String> = emptyMap(), |
| 67 | + ) { |
| 68 | + val resource = "/fixtures/$fixture" |
| 69 | + val url = |
| 70 | + BuildToolHarness::class.java.getResource(resource) |
| 71 | + ?: error("Fixture not found on test classpath: $resource") |
| 72 | + val source = Paths.get(url.toURI()) |
| 73 | + Files.walk(source).use { stream -> |
| 74 | + stream.forEach { path -> |
| 75 | + val dest = target.resolve(source.relativize(path).toString()) |
| 76 | + if (Files.isDirectory(path)) { |
| 77 | + Files.createDirectories(dest) |
| 78 | + } else { |
| 79 | + Files.createDirectories(dest.parent) |
| 80 | + if (substitutions.isEmpty()) { |
| 81 | + Files.copy(path, dest, StandardCopyOption.REPLACE_EXISTING) |
| 82 | + } else { |
| 83 | + var text = Files.readString(path) |
| 84 | + for ((token, value) in substitutions) { |
| 85 | + text = text.replace(token, value) |
| 86 | + } |
| 87 | + Files.writeString(dest, text) |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + /** Compare two strings ignoring trailing whitespace. */ |
| 95 | + protected fun assertNoDiff(obtained: String, expected: String) { |
| 96 | + if (obtained.trimEnd() != expected.trimEnd()) { |
| 97 | + fail( |
| 98 | + "diff:\n--- obtained ---\n${obtained.trimEnd()}\n" + |
| 99 | + "--- expected ---\n${expected.trimEnd()}" |
| 100 | + ) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + protected fun checkBuild( |
| 105 | + name: String, |
| 106 | + fixture: String, |
| 107 | + expectedScipFiles: Int = 0, |
| 108 | + extraArguments: List<String> = emptyList(), |
| 109 | + expectedError: ((String) -> Unit)? = null, |
| 110 | + expectedPackages: String = "", |
| 111 | + initCommand: (Path) -> List<String> = { emptyList() }, |
| 112 | + prepare: (Path) -> Unit = {}, |
| 113 | + targetRoot: String? = null, |
| 114 | + maxJdk: Int? = null, |
| 115 | + substitutions: Map<String, String> = emptyMap(), |
| 116 | + ): DynamicTest = |
| 117 | + dynamicTest(name) { |
| 118 | + // Some build tools cap the JDK they support (e.g. Gradle 8.10 tops |
| 119 | + // out at JDK 21); skip rather than fail when the JDK is too new. |
| 120 | + assumeTrue( |
| 121 | + maxJdk == null || currentJavaVersion <= maxJdk, |
| 122 | + "Test $name ignored: JDK $currentJavaVersion exceeds max $maxJdk", |
| 123 | + ) |
| 124 | + |
| 125 | + val base = newTempBase() |
| 126 | + try { |
| 127 | + val workingDirectory = Files.createDirectories(base.resolve("workingDirectory")) |
| 128 | + val cacheDirectory = Files.createDirectories(base.resolve("cache")) |
| 129 | + |
| 130 | + val init = initCommand(workingDirectory) |
| 131 | + if (init.isNotEmpty()) exec(init, workingDirectory) |
| 132 | + |
| 133 | + copyFixture(fixture, workingDirectory, substitutions) |
| 134 | + prepare(workingDirectory) |
| 135 | + |
| 136 | + val targetroot = workingDirectory.resolve(targetRoot ?: "targetroot") |
| 137 | + val arguments = |
| 138 | + listOf( |
| 139 | + "index", |
| 140 | + "--temporary-directory", |
| 141 | + cacheDirectory.toString(), |
| 142 | + "--targetroot", |
| 143 | + targetroot.toString(), |
| 144 | + ) + extraArguments |
| 145 | + |
| 146 | + val (exit, output) = runScipJava(workingDirectory, arguments) |
| 147 | + |
| 148 | + if (expectedError != null) { |
| 149 | + assertNotEquals(0, exit, "expected failure, output:\n$output") |
| 150 | + expectedError(output) |
| 151 | + } else { |
| 152 | + assertEquals(0, exit, "expected success, output:\n$output") |
| 153 | + } |
| 154 | + |
| 155 | + val shards = listScipShards(targetroot) |
| 156 | + if (shards.size != expectedScipFiles) { |
| 157 | + fail( |
| 158 | + "Expected $expectedScipFiles SCIP shard(s), got ${shards.size}: " + |
| 159 | + "$shards\noutput:\n$output" |
| 160 | + ) |
| 161 | + } |
| 162 | + |
| 163 | + if (expectedPackages.isNotEmpty()) { |
| 164 | + val obtained = |
| 165 | + ClasspathEntry.fromTargetroot(targetroot, workingDirectory) |
| 166 | + .map { it.mavenCoordinate() } |
| 167 | + .sorted() |
| 168 | + .distinct() |
| 169 | + .joinToString("\n") |
| 170 | + assertNoDiff(obtained, expectedPackages) |
| 171 | + } |
| 172 | + } finally { |
| 173 | + base.toFile().deleteRecursively() |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + protected fun checkErrorOutput( |
| 178 | + name: String, |
| 179 | + arguments: List<String>, |
| 180 | + expectedOutput: String, |
| 181 | + fixture: String? = null, |
| 182 | + ): DynamicTest = |
| 183 | + dynamicTest(name) { |
| 184 | + val base = newTempBase() |
| 185 | + try { |
| 186 | + val workingDirectory = Files.createDirectories(base.resolve("workingDirectory")) |
| 187 | + if (fixture != null) { |
| 188 | + copyFixture(fixture, workingDirectory, emptyMap()) |
| 189 | + } |
| 190 | + val (exit, rawOutput) = runScipJava(workingDirectory, arguments) |
| 191 | + val output = rawOutput.replace(base.toString(), "") |
| 192 | + assertEquals(1, exit, "output:\n$output") |
| 193 | + assertNoDiff(output, expectedOutput) |
| 194 | + } finally { |
| 195 | + base.toFile().deleteRecursively() |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + companion object { |
| 200 | + /** Major version of the JVM running these tests (e.g. 17, 21). */ |
| 201 | + private val currentJavaVersion: Int = Runtime.version().feature() |
| 202 | + |
| 203 | + /** Run an external command, inheriting stdio. Throws if it exits non-zero. */ |
| 204 | + internal fun exec(command: List<String>, cwd: Path) { |
| 205 | + val exit = ProcessBuilder(command).directory(cwd.toFile()).inheritIO().start().waitFor() |
| 206 | + check(exit == 0) { "Command failed ($exit): ${command.joinToString(" ")}" } |
| 207 | + } |
| 208 | + } |
| 209 | +} |
0 commit comments