From 1f8e7b7cf5c24c24b15acf71fc230c2e1c11a01c Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Fri, 22 May 2026 00:19:23 +0530 Subject: [PATCH 1/4] Coverage paint fails for files containing extended ascii characters --- .../metrics/source/SourceCodePainter.java | 15 ++++++- .../metrics/source/SourceCodePainterTest.java | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java index eac03337..dc070135 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java @@ -12,7 +12,10 @@ import java.io.File; import java.io.IOException; import java.io.Serial; +import java.io.InputStreamReader; +import java.nio.charset.CodingErrorAction; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -211,8 +214,8 @@ private int paint(final CoverageSourcePrinter paint, final String relativePathId try { Path paintedFilesFolder = Files.createTempDirectory(temporaryFolder, directory); var fullSourcePath = paintedFilesFolder.resolve(sanitizedFileName); - try (BufferedWriter output = Files.newBufferedWriter(fullSourcePath)) { - List lines = Files.readAllLines(Path.of(resolvedPath.getRemote()), charset); + try (BufferedWriter output = Files.newBufferedWriter(fullSourcePath, StandardCharsets.UTF_8)) { + List lines = readSourceLines(Path.of(resolvedPath.getRemote()), charset); // added a header to display what is being shown in each column output.write(paint.getColumnHeader()); @@ -231,6 +234,14 @@ private int paint(final CoverageSourcePrinter paint, final String relativePathId } } + private List readSourceLines(final Path sourcePath, final Charset charset) throws IOException { + try (var reader = new java.io.BufferedReader(new InputStreamReader(Files.newInputStream(sourcePath), + charset.newDecoder().onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE)))) { + return reader.lines().collect(Collectors.toList()); + } + } + private Optional findSourceFile(final FilePath workspace, final String fileName, final FilteredLog log) { try { diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java new file mode 100644 index 00000000..c14d31cf --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java @@ -0,0 +1,42 @@ +package io.jenkins.plugins.coverage.metrics.source; + +import org.junit.jupiter.api.Test; + +import edu.hm.hafner.coverage.FileNode; +import edu.hm.hafner.util.FilteredLog; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +/** + * Verifies that source painting handles files with extended ASCII characters. + * + * @author Akash Manna + */ +class SourceCodePainterTest { + private static final Charset WINDOWS_1252 = Charset.forName("windows-1252"); + + @Test + void shouldPaintSourceFilesWithExtendedAsciiCharacters() throws IOException, InterruptedException { + Path workspace = Files.createTempDirectory("source-painter"); + Path sourceFile = workspace.resolve("Example.m"); + Files.write(sourceFile, List.of( + "function y = example()", + "% Copyright 2026, Caf\u00e9 Corporation", + "y = 1;"), WINDOWS_1252); + + var painter = new SourceCodePainter.AgentCoveragePainter( + List.of(new CoverageSourcePrinter(new FileNode("", "Example.m"))), "", "coverage"); + + FilteredLog log = painter.invoke(workspace.toFile(), null); + + assertThat(log.getErrorMessages()).isEmpty(); + assertThat(log.getInfoMessages()).contains("-> finished painting successfully"); + assertThat(workspace.resolve(SourceCodeFacade.COVERAGE_SOURCES_ZIP)).exists(); + } +} \ No newline at end of file From c7652d87d21138db28a1aa42a2ced56e00541645 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Tue, 2 Jun 2026 13:22:49 +0530 Subject: [PATCH 2/4] Addressed few requested changes --- .../metrics/source/SourceCodePainter.java | 3 +- .../metrics/source/SourceCodePainterTest.java | 94 ++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java index dc070135..3ad78548 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainter.java @@ -8,6 +8,7 @@ import edu.hm.hafner.util.FilteredLog; import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.IOException; @@ -235,7 +236,7 @@ private int paint(final CoverageSourcePrinter paint, final String relativePathId } private List readSourceLines(final Path sourcePath, final Charset charset) throws IOException { - try (var reader = new java.io.BufferedReader(new InputStreamReader(Files.newInputStream(sourcePath), + try (var reader = new BufferedReader(new InputStreamReader(Files.newInputStream(sourcePath), charset.newDecoder().onMalformedInput(CodingErrorAction.REPLACE) .onUnmappableCharacter(CodingErrorAction.REPLACE)))) { return reader.lines().collect(Collectors.toList()); diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java index c14d31cf..7c9d66f6 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java @@ -5,11 +5,15 @@ import edu.hm.hafner.coverage.FileNode; import edu.hm.hafner.util.FilteredLog; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import static org.assertj.core.api.Assertions.*; @@ -21,22 +25,106 @@ class SourceCodePainterTest { private static final Charset WINDOWS_1252 = Charset.forName("windows-1252"); + private static final byte[] CAFE_NBSP_CORPORATION_UTF8 = { + (byte) 'C', (byte) 'a', (byte) 'f', + (byte) 0xC3, (byte) 0xA9, + (byte) 0xC2, (byte) 0xA0, + (byte) 'C', (byte) 'o', (byte) 'r', (byte) 'p', + (byte) 'o', (byte) 'r', (byte) 'a', (byte) 't', (byte) 'i', + (byte) 'o', (byte) 'n' + }; + + private static final byte[] REPLACEMENT_CHAR_UTF8 = { + (byte) 0xEF, (byte) 0xBF, (byte) 0xBD + }; + @Test void shouldPaintSourceFilesWithExtendedAsciiCharacters() throws IOException, InterruptedException { Path workspace = Files.createTempDirectory("source-painter"); Path sourceFile = workspace.resolve("Example.m"); + Files.write(sourceFile, List.of( "function y = example()", - "% Copyright 2026, Caf\u00e9 Corporation", + "% Copyright 2026, Café Corporation", "y = 1;"), WINDOWS_1252); var painter = new SourceCodePainter.AgentCoveragePainter( - List.of(new CoverageSourcePrinter(new FileNode("", "Example.m"))), "", "coverage"); + List.of(new CoverageSourcePrinter(new FileNode("", "Example.m"))), + "windows-1252", + "coverage"); FilteredLog log = painter.invoke(workspace.toFile(), null); assertThat(log.getErrorMessages()).isEmpty(); assertThat(log.getInfoMessages()).contains("-> finished painting successfully"); - assertThat(workspace.resolve(SourceCodeFacade.COVERAGE_SOURCES_ZIP)).exists(); + + Path outerZipPath = workspace.resolve(SourceCodeFacade.COVERAGE_SOURCES_ZIP); + assertThat(outerZipPath).exists(); + + byte[] paintedBytes = readPaintedBytesFromNestedZip(outerZipPath); + + assertThat(containsBytes(paintedBytes, CAFE_NBSP_CORPORATION_UTF8)) + .as("Painted HTML must contain UTF-8 bytes for 'Cafe-acute NBSP Corporation' " + + "(43 61 66 C3 A9 C2 A0 43 6F 72 70 6F 72 61 74 69 6F 6E). " + + "Actual painted bytes: " + toHex(paintedBytes)) + .isTrue(); + + assertThat(containsBytes(paintedBytes, REPLACEMENT_CHAR_UTF8)) + .as("Painted HTML must NOT contain UTF-8 replacement character EF BF BD " + + "— that would mean 0xE9 was not decoded correctly as windows-1252") + .isFalse(); + } + + private boolean containsBytes(final byte[] haystack, final byte[] needle) { + outer: + for (int i = 0; i <= haystack.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + return false; + } + + private String toHex(final byte[] bytes) { + var sb = new StringBuilder(); + for (byte b : bytes) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(String.format("%02X", b & 0xFF)); + } + return sb.toString(); + } + + private byte[] readPaintedBytesFromNestedZip(final Path outerZipPath) throws IOException { + try (var outerZip = new ZipInputStream(Files.newInputStream(outerZipPath))) { + ZipEntry outerEntry; + while ((outerEntry = outerZip.getNextEntry()) != null) { + if (outerEntry.getName().endsWith(SourceCodeFacade.ZIP_FILE_EXTENSION)) { + byte[] innerZipBytes = outerZip.readAllBytes(); + byte[] content = extractBytesFromZip(innerZipBytes); + if (content.length > 0) { + return content; + } + } + } + } + return new byte[0]; + } + + private byte[] extractBytesFromZip(final byte[] zipBytes) throws IOException { + var out = new ByteArrayOutputStream(); + try (var innerZip = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = innerZip.getNextEntry()) != null) { + if (!entry.isDirectory()) { + out.write(innerZip.readAllBytes()); + } + } + } + return out.toByteArray(); } } \ No newline at end of file From 9e5ddb8bf43ad213887ca9b2bbdeacba50b4d743 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Tue, 2 Jun 2026 13:46:46 +0530 Subject: [PATCH 3/4] Fix many PMD and CheckStyle warnings --- .../metrics/source/SourceCodePainterTest.java | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java index 7c9d66f6..f652517f 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java @@ -15,7 +15,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; /** * Verifies that source painting handles files with extended ASCII characters. @@ -26,16 +26,16 @@ class SourceCodePainterTest { private static final Charset WINDOWS_1252 = Charset.forName("windows-1252"); private static final byte[] CAFE_NBSP_CORPORATION_UTF8 = { - (byte) 'C', (byte) 'a', (byte) 'f', - (byte) 0xC3, (byte) 0xA9, - (byte) 0xC2, (byte) 0xA0, - (byte) 'C', (byte) 'o', (byte) 'r', (byte) 'p', - (byte) 'o', (byte) 'r', (byte) 'a', (byte) 't', (byte) 'i', - (byte) 'o', (byte) 'n' + (byte) 'C', (byte) 'a', (byte) 'f', + (byte) 0xC3, (byte) 0xA9, + (byte) 0xC2, (byte) 0xA0, + (byte) 'C', (byte) 'o', (byte) 'r', (byte) 'p', + (byte) 'o', (byte) 'r', (byte) 'a', (byte) 't', + (byte) 'i', (byte) 'o', (byte) 'n' }; private static final byte[] REPLACEMENT_CHAR_UTF8 = { - (byte) 0xEF, (byte) 0xBF, (byte) 0xBD + (byte) 0xEF, (byte) 0xBF, (byte) 0xBD }; @Test @@ -76,55 +76,74 @@ void shouldPaintSourceFilesWithExtendedAsciiCharacters() throws IOException, Int } private boolean containsBytes(final byte[] haystack, final byte[] needle) { - outer: for (int i = 0; i <= haystack.length - needle.length; i++) { + boolean match = true; + for (int j = 0; j < needle.length; j++) { if (haystack[i + j] != needle[j]) { - continue outer; + match = false; + break; } } - return true; + + if (match) { + return true; + } } + return false; } private String toHex(final byte[] bytes) { var sb = new StringBuilder(); + for (byte b : bytes) { if (sb.length() > 0) { sb.append(' '); } + sb.append(String.format("%02X", b & 0xFF)); } + return sb.toString(); } private byte[] readPaintedBytesFromNestedZip(final Path outerZipPath) throws IOException { try (var outerZip = new ZipInputStream(Files.newInputStream(outerZipPath))) { - ZipEntry outerEntry; - while ((outerEntry = outerZip.getNextEntry()) != null) { + ZipEntry outerEntry = outerZip.getNextEntry(); + + while (outerEntry != null) { if (outerEntry.getName().endsWith(SourceCodeFacade.ZIP_FILE_EXTENSION)) { byte[] innerZipBytes = outerZip.readAllBytes(); byte[] content = extractBytesFromZip(innerZipBytes); + if (content.length > 0) { return content; } } + + outerEntry = outerZip.getNextEntry(); } } + return new byte[0]; } private byte[] extractBytesFromZip(final byte[] zipBytes) throws IOException { var out = new ByteArrayOutputStream(); + try (var innerZip = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { - ZipEntry entry; - while ((entry = innerZip.getNextEntry()) != null) { + ZipEntry entry = innerZip.getNextEntry(); + + while (entry != null) { if (!entry.isDirectory()) { out.write(innerZip.readAllBytes()); } + + entry = innerZip.getNextEntry(); } } + return out.toByteArray(); } } \ No newline at end of file From 8873152b8ec5b71e7880318e93da11663f38b7a9 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Tue, 2 Jun 2026 17:35:25 +0530 Subject: [PATCH 4/4] Add assertion to verify that extended ASCII characters are rendered correctly in painted source files --- .../metrics/source/SourceCodePainterTest.java | 59 +------------------ 1 file changed, 3 insertions(+), 56 deletions(-) diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java index f652517f..fc118959 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/source/SourceCodePainterTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -25,19 +26,6 @@ class SourceCodePainterTest { private static final Charset WINDOWS_1252 = Charset.forName("windows-1252"); - private static final byte[] CAFE_NBSP_CORPORATION_UTF8 = { - (byte) 'C', (byte) 'a', (byte) 'f', - (byte) 0xC3, (byte) 0xA9, - (byte) 0xC2, (byte) 0xA0, - (byte) 'C', (byte) 'o', (byte) 'r', (byte) 'p', - (byte) 'o', (byte) 'r', (byte) 'a', (byte) 't', - (byte) 'i', (byte) 'o', (byte) 'n' - }; - - private static final byte[] REPLACEMENT_CHAR_UTF8 = { - (byte) 0xEF, (byte) 0xBF, (byte) 0xBD - }; - @Test void shouldPaintSourceFilesWithExtendedAsciiCharacters() throws IOException, InterruptedException { Path workspace = Files.createTempDirectory("source-painter"); @@ -63,49 +51,8 @@ void shouldPaintSourceFilesWithExtendedAsciiCharacters() throws IOException, Int byte[] paintedBytes = readPaintedBytesFromNestedZip(outerZipPath); - assertThat(containsBytes(paintedBytes, CAFE_NBSP_CORPORATION_UTF8)) - .as("Painted HTML must contain UTF-8 bytes for 'Cafe-acute NBSP Corporation' " - + "(43 61 66 C3 A9 C2 A0 43 6F 72 70 6F 72 61 74 69 6F 6E). " - + "Actual painted bytes: " + toHex(paintedBytes)) - .isTrue(); - - assertThat(containsBytes(paintedBytes, REPLACEMENT_CHAR_UTF8)) - .as("Painted HTML must NOT contain UTF-8 replacement character EF BF BD " - + "— that would mean 0xE9 was not decoded correctly as windows-1252") - .isFalse(); - } - - private boolean containsBytes(final byte[] haystack, final byte[] needle) { - for (int i = 0; i <= haystack.length - needle.length; i++) { - boolean match = true; - - for (int j = 0; j < needle.length; j++) { - if (haystack[i + j] != needle[j]) { - match = false; - break; - } - } - - if (match) { - return true; - } - } - - return false; - } - - private String toHex(final byte[] bytes) { - var sb = new StringBuilder(); - - for (byte b : bytes) { - if (sb.length() > 0) { - sb.append(' '); - } - - sb.append(String.format("%02X", b & 0xFF)); - } - - return sb.toString(); + var renderedText = new String(paintedBytes, StandardCharsets.UTF_8).replace("\u00A0", " "); + assertThat(renderedText).contains("Copyright 2026, Café Corporation"); } private byte[] readPaintedBytesFromNestedZip(final Path outerZipPath) throws IOException {