Skip to content

Commit 6d3ee86

Browse files
committed
feat: add spec conformance tests
Tests the implementation against the spec's official conformance test suite. The spec is included as git submodule to make versioning and updating easier. A handful of tests are failing. I opted to document them as known failures for now. Fixes for those should ship separately. Note that only tests of the `required` group are executed currently, because the library lacks a differentiation between strict and lenient parsing, which tests of the `recommended` group require. We should implement that later. Signed-off-by: nscuro <nscuro@protonmail.com>
1 parent 69192a5 commit 6d3ee86

6 files changed

Lines changed: 669 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1
2222
with:
2323
persist-credentials: false
24+
submodules: 'true'
2425

2526
- name: Set up JDK 8 and ${{ matrix.java-version }}
2627
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # tag=v5.7.0

‎.gitmodules‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "purl-spec"]
2+
path = purl-spec
3+
url = https://github.com/package-url/purl-spec.git

‎pom.xml‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@
216216
<artifactId>bnd-maven-plugin</artifactId>
217217
<version>${bnd.maven.plugin.version}</version>
218218
</plugin>
219+
<plugin>
220+
<groupId>org.codehaus.mojo</groupId>
221+
<artifactId>exec-maven-plugin</artifactId>
222+
<version>${exec.maven.plugin.version}</version>
223+
</plugin>
219224
<plugin>
220225
<groupId>org.apache.maven.plugins</groupId>
221226
<artifactId>maven-clean-plugin</artifactId>
@@ -614,6 +619,28 @@
614619
</activation>
615620
<build>
616621
<plugins>
622+
<plugin>
623+
<groupId>org.codehaus.mojo</groupId>
624+
<artifactId>exec-maven-plugin</artifactId>
625+
<executions>
626+
<execution>
627+
<id>git-submodule-update</id>
628+
<goals>
629+
<goal>exec</goal>
630+
</goals>
631+
<phase>initialize</phase>
632+
<configuration>
633+
<executable>git</executable>
634+
<arguments>
635+
<argument>submodule</argument>
636+
<argument>update</argument>
637+
<argument>--init</argument>
638+
<argument>--recursive</argument>
639+
</arguments>
640+
</configuration>
641+
</execution>
642+
</executions>
643+
</plugin>
617644
<plugin>
618645
<groupId>org.apache.maven.plugins</groupId>
619646
<artifactId>maven-enforcer-plugin</artifactId>
@@ -680,7 +707,6 @@
680707
<plugin>
681708
<groupId>org.codehaus.mojo</groupId>
682709
<artifactId>exec-maven-plugin</artifactId>
683-
<version>${exec.maven.plugin.version}</version>
684710
<executions>
685711
<execution>
686712
<id>run-benchmark</id>

‎purl-spec‎

Submodule purl-spec added at 65eeef8
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
/*
2+
* MIT License
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.github.packageurl;
23+
24+
import static com.github.packageurl.PackageURLBuilder.aPackageURL;
25+
import static org.junit.jupiter.api.Assertions.assertEquals;
26+
import static org.junit.jupiter.api.Assertions.assertThrows;
27+
import static org.junit.jupiter.api.Assertions.fail;
28+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
29+
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
30+
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
31+
32+
import java.io.IOException;
33+
import java.io.InputStream;
34+
import java.io.UncheckedIOException;
35+
import java.nio.charset.StandardCharsets;
36+
import java.nio.file.Files;
37+
import java.nio.file.Path;
38+
import java.nio.file.Paths;
39+
import java.util.ArrayList;
40+
import java.util.Collections;
41+
import java.util.HashSet;
42+
import java.util.LinkedHashMap;
43+
import java.util.List;
44+
import java.util.Map;
45+
import java.util.Set;
46+
import java.util.TreeMap;
47+
import java.util.stream.Collectors;
48+
import java.util.stream.Stream;
49+
import org.json.JSONArray;
50+
import org.json.JSONObject;
51+
import org.json.JSONTokener;
52+
import org.junit.jupiter.api.AfterAll;
53+
import org.junit.jupiter.api.DynamicNode;
54+
import org.junit.jupiter.api.TestFactory;
55+
56+
/**
57+
* Runs the conformance suite of the PURL specification.
58+
* <p>
59+
* Only the {@code required} test group is enforced.
60+
* {@code recommended} covers remediation of non-canonical input,
61+
* which this implementation does not offer as a distinct mode.
62+
* <p>
63+
* {@code required} tests that do not pass yet are listed in {@code purl-conformance-known-gaps.json}.
64+
* To regenerate the file, run:
65+
* <pre>mvn test -Dtest=PurlSpecConformanceTest -Dpurl.conformance.recordGaps=true</pre>
66+
*
67+
* @see <a href="https://github.com/package-url/purl-spec/blob/main/docs/tests/test-suite.md">Test suite</a>
68+
*/
69+
class PurlSpecConformanceTest {
70+
71+
private static final Path SPEC_DIR = Paths.get(System.getProperty("purl.spec.dir", "purl-spec"));
72+
private static final String KNOWN_GAPS_RESOURCE = "/purl-conformance-known-gaps.json";
73+
private static final Path KNOWN_GAPS_FILE =
74+
Paths.get("src", "test", "resources").resolve(KNOWN_GAPS_RESOURCE.substring(1));
75+
private static final String TEST_GROUP_REQUIRED = "required";
76+
private static final String[] PURL_COMPONENTS = {"type", "namespace", "name", "version", "subpath", "qualifiers"};
77+
private static final boolean RECORD_GAPS = Boolean.getBoolean("purl.conformance.recordGaps");
78+
private static final Set<String> KNOWN_GAP_KEYS = loadKnownGaps();
79+
private static final Map<String, JSONObject> RECORDED_GAPS = Collections.synchronizedMap(new TreeMap<>());
80+
81+
@TestFactory
82+
Stream<DynamicNode> conformance() throws IOException {
83+
if (!Files.isDirectory(SPEC_DIR)) {
84+
throw new IllegalStateException(
85+
"purl-spec not found at " + SPEC_DIR.toAbsolutePath() + "; run: git submodule update --init");
86+
}
87+
88+
final List<DynamicNode> containers;
89+
try (Stream<Path> paths = Files.walk(SPEC_DIR.resolve("tests"))) {
90+
containers = paths.filter(path -> path.toString().endsWith(".json"))
91+
.sorted()
92+
.map(PurlSpecConformanceTest::toContainer)
93+
.collect(Collectors.toList());
94+
}
95+
if (containers.isEmpty()) {
96+
throw new IllegalStateException("No fixtures below " + SPEC_DIR.resolve("tests"));
97+
}
98+
99+
return containers.stream();
100+
}
101+
102+
@AfterAll
103+
static void writeRecordedGaps() throws IOException {
104+
if (!RECORD_GAPS) {
105+
return;
106+
}
107+
108+
final JSONArray gaps = new JSONArray();
109+
for (JSONObject recorded : RECORDED_GAPS.values()) {
110+
gaps.put(new JSONObject()
111+
.put("test_type", recorded.optString("test_type"))
112+
.put("description", recorded.optString("description"))
113+
.put("input", normalizedInput(recorded)));
114+
}
115+
116+
if (!Files.isDirectory(KNOWN_GAPS_FILE.toAbsolutePath().getParent())) {
117+
throw new IOException("Expected test resources at "
118+
+ KNOWN_GAPS_FILE.toAbsolutePath().getParent() + "; run from the module directory");
119+
}
120+
Files.write(
121+
KNOWN_GAPS_FILE,
122+
(new JSONObject().put("gaps", gaps).toString(2) + "\n").getBytes(StandardCharsets.UTF_8));
123+
}
124+
125+
private static DynamicNode toContainer(Path file) {
126+
final JSONArray tests = readJson(file).optJSONArray("tests");
127+
final Map<String, List<DynamicNode>> byGroup = new TreeMap<>();
128+
129+
for (int i = 0; tests != null && i < tests.length(); i++) {
130+
final JSONObject test = tests.getJSONObject(i);
131+
final String group = test.optString("test_group", TEST_GROUP_REQUIRED);
132+
byGroup.computeIfAbsent(group, key -> new ArrayList<>())
133+
.add(dynamicTest(
134+
"[" + test.optString("test_type") + "] " + test.optString("description"),
135+
() -> execute(test, group)));
136+
}
137+
138+
final List<DynamicNode> groups = new ArrayList<>();
139+
for (Map.Entry<String, List<DynamicNode>> group : byGroup.entrySet()) {
140+
groups.add(dynamicContainer(group.getKey() + " (" + group.getValue().size() + ")", group.getValue()));
141+
}
142+
143+
return dynamicContainer(file.getFileName().toString(), groups);
144+
}
145+
146+
private static void execute(JSONObject test, String group) {
147+
assumeTrue(TEST_GROUP_REQUIRED.equals(group), "recommended group: no lenient parsing mode");
148+
149+
AssertionError failure = null;
150+
try {
151+
run(test);
152+
} catch (AssertionError e) {
153+
failure = e;
154+
} catch (RuntimeException e) {
155+
failure = new AssertionError(e);
156+
}
157+
158+
final String key = keyOf(test);
159+
if (RECORD_GAPS) {
160+
if (failure != null) {
161+
RECORDED_GAPS.put(key, test);
162+
}
163+
} else if (KNOWN_GAP_KEYS.contains(key)) {
164+
if (failure == null) {
165+
fail("Known gap now passes; remove it from " + KNOWN_GAPS_RESOURCE + ": " + key);
166+
}
167+
} else if (failure != null) {
168+
throw failure;
169+
}
170+
}
171+
172+
private static void run(JSONObject test) {
173+
final String type = test.optString("test_type");
174+
final boolean expectedFailure = test.optBoolean("expected_failure", false);
175+
176+
if ("build".equals(type)) {
177+
executeBuild(test, expectedFailure);
178+
} else if ("parse".equals(type) || "validate".equals(type)) {
179+
executeParse(test, expectedFailure, "validate".equals(type));
180+
} else {
181+
throw new IllegalStateException("Unsupported test type: " + type);
182+
}
183+
}
184+
185+
private static void executeParse(JSONObject test, boolean expectedFailure, boolean roundTrip) {
186+
final String input = test.getString("input");
187+
if (expectedFailure) {
188+
assertThrows(
189+
MalformedPackageURLException.class, () -> new PackageURL(input), test.optString("description"));
190+
return;
191+
}
192+
193+
final PackageURL purl;
194+
try {
195+
purl = new PackageURL(input);
196+
} catch (MalformedPackageURLException e) {
197+
throw new AssertionError("Failed to parse " + input, e);
198+
}
199+
200+
if (roundTrip) {
201+
assertEquals(test.getString("expected_output"), purl.canonicalize(), "canonical form");
202+
return;
203+
}
204+
205+
final JSONObject expected = test.getJSONObject("expected_output");
206+
assertEquals(optString(expected, "type"), purl.getType(), "type");
207+
assertEquals(optString(expected, "namespace"), purl.getNamespace(), "namespace");
208+
assertEquals(optString(expected, "name"), purl.getName(), "name");
209+
assertEquals(optString(expected, "version"), purl.getVersion(), "version");
210+
assertEquals(optString(expected, "subpath"), purl.getSubpath(), "subpath");
211+
assertEquals(qualifiersOf(expected), purl.getQualifiers(), "qualifiers");
212+
}
213+
214+
private static void executeBuild(JSONObject test, boolean expectedFailure) {
215+
JSONObject input = test.getJSONObject("input");
216+
if (expectedFailure) {
217+
assertThrows(MalformedPackageURLException.class, () -> build(input), test.optString("description"));
218+
return;
219+
}
220+
221+
try {
222+
assertEquals(test.getString("expected_output"), build(input).canonicalize(), "canonical form");
223+
} catch (MalformedPackageURLException e) {
224+
throw new AssertionError("Failed to build from " + input, e);
225+
}
226+
}
227+
228+
private static PackageURL build(JSONObject components) throws MalformedPackageURLException {
229+
PackageURLBuilder builder = aPackageURL()
230+
.withType(optString(components, "type"))
231+
.withNamespace(optString(components, "namespace"))
232+
.withName(optString(components, "name"))
233+
.withVersion(optString(components, "version"))
234+
.withSubpath(optString(components, "subpath"));
235+
236+
final JSONObject qualifiers = components.optJSONObject("qualifiers");
237+
if (qualifiers != null) {
238+
for (String key : sortedKeys(qualifiers)) {
239+
builder.withQualifier(key, optString(qualifiers, key));
240+
}
241+
}
242+
243+
return builder.build();
244+
}
245+
246+
private static Map<String, String> qualifiersOf(JSONObject components) {
247+
final JSONObject qualifiers = components.optJSONObject("qualifiers");
248+
if (qualifiers == null) {
249+
return Collections.emptyMap();
250+
}
251+
252+
final Map<String, String> result = new LinkedHashMap<>();
253+
for (String key : sortedKeys(qualifiers)) {
254+
String value = optString(qualifiers, key);
255+
if (value != null && !value.isEmpty()) {
256+
result.put(key, value);
257+
}
258+
}
259+
260+
return result;
261+
}
262+
263+
private static String keyOf(JSONObject test) {
264+
return test.optString("test_type") + " " + normalizedInput(test);
265+
}
266+
267+
private static Object normalizedInput(JSONObject test) {
268+
final Object input = test.get("input");
269+
if (!(input instanceof JSONObject)) {
270+
return input;
271+
}
272+
273+
final JSONObject components = (JSONObject) input;
274+
final JSONObject normalized = new JSONObject();
275+
for (String field : PURL_COMPONENTS) {
276+
normalized.put(field, normalizedComponent(components, field));
277+
}
278+
279+
return normalized;
280+
}
281+
282+
private static Object normalizedComponent(JSONObject components, String field) {
283+
if (!"qualifiers".equals(field)) {
284+
final String value = optString(components, field);
285+
return value != null ? value : JSONObject.NULL;
286+
}
287+
288+
final JSONObject qualifiers = components.optJSONObject(field);
289+
if (qualifiers == null) {
290+
return JSONObject.NULL;
291+
}
292+
293+
final JSONObject sorted = new JSONObject();
294+
for (String key : sortedKeys(qualifiers)) {
295+
sorted.put(key, optString(qualifiers, key));
296+
}
297+
298+
return sorted;
299+
}
300+
301+
private static List<String> sortedKeys(JSONObject object) {
302+
final List<String> keys = new ArrayList<>(object.keySet());
303+
Collections.sort(keys);
304+
return keys;
305+
}
306+
307+
private static String optString(JSONObject object, String field) {
308+
return object.isNull(field) ? null : object.optString(field, null);
309+
}
310+
311+
private static Set<String> loadKnownGaps() {
312+
final Set<String> keys = new HashSet<>();
313+
try (InputStream in = PurlSpecConformanceTest.class.getResourceAsStream(KNOWN_GAPS_RESOURCE)) {
314+
if (in == null) {
315+
return keys;
316+
}
317+
318+
final JSONArray gaps = new JSONObject(new JSONTokener(in)).optJSONArray("gaps");
319+
for (int i = 0; gaps != null && i < gaps.length(); i++) {
320+
keys.add(keyOf(gaps.getJSONObject(i)));
321+
}
322+
} catch (IOException e) {
323+
throw new UncheckedIOException(e);
324+
}
325+
326+
return keys;
327+
}
328+
329+
private static JSONObject readJson(Path file) {
330+
try (InputStream in = Files.newInputStream(file)) {
331+
return new JSONObject(new JSONTokener(in));
332+
} catch (IOException e) {
333+
throw new UncheckedIOException("Failed to read " + file, e);
334+
}
335+
}
336+
}

0 commit comments

Comments
 (0)