|
| 1 | +/* |
| 2 | + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 3 | + * SPDX-License-Identifier: Apache-2.0 |
| 4 | + */ |
| 5 | + |
| 6 | +package software.amazon.smithy.diff.testrunner; |
| 7 | + |
| 8 | +import static java.lang.String.format; |
| 9 | + |
| 10 | +import java.nio.file.Path; |
| 11 | +import java.util.Arrays; |
| 12 | +import java.util.Collection; |
| 13 | +import java.util.Collections; |
| 14 | +import java.util.List; |
| 15 | +import java.util.Objects; |
| 16 | +import java.util.TreeSet; |
| 17 | +import java.util.regex.Matcher; |
| 18 | +import java.util.regex.Pattern; |
| 19 | +import java.util.stream.Collectors; |
| 20 | +import software.amazon.smithy.model.SourceLocation; |
| 21 | +import software.amazon.smithy.model.shapes.ShapeId; |
| 22 | +import software.amazon.smithy.model.validation.Severity; |
| 23 | +import software.amazon.smithy.model.validation.ValidationEvent; |
| 24 | +import software.amazon.smithy.utils.IoUtils; |
| 25 | + |
| 26 | +/** |
| 27 | + * Runs a single test case by loading corresponding models `a` and `b` and |
| 28 | + * ensuring the resulting events match the diff events stored in a `-----` |
| 29 | + * separated file. |
| 30 | + */ |
| 31 | +public final class SmithyDiffTestCase { |
| 32 | + private static final Pattern EVENT_PATTERN = Pattern.compile( |
| 33 | + "^\\[(?<severity>SUPPRESSED|NOTE|WARNING|DANGER|ERROR)] " |
| 34 | + + "(?<shape>[^ ]+): " |
| 35 | + + "?(?<message>.*) " |
| 36 | + + "\\| " |
| 37 | + + "(?<id>[^)]+)", |
| 38 | + Pattern.DOTALL); |
| 39 | + |
| 40 | + private final Path path; |
| 41 | + private final String name; |
| 42 | + private final List<ValidationEvent> expectedEvents; |
| 43 | + |
| 44 | + /** |
| 45 | + * @param path Parent path of where the model and event files are stored. |
| 46 | + * @param name Name of the test case |
| 47 | + * @param expectedEvents The expected diff events to encounter. |
| 48 | + */ |
| 49 | + public SmithyDiffTestCase( |
| 50 | + Path path, |
| 51 | + String name, |
| 52 | + List<ValidationEvent> expectedEvents |
| 53 | + ) { |
| 54 | + this.path = Objects.requireNonNull(path); |
| 55 | + this.name = Objects.requireNonNull(name); |
| 56 | + this.expectedEvents = Collections.unmodifiableList(expectedEvents); |
| 57 | + } |
| 58 | + |
| 59 | + /** |
| 60 | + * Creates a test case from a test case path and name. |
| 61 | + * |
| 62 | + * <p>The models and events file are expected to be stored in the same |
| 63 | + * directory as the model and events file are assumed to be named the same |
| 64 | + * barring the file extensions: `.a.(json|smithy)`, `.b.(json|smithy)`, |
| 65 | + * `.events`. |
| 66 | + * |
| 67 | + * <p>The accompanying events file is a `-----` separated list of event |
| 68 | + * strings, where each event is defined in the following format: |
| 69 | + * {@code [SEVERITY] shapeId message | EventId filename:line:column}. |
| 70 | + * A shapeId of "-" means that a specific shape is not targeted. |
| 71 | + * |
| 72 | + * @param path Parent path of where the model and event files are stored. |
| 73 | + * @param name Name of the test case |
| 74 | + * @return Returns the created test case. |
| 75 | + */ |
| 76 | + public static SmithyDiffTestCase from(Path path, String name) { |
| 77 | + List<ValidationEvent> expectedEvents = loadExpectedEvents(path, name); |
| 78 | + return new SmithyDiffTestCase(path, name, expectedEvents); |
| 79 | + } |
| 80 | + |
| 81 | + /** |
| 82 | + * Gets the parent path of the test case. |
| 83 | + * |
| 84 | + * @return parent path of the test case. |
| 85 | + */ |
| 86 | + public Path getPath() { |
| 87 | + return path; |
| 88 | + } |
| 89 | + |
| 90 | + /** |
| 91 | + * Gets the name of the test case. |
| 92 | + * |
| 93 | + * @return name of the test case. |
| 94 | + */ |
| 95 | + public String getName() { |
| 96 | + return name; |
| 97 | + } |
| 98 | + |
| 99 | + /** |
| 100 | + * Gets the expected validation events. |
| 101 | + * |
| 102 | + * @return Expected validation events. |
| 103 | + */ |
| 104 | + public List<ValidationEvent> getExpectedEvents() { |
| 105 | + return expectedEvents; |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * Creates a test case result from a list of model diff events. |
| 110 | + * |
| 111 | + * <p>The diff events encountered are compared against the expected |
| 112 | + * validation events. An actual event (A) is considered a match with an |
| 113 | + * expected event (E) if A and E target the same shape, have the same |
| 114 | + * severity, the eventId of A contains the eventId of E, and the message |
| 115 | + * of E starts with the suppression reason or message of A. |
| 116 | + * |
| 117 | + * @param actualEvents List of actual diff events. |
| 118 | + * @return Returns the created test case result. |
| 119 | + */ |
| 120 | + public Result createResult(List<ValidationEvent> actualEvents) { |
| 121 | + List<ValidationEvent> unmatchedEvents = expectedEvents.stream() |
| 122 | + .filter(expectedEvent -> actualEvents.stream() |
| 123 | + .noneMatch(actualEvent -> compareEvents(expectedEvent, actualEvent))) |
| 124 | + .collect(Collectors.toList()); |
| 125 | + |
| 126 | + List<ValidationEvent> extraEvents = actualEvents.stream() |
| 127 | + .filter(actualEvent -> expectedEvents.stream() |
| 128 | + .noneMatch(expectedEvent -> compareEvents(expectedEvent, actualEvent))) |
| 129 | + // Exclude suppressed events from needing to be defined as acceptable events. |
| 130 | + // However, these can still be defined as required events. |
| 131 | + .filter(event -> event.getSeverity() != Severity.SUPPRESSED) |
| 132 | + .collect(Collectors.toList()); |
| 133 | + |
| 134 | + return new SmithyDiffTestCase.Result(name, unmatchedEvents, extraEvents); |
| 135 | + } |
| 136 | + |
| 137 | + private static boolean compareEvents(ValidationEvent expected, ValidationEvent actual) { |
| 138 | + String normalizedActualMessage = normalizeMessage(actual.getMessage()); |
| 139 | + if (actual.getSuppressionReason().isPresent()) { |
| 140 | + normalizedActualMessage += " (" + actual.getSuppressionReason().get() + ")"; |
| 141 | + } |
| 142 | + normalizedActualMessage = normalizeMessage(normalizedActualMessage); |
| 143 | + |
| 144 | + String comparedMessage = normalizeMessage(expected.getMessage()); |
| 145 | + return expected.getSeverity() == actual.getSeverity() |
| 146 | + && actual.containsId(expected.getId()) |
| 147 | + && expected.getShapeId().equals(actual.getShapeId()) |
| 148 | + // Normalize new lines. |
| 149 | + && normalizedActualMessage.startsWith(comparedMessage); |
| 150 | + } |
| 151 | + |
| 152 | + // Newlines in persisted validation events are escaped. |
| 153 | + private static String normalizeMessage(String message) { |
| 154 | + return message.replace("\n", "\\n").replace("\r", "\\n"); |
| 155 | + } |
| 156 | + |
| 157 | + private static List<ValidationEvent> loadExpectedEvents(Path path, String name) { |
| 158 | + String fileName = path.resolve(name + SmithyDiffTestSuite.EVENTS).toString(); |
| 159 | + String contents = IoUtils.readUtf8File(fileName); |
| 160 | + return Arrays.stream(contents.split("-----")) |
| 161 | + .map(chunk -> chunk.trim()) |
| 162 | + .filter(chunk -> !chunk.isEmpty()) |
| 163 | + .map(chunk -> parseValidationEvent(chunk, fileName)) |
| 164 | + .collect(Collectors.toList()); |
| 165 | + } |
| 166 | + |
| 167 | + static ValidationEvent parseValidationEvent(String event, String fileName) { |
| 168 | + Matcher matcher = EVENT_PATTERN.matcher(event); |
| 169 | + if (!matcher.find()) { |
| 170 | + throw new IllegalArgumentException(format("Invalid validation event in file `%s`, the following event did " |
| 171 | + + "not match the expected regular expression `%s`: %s", |
| 172 | + fileName, EVENT_PATTERN.pattern(), event)); |
| 173 | + } |
| 174 | + |
| 175 | + // Construct a dummy source location since we don't validate it. |
| 176 | + SourceLocation location = new SourceLocation("/", 0, 0); |
| 177 | + |
| 178 | + ValidationEvent.Builder builder = ValidationEvent.builder() |
| 179 | + .severity(Severity.fromString(matcher.group("severity")).get()) |
| 180 | + .sourceLocation(location) |
| 181 | + .id(matcher.group("id")) |
| 182 | + .message(matcher.group("message")); |
| 183 | + |
| 184 | + // A shape ID of "-" means no shape. |
| 185 | + if (!matcher.group("shape").equals("-")) { |
| 186 | + builder.shapeId(ShapeId.from(matcher.group("shape"))); |
| 187 | + } |
| 188 | + |
| 189 | + return builder.build(); |
| 190 | + } |
| 191 | + |
| 192 | + /** |
| 193 | + * Output of diffing a model against a test case. |
| 194 | + */ |
| 195 | + public static final class Result { |
| 196 | + private final String name; |
| 197 | + private final Collection<ValidationEvent> unmatchedEvents; |
| 198 | + private final Collection<ValidationEvent> extraEvents; |
| 199 | + |
| 200 | + Result( |
| 201 | + String name, |
| 202 | + Collection<ValidationEvent> unmatchedEvents, |
| 203 | + Collection<ValidationEvent> extraEvents |
| 204 | + ) { |
| 205 | + this.name = name; |
| 206 | + this.unmatchedEvents = Collections.unmodifiableCollection(new TreeSet<>(unmatchedEvents)); |
| 207 | + this.extraEvents = Collections.unmodifiableCollection(new TreeSet<>(extraEvents)); |
| 208 | + } |
| 209 | + |
| 210 | + @Override |
| 211 | + public String toString() { |
| 212 | + StringBuilder builder = new StringBuilder(); |
| 213 | + |
| 214 | + builder |
| 215 | + .append("============================\n" |
| 216 | + + "Model Diff Validation Result\n" |
| 217 | + + "============================\n") |
| 218 | + .append(name) |
| 219 | + .append('\n'); |
| 220 | + |
| 221 | + if (!unmatchedEvents.isEmpty()) { |
| 222 | + builder.append("\nDid not match the following events\n" |
| 223 | + + "----------------------------------\n"); |
| 224 | + for (ValidationEvent event : unmatchedEvents) { |
| 225 | + builder.append(event.toString()).append("\n\n"); |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + if (!extraEvents.isEmpty()) { |
| 230 | + builder.append("\nEncountered unexpected events\n" |
| 231 | + + "-----------------------------\n"); |
| 232 | + for (ValidationEvent event : extraEvents) { |
| 233 | + builder.append(event.toString()).append("\n\n"); |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + return builder.toString(); |
| 238 | + } |
| 239 | + |
| 240 | + /** |
| 241 | + * @return Returns the name of the test case. |
| 242 | + */ |
| 243 | + public String getName() { |
| 244 | + return name; |
| 245 | + } |
| 246 | + |
| 247 | + /** |
| 248 | + * @return Returns the events that were expected but not encountered. |
| 249 | + */ |
| 250 | + public Collection<ValidationEvent> getUnmatchedEvents() { |
| 251 | + return unmatchedEvents; |
| 252 | + } |
| 253 | + |
| 254 | + /** |
| 255 | + * @return Returns the events that were encountered but not expected. |
| 256 | + */ |
| 257 | + public Collection<ValidationEvent> getExtraEvents() { |
| 258 | + return extraEvents; |
| 259 | + } |
| 260 | + |
| 261 | + /** |
| 262 | + * Checks if the result does not match expected results. |
| 263 | + * |
| 264 | + * @return True if there are extra or unmatched events. |
| 265 | + */ |
| 266 | + public boolean isInvalid() { |
| 267 | + return !unmatchedEvents.isEmpty() || !extraEvents.isEmpty(); |
| 268 | + } |
| 269 | + |
| 270 | + /** |
| 271 | + * Throws an exception if the result is invalid, otherwise returns the result. |
| 272 | + * |
| 273 | + * @return Returns the result if it is ok. |
| 274 | + * @throws Error if the result contains invalid events. |
| 275 | + */ |
| 276 | + public Result unwrap() { |
| 277 | + if (isInvalid()) { |
| 278 | + throw new Error(this); |
| 279 | + } |
| 280 | + |
| 281 | + return this; |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + /** |
| 286 | + * Thrown when errors are encountered while unwrapping a test case. |
| 287 | + */ |
| 288 | + public static final class Error extends RuntimeException { |
| 289 | + public final Result result; |
| 290 | + |
| 291 | + Error(Result result) { |
| 292 | + super(result.toString()); |
| 293 | + this.result = result; |
| 294 | + } |
| 295 | + } |
| 296 | +} |
0 commit comments