ReporterTest.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.cling.invoker.mvnval;
import java.nio.file.Path;
import java.util.List;
import org.apache.maven.api.services.BuilderProblem;
import org.apache.maven.api.services.ModelProblem;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for the two output formats.
* Tests that both formats derive a problem's location from its parts rather than from
* {@link ModelProblem#getLocation()}, and that the JSON document escapes what it must.
*/
@DisplayName("Reporter")
class ReporterTest {
private static final Path POM = Path.of("bad/pom.xml");
/** Text output names the POM on the first line and lists the problems under it. */
private static final int FIRST_PROBLEM_LINE = 1;
/** The problem line of a rendered text report, without the " SEVERITY message" prefix. */
private static String locationOf(ModelProblem problem) {
String line = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of(problem))))
.get(FIRST_PROBLEM_LINE);
int at = line.indexOf(" @ ");
return at < 0 ? "" : line.substring(at + 3);
}
@Test
@DisplayName("should not rely on getLocation, which the model builder leaves empty")
void shouldNotRelyOnGetLocation() {
ModelProblem problem = TestUtils.problem(BuilderProblem.Severity.ERROR, "boom", "bad/pom.xml", 13, 17);
assertEquals(
"",
problem.getLocation(),
"DefaultModelProblem hardcodes getLocation() to empty; the reporter must build it itself");
assertEquals("line 13, column 17", locationOf(problem), "minus the file already named on the line above");
}
@Test
@DisplayName("should leave out the parts of a location that are unknown")
void shouldLeaveOutUnknownLocationParts() {
assertEquals(
"",
locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "no place", null, -1, -1)),
"a problem with no location at all should add nothing");
assertEquals(
"",
locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "same file", "bad/pom.xml", 0, 0)),
"a source that is the file being reported on adds nothing");
assertEquals(
"line 7",
locationOf(TestUtils.problem(BuilderProblem.Severity.WARNING, "line only", null, 7, 0)),
"a problem with only a line should produce just the line");
}
@Test
@DisplayName("should name the source as a path when the problem comes from another file")
void shouldNameSourceOfAnotherFileAsPath() {
Path other = Path.of("other/pom.xml").toAbsolutePath().normalize();
ModelProblem problem = TestUtils.problem(
BuilderProblem.Severity.ERROR, "elsewhere", other.toUri().toString(), 4, 0);
assertEquals(
other + ", line 4",
locationOf(problem),
"the model builder reports the source as a file URL; the output uses paths");
}
@Test
@DisplayName("should indent the continuation lines of a multi-line message")
void shouldIndentContinuationLines() {
// A parser failure arrives as several lines; unindented, the tail would look like a
// separate entry rather than part of this problem.
ModelProblem problem = TestUtils.problem(
BuilderProblem.Severity.FATAL, "Non-parseable POM" + System.lineSeparator() + "at [1,19]", null, 0, 0);
List<String> lines = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of(problem))));
assertEquals(
" FATAL Non-parseable POM" + System.lineSeparator() + " at [1,19]", lines.get(FIRST_PROBLEM_LINE));
}
@Test
@DisplayName("should pass a source that is not a file through unchanged")
void shouldPassNonFileSourceThrough() {
// On Unix this parses as a relative path, and resolving it would name a file in the
// working directory that was never involved.
ModelProblem problem = TestUtils.problem(BuilderProblem.Severity.ERROR, "imported", "org.test:bom:1.0", 0, 0);
assertEquals("org.test:bom:1.0", Report.of(POM, List.of(problem)).sourceOf(problem));
}
@Nested
@DisplayName("Text")
class TextTests {
@Test
@DisplayName("should report a clean file on one line")
void shouldReportCleanFileOnOneLine() {
List<String> lines = TestUtils.render(OutputFormat.TEXT, List.of(Report.of(POM, List.of())));
assertEquals(List.of(POM + ": no problems"), lines);
}
@Test
@DisplayName("should append the location after the message")
void shouldAppendLocationAfterMessage() {
List<String> lines = TestUtils.render(
OutputFormat.TEXT,
List.of(Report.of(
POM,
List.of(TestUtils.problem(BuilderProblem.Severity.ERROR, "boom", "bad/pom.xml", 13, 17)))));
assertEquals(POM + ":", lines.get(0));
assertEquals(" ERROR boom @ line 13, column 17", lines.get(FIRST_PROBLEM_LINE));
}
@Test
@DisplayName("should omit the location separator when there is no location")
void shouldOmitLocationSeparatorWhenNoLocation() {
List<String> lines = TestUtils.render(
OutputFormat.TEXT,
List.of(Report.of(
POM,
List.of(TestUtils.problem(BuilderProblem.Severity.WARNING, "no place", null, -1, -1)))));
assertEquals(
" WARNING no place", lines.get(FIRST_PROBLEM_LINE), "no location means no trailing separator");
}
@Test
@DisplayName("should report a file that could not be read")
void shouldReportUnreadableFile() {
List<String> lines =
TestUtils.render(OutputFormat.TEXT, List.of(Report.failed(POM, "not a readable file")));
assertEquals(List.of(POM + ": not a readable file"), lines);
}
@Test
@DisplayName("should emit context lines around a problem when the source file exists")
void shouldEmitContextLines() throws Exception {
// Write a temporary POM with some content so the context reader can find the file.
java.nio.file.Path tmp = java.nio.file.Files.createTempFile("mvnval-test-", ".xml");
try {
java.nio.file.Files.writeString(tmp, "<project>\n <bad/>\n <also/>\n</project>\n");
// Problem on line 2, context=1 ��� lines 1���3 should appear.
List<String> lines = TestUtils.render(
OutputFormat.TEXT,
List.of(Report.of(
tmp,
List.of(TestUtils.problem(
BuilderProblem.Severity.ERROR, "bad element", tmp.toString(), 2, 3)))),
1);
// Verify the marker line is present.
assertTrue(
lines.stream().anyMatch(l -> l.contains("> ") && l.contains("<bad/>")),
"problem line must carry the > marker: " + lines);
// Verify the surrounding context line is present.
assertTrue(
lines.stream().anyMatch(l -> !l.contains("> ") && l.contains("<project>")),
"context line before the problem must appear: " + lines);
assertTrue(
lines.stream().anyMatch(l -> !l.contains("> ") && l.contains("<also/>")),
"context line after the problem must appear: " + lines);
} finally {
java.nio.file.Files.deleteIfExists(tmp);
}
}
@Test
@DisplayName("should suppress context when --context 0 is used")
void shouldSuppressContextWhenZero() throws Exception {
java.nio.file.Path tmp = java.nio.file.Files.createTempFile("mvnval-test-", ".xml");
try {
java.nio.file.Files.writeString(tmp, "<project>\n <bad/>\n</project>\n");
List<String> lines = TestUtils.render(
OutputFormat.TEXT,
List.of(Report.of(
tmp,
List.of(TestUtils.problem(
BuilderProblem.Severity.ERROR, "bad element", tmp.toString(), 2, 1)))),
0);
// No gutter lines ��� only the header and the problem line itself.
assertTrue(lines.stream().noneMatch(l -> l.contains("|")), "no gutter lines when context=0: " + lines);
} finally {
java.nio.file.Files.deleteIfExists(tmp);
}
}
}
@Nested
@DisplayName("JSON")
class JsonTests {
@Test
@DisplayName("should emit one object per file in a single array")
void shouldEmitOneObjectPerFile() {
List<String> lines = TestUtils.render(
OutputFormat.JSON,
List.of(Report.of(Path.of("a.xml"), List.of()), Report.of(Path.of("b.xml"), List.of())));
assertEquals(1, lines.size(), "the whole document should be written as one line");
assertEquals("[{\"pom\":\"a.xml\",\"problems\":[]},{\"pom\":\"b.xml\",\"problems\":[]}]", lines.get(0));
}
@Test
@DisplayName("should leave out absent fields rather than emitting the -1 sentinel")
void shouldLeaveOutAbsentFields() {
String json = TestUtils.render(
OutputFormat.JSON,
List.of(Report.of(
POM,
List.of(TestUtils.problem(
BuilderProblem.Severity.WARNING, "no place", null, -1, -1)))))
.get(0);
assertFalse(json.contains("-1"), "the -1 the model builder uses for \"unknown\" must not leak: " + json);
assertFalse(json.contains("\"line\""), "an unknown line should be left out entirely: " + json);
assertFalse(json.contains("\"source\""), "an unknown source should be left out entirely: " + json);
assertFalse(json.contains("file:"), "a file URL must not reach the document: " + json);
assertTrue(json.contains("\"severity\":\"WARNING\""), json);
}
@Test
@DisplayName("should escape quotes, backslashes and control characters")
void shouldEscapeQuotesBackslashesAndControlCharacters() {
String json = TestUtils.render(
OutputFormat.JSON,
List.of(Report.of(
POM,
List.of(TestUtils.problem(
BuilderProblem.Severity.ERROR,
"say \"hi\"\nand\ttab\\end",
"bad/pom.xml",
1,
1)))))
.get(0);
assertTrue(json.contains("say \\\"hi\\\""), "quotes should be escaped: " + json);
assertTrue(json.contains("\\n"), "newlines should be escaped: " + json);
assertTrue(json.contains("\\t"), "tabs should be escaped: " + json);
assertTrue(json.contains("\\\\end"), "backslashes should be escaped: " + json);
}
@Test
@DisplayName("should escape everything outside printable ASCII")
void shouldEscapeEverythingOutsidePrintableAscii() {
// A lone high surrogate is reachable from a filename and no charset can encode it; a
// console that is not UTF-8 would mangle the accented letter. Both are escaped so the
// document survives whatever the writer's charset turns out to be.
String json = TestUtils.render(
OutputFormat.JSON,
List.of(Report.of(
POM,
List.of(TestUtils.problem(
BuilderProblem.Severity.ERROR, "\u0001 caf�� \ud83d", null, 1, 1)))))
.get(0);
assertTrue(json.contains("\\u0001"), "control characters should be escaped: " + json);
assertTrue(json.contains("\\u00e9"), "non-ASCII should be escaped: " + json);
assertTrue(json.contains("\\ud83d"), "an unpaired surrogate should be escaped: " + json);
assertTrue(json.chars().allMatch(c -> c >= 0x20 && c <= 0x7e), "the document should be plain ASCII");
}
@Test
@DisplayName("should record a file that could not be read as a failure")
void shouldRecordUnreadableFileAsFailure() {
String json = TestUtils.render(OutputFormat.JSON, List.of(Report.failed(POM, "not a readable file")))
.get(0);
assertTrue(json.contains("\"failure\":\"not a readable file\""), json);
}
}
}