diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1eaae2807..5e82c2134 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -117,7 +117,20 @@ jobs: - name: Run tests shell: bash - run: ./gradlew test --no-daemon --stacktrace + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + ./gradlew test jacocoTestReport --no-daemon --stacktrace + else + ./gradlew test --no-daemon --stacktrace + fi + + - name: Upload coverage to Coveralls + if: runner.os == 'Linux' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: de.peeeq.wurstscript/build/reports/jacoco/test/jacocoTestReport.xml + format: jacoco - name: Report test results if: always() diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index 611bfc524..29d1ede8f 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -14,7 +14,6 @@ plugins { id 'idea' id 'jacoco' id 'maven-publish' - id 'com.github.kt3k.coveralls' version '2.12.2' id 'com.gradleup.shadow' version '9.2.2' id 'de.undercouch.download' version '5.6.0' } @@ -53,7 +52,16 @@ tasks.named("jacocoTestReport", JacocoReport) { reports { xml.required.set(true) } - def excluded = ['**/ast/**', '**/jassAst/**', '**/jassIm/**', '**/luaAst/**', '**/antlr/**'] + def excluded = [ + '**/ast/**', + '**/jassAst/**', + '**/jassIm/**', + '**/luaAst/**', + '**/antlr/**', + 'de/peeeq/wurstio/gui/**', + 'de/peeeq/wurstio/AbortCompilationException.class', + 'de/peeeq/wurstio/ModelChangedException.class' + ] classDirectories.setFrom( files(classDirectories.files.collect { dir -> @@ -473,10 +481,5 @@ tasks.register('create_zips') { } } -// TODO add a modern documentation generator replacement for the removed legacy hotdoc pipeline. -tasks.named("coveralls") { - notCompatibleWithConfigurationCache("coveralls plugin task uses Project at execution time") -} - /** -------- Apply deployment settings -------- */ apply from: 'deploy.gradle' diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/Checksums.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/Checksums.java index 397082c2a..74f82c0c2 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/Checksums.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/Checksums.java @@ -9,6 +9,8 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; @@ -42,7 +44,7 @@ private static String printData(List data) { private static List getData(File f) { List result = new ArrayList<>(); - for (File p : f.listFiles()) { + for (File p : sortedChildren(f)) { getDataRec(result, "", p); } return result; @@ -50,7 +52,7 @@ private static List getData(File f) { private static void getDataRec(List result, String path, File f) { if (f.isDirectory()) { - for (File p : f.listFiles()) { + for (File p : sortedChildren(f)) { getDataRec(result, path + "/" + f.getName(), p); } } else { @@ -58,6 +60,15 @@ private static void getDataRec(List result, String path, File f) { } } + private static File[] sortedChildren(File directory) { + File[] children = directory.listFiles(); + if (children == null) { + throw new IllegalArgumentException("Cannot list directory " + directory); + } + Arrays.sort(children, Comparator.comparing(File::getName)); + return children; + } + // stolen from http://stackoverflow.com/a/304350/303637 private static String md5(File f) { try { @@ -98,4 +109,4 @@ public Data(String filePath, String md5) { this.filePath = filePath; this.md5 = md5; } -} \ No newline at end of file +} diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/compilationserver/WurstServer.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/compilationserver/WurstServer.java index dc85b07da..961216fb5 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/compilationserver/WurstServer.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/compilationserver/WurstServer.java @@ -18,12 +18,21 @@ import java.util.function.Consumer; public class WurstServer { - private static final int portNumber = 27425; + private static final int DEFAULT_PORT = 27425; + private final int portNumber; private volatile boolean stopped; private Consumer printer = System.out::println; private @Nullable ServerSocket serverSocket; + public WurstServer() { + this(DEFAULT_PORT); + } + + WurstServer(int portNumber) { + this.portNumber = portNumber; + } + public void start() { try (ServerSocket serverSocket = new ServerSocket(portNumber, 1, InetAddress.getLoopbackAddress())) { this.serverSocket = serverSocket; diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/RunArgs.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/RunArgs.java index d6cae57e9..74ef7be0d 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/RunArgs.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/RunArgs.java @@ -132,7 +132,7 @@ public RunArgs(String... args) { optionExtractImports = addOptionWithArg("-extractImports", "Extract all files from a map into a folder next to the mapp.", arg -> mapFile = arg); optionExportObjects = addOptionWithArg("exportobjects", "Export object editor data from a map file or map folder to Wurst source.", arg -> exportObjectsFile = arg); addOptionWithArg("exportobjectsOut", "Output folder for -exportobjects.", arg -> exportObjectsOut = arg); - optionShowVersion = addOption("-version", "Shows the version of the compiler"); + optionShowVersion = addOption("version", "Shows the version of the compiler"); // other optionNoExtractMapScript = addOption("noExtractMapScript", "Do not extract the map script from the map and use the one from the Wurst folder instead."); diff --git a/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/MainTests.java b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/MainTests.java new file mode 100644 index 000000000..148fea42a --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/MainTests.java @@ -0,0 +1,38 @@ +package de.peeeq.wurstio; + +import de.peeeq.wurstscript.CompileTimeInfo; +import org.testng.annotations.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +public class MainTests { + + @Test + public void noArgumentsReturnsWithoutStartingInteractiveComponents() { + String output = captureStdout(() -> Main.main(new String[0])); + assertTrue(output.contains("Usage:")); + } + + @Test + public void versionPrintsCompilerVersion() { + String output = captureStdout(() -> Main.main(new String[]{"-version"})); + assertEquals(output.trim(), CompileTimeInfo.version); + } + + private String captureStdout(Runnable action) { + PrintStream previousOut = System.out; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8)); + action.run(); + } finally { + System.setOut(previousOut); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/compilationserver/WurstServerTests.java b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/compilationserver/WurstServerTests.java new file mode 100644 index 000000000..d8102985d --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/compilationserver/WurstServerTests.java @@ -0,0 +1,23 @@ +package de.peeeq.wurstio.compilationserver; + +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.testng.Assert.assertEquals; + +public class WurstServerTests { + + @Test + public void stoppedServerCanStartAndExitWithoutAcceptingRequests() { + WurstServer server = new WurstServer(0); + List messages = new ArrayList<>(); + server.setPrinter(messages::add); + + server.stop(); + server.start(); + + assertEquals(messages, List.of("Server started.", "Server stopped.")); + } +} diff --git a/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/map/importer/ImportFileTests.java b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/map/importer/ImportFileTests.java new file mode 100644 index 000000000..34fdc51fa --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/map/importer/ImportFileTests.java @@ -0,0 +1,164 @@ +package de.peeeq.wurstio.map.importer; + +import de.peeeq.wurstio.mpq.MpqEditor; +import de.peeeq.wurstio.utils.FileUtils; +import net.moonlightflower.wc3libs.bin.app.IMP; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class ImportFileTests { + private static final String MANIFEST_PATH = "wurst_cache_manifest.txt"; + + private Path tempDir; + + @AfterMethod(alwaysRun = true) + public void cleanup() throws IOException { + if (tempDir != null) { + FileUtils.deleteRecursively(tempDir.toFile()); + } + } + + @Test + public void hashesBytesAndFilesWithMd5() throws Exception { + byte[] content = "abc".getBytes(StandardCharsets.UTF_8); + assertEquals(ImportFile.calculateHash(content), "900150983cd24fb0d6963f7d28e17f72"); + + tempDir = Files.createTempDirectory("wurst-import-hash"); + Path file = Files.write(tempDir.resolve("value.bin"), content); + assertEquals(ImportFile.calculateFileHash(file.toFile()), "900150983cd24fb0d6963f7d28e17f72"); + } + + @Test + public void manifestRoundTripPreservesConfigsAndImports() { + ImportFile.CacheManifest manifest = new ImportFile.CacheManifest(); + manifest.setW3iConfig("w3i-hash"); + manifest.setMapConfig("map-hash"); + manifest.importFiles.put("models\\unit.mdx", + new ImportFile.CacheManifest.FileEntry("file-hash", 123L)); + + ImportFile.CacheManifest restored = ImportFile.CacheManifest.deserialize(manifest.serialize()); + + assertTrue(restored.hasW3iConfig()); + assertTrue(restored.hasMapConfig()); + assertTrue(restored.w3iConfigMatches("w3i-hash")); + assertTrue(restored.mapConfigMatches("map-hash")); + assertEquals(restored.importFiles.get("models\\unit.mdx").hash, "file-hash"); + assertEquals(restored.importFiles.get("models\\unit.mdx").lastModified, 123L); + } + + @Test + public void malformedManifestLinesAreIgnored() { + ImportFile.CacheManifest restored = ImportFile.CacheManifest.deserialize( + "# comment\ninvalid\nIMPORT|bad|hash|not-a-number\nUNKNOWN|path|hash|1\n"); + + assertFalse(restored.hasW3iConfig()); + assertFalse(restored.hasMapConfig()); + assertTrue(restored.importFiles.isEmpty()); + } + + @Test + public void manifestStorageUsesMpqCacheFile() { + FakeMpqEditor mpq = new FakeMpqEditor(); + ImportFile.CacheManifest manifest = new ImportFile.CacheManifest(); + manifest.setMapConfig("map-hash"); + + ImportFile.saveManifest(mpq, manifest); + assertTrue(ImportFile.getCachedManifest(mpq).orElseThrow().mapConfigMatches("map-hash")); + + ImportFile.invalidateCache(mpq); + assertTrue(ImportFile.getCachedManifest(mpq).isEmpty()); + } + + @Test + public void cachedImportUpdatesAndDeletesOnlyChangedFiles() throws Exception { + tempDir = Files.createTempDirectory("wurst-import-cache"); + Path imports = Files.createDirectories(tempDir.resolve("imports").resolve("nested")); + Path source = Files.writeString(imports.resolve("unit.txt"), "abc", StandardCharsets.UTF_8); + FakeMpqEditor mpq = new FakeMpqEditor(); + + ImportFile.ImportResult first = ImportFile.importFilesFromImports(tempDir.toFile(), mpq); + assertEquals(first.filesProcessed, 1); + assertEquals(first.filesUpdated, 1); + assertEquals(first.filesDeleted, 0); + assertFalse(first.cacheUsed); + assertTrue(mpq.hasFile("nested\\unit.txt")); + assertTrue(mpq.hasFile(IMP.GAME_PATH)); + assertTrue(mpq.hasFile(MANIFEST_PATH)); + + ImportFile.ImportResult cached = ImportFile.importFilesFromImports(tempDir.toFile(), mpq); + assertEquals(cached.filesProcessed, 1); + assertEquals(cached.filesUpdated, 0); + assertEquals(cached.filesDeleted, 0); + assertTrue(cached.cacheUsed); + + Files.delete(source); + ImportFile.ImportResult deleted = ImportFile.importFilesFromImports(tempDir.toFile(), mpq); + assertEquals(deleted.filesProcessed, 0); + assertEquals(deleted.filesUpdated, 0); + assertEquals(deleted.filesDeleted, 1); + assertFalse(deleted.cacheUsed); + assertFalse(mpq.hasFile("nested\\unit.txt")); + } + + private static final class FakeMpqEditor implements MpqEditor { + private final Map files = new HashMap<>(); + + @Override + public boolean canWrite() { + return true; + } + + @Override + public byte[] extractFile(String fileToExtract) throws IOException { + byte[] result = files.get(fileToExtract); + if (result == null) { + throw new IOException("Missing " + fileToExtract); + } + return result; + } + + @Override + public void insertFile(String filenameInMpq, byte[] contents) { + files.put(filenameInMpq, contents); + } + + @Override + public void insertFile(String filenameInMpq, File contents) throws IOException { + files.put(filenameInMpq, Files.readAllBytes(contents.toPath())); + } + + @Override + public void deleteFile(String filenameInMpq) { + files.remove(filenameInMpq); + } + + @Override + public boolean hasFile(String fileName) { + return files.containsKey(fileName); + } + + @Override + public void setKeepHeaderOffset(boolean flag) { + } + + @Override + public void closeWithCompression() { + } + + @Override + public void close() { + } + } +} diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ChecksumsTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ChecksumsTests.java new file mode 100644 index 000000000..e0bdfe3e3 --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ChecksumsTests.java @@ -0,0 +1,56 @@ +package tests.wurstscript.tests; + +import de.peeeq.wurstio.Checksums; +import de.peeeq.wurstio.utils.FileUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.expectThrows; + +public class ChecksumsTests { + private Path tempDir; + + @AfterMethod(alwaysRun = true) + public void cleanup() throws IOException { + if (tempDir != null) { + FileUtils.deleteRecursively(tempDir.toFile()); + } + } + + @Test + public void bytesToHexUsesLowercaseAndPreservesLeadingZeroes() { + assertEquals(Checksums.bytesToHex(new byte[]{0, 15, 16, -1}), "000f10ff"); + } + + @Test + public void mainWritesChecksumsForNestedFiles() throws IOException { + tempDir = Files.createTempDirectory("wurst-checksums"); + Path input = Files.createDirectory(tempDir.resolve("input")); + Path nested = Files.createDirectory(input.resolve("nested")); + Files.write(nested.resolve("empty.bin"), new byte[0]); + Files.writeString(input.resolve("a.txt"), "abc", StandardCharsets.UTF_8); + Path output = tempDir.resolve("checksums.md5"); + + Checksums.main(new String[]{input.toString(), output.toString()}); + + String result = Files.readString(output, StandardCharsets.UTF_8); + assertEquals(result, + "/a.txt\n" + + "900150983cd24fb0d6963f7d28e17f72\n" + + "/nested/empty.bin\n" + + "d41d8cd98f00b204e9800998ecf8427e\n"); + } + + @Test + public void mainRejectsWrongArgumentCount() { + RuntimeException error = expectThrows(RuntimeException.class, + () -> Checksums.main(new String[]{"only-one-argument"})); + assertEquals(error.getMessage(), "usage: 1. folder, 2. output file"); + } +}