Skip to content

Commit a5581e2

Browse files
edburnsCopilot
andauthored
Add reflection-based Jackson round-trip test for all generated types (#1509)
* Test generate RPC classes * fix: use grep -m 1 instead of grep | head -1 to avoid broken pipe The shell runs with 'set -e -o pipefail', so when head -1 closes the pipe early, grep exits with SIGPIPE (exit 141) and pipefail propagates that as the pipeline's exit status, failing the step. Using 'grep -m 1' makes grep stop after the first match itself, eliminating the broken pipe entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5522793 commit a5581e2

2 files changed

Lines changed: 173 additions & 4 deletions

File tree

.github/actions/java-test-report/action.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ runs:
6666
for file in ${{ inputs.report-path }}; do
6767
if [ -f "$file" ]; then
6868
CLASS=$(basename "$file" .xml | sed 's/TEST-//')
69-
T=$(grep -o 'tests="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g')
70-
F=$(grep -o 'failures="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g')
71-
E=$(grep -o 'errors="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g')
72-
TIME=$(grep -o 'time="[0-9.]*"' "$file" | head -1 | sed 's/[^0-9.]//g')
69+
T=$(grep -m 1 -o 'tests="[0-9]*"' "$file" | sed 's/[^0-9]//g')
70+
F=$(grep -m 1 -o 'failures="[0-9]*"' "$file" | sed 's/[^0-9]//g')
71+
E=$(grep -m 1 -o 'errors="[0-9]*"' "$file" | sed 's/[^0-9]//g')
72+
TIME=$(grep -m 1 -o 'time="[0-9.]*"' "$file" | sed 's/[^0-9.]//g')
7373
P=$((T - F - E))
7474
7575
STATUS="✅"
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.generated;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
import java.io.IOException;
10+
import java.net.URISyntaxException;
11+
import java.net.URL;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.util.ArrayList;
15+
import java.util.Collection;
16+
import java.util.List;
17+
18+
import org.junit.jupiter.api.DynamicTest;
19+
import org.junit.jupiter.api.TestFactory;
20+
21+
import com.fasterxml.jackson.databind.DeserializationFeature;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.fasterxml.jackson.databind.SerializationFeature;
24+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
25+
26+
/**
27+
* Reflection-based Jackson round-trip test for all generated types in the
28+
* {@code com.github.copilot.generated} and
29+
* {@code com.github.copilot.generated.rpc} packages.
30+
*
31+
* <p>
32+
* Records are deserialized from {@code {}} (empty JSON object) and
33+
* re-serialized to verify the Jackson annotations work. Enums have every
34+
* variant serialized and deserialized back via {@code @JsonValue} /
35+
* {@code @JsonCreator}.
36+
*
37+
* <p>
38+
* This test automatically discovers classes at runtime, so it never needs
39+
* updating when generated types are added or removed.
40+
*/
41+
class GeneratedTypesJacksonRoundTripTest {
42+
43+
private static final ObjectMapper MAPPER = createMapper();
44+
45+
private static final String[] GENERATED_PACKAGES = {"com.github.copilot.generated",
46+
"com.github.copilot.generated.rpc"};
47+
48+
private static ObjectMapper createMapper() {
49+
var mapper = new ObjectMapper();
50+
mapper.registerModule(new JavaTimeModule());
51+
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
52+
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
53+
return mapper;
54+
}
55+
56+
@TestFactory
57+
Collection<DynamicTest> roundTripAllGeneratedRecords() {
58+
List<DynamicTest> tests = new ArrayList<>();
59+
for (Class<?> cls : discoverGeneratedClasses()) {
60+
if (!cls.isRecord())
61+
continue;
62+
// Skip abstract/sealed event base class — it requires a "type" discriminator
63+
if (cls == SessionEvent.class)
64+
continue;
65+
tests.add(DynamicTest.dynamicTest("record round-trip: " + cls.getSimpleName(), () -> {
66+
// Deserialize from empty JSON — all fields will be null/default
67+
Object instance = MAPPER.readValue("{}", cls);
68+
assertNotNull(instance, "Deserialized instance should not be null for " + cls.getName());
69+
70+
// Serialize back to JSON
71+
String json = MAPPER.writeValueAsString(instance);
72+
assertNotNull(json, "Serialized JSON should not be null for " + cls.getName());
73+
74+
// Round-trip: deserialize the serialized output
75+
Object roundTripped = MAPPER.readValue(json, cls);
76+
assertEquals(instance, roundTripped, "Round-trip should produce equal instance for " + cls.getName());
77+
}));
78+
}
79+
assertFalse(tests.isEmpty(), "Should discover at least one generated record");
80+
return tests;
81+
}
82+
83+
@TestFactory
84+
Collection<DynamicTest> roundTripAllGeneratedEnums() {
85+
List<DynamicTest> tests = new ArrayList<>();
86+
for (Class<?> cls : discoverGeneratedClasses()) {
87+
if (!cls.isEnum())
88+
continue;
89+
tests.add(DynamicTest.dynamicTest("enum round-trip: " + cls.getSimpleName(), () -> {
90+
Object[] constants = cls.getEnumConstants();
91+
assertNotNull(constants, "Enum constants should not be null for " + cls.getName());
92+
assertTrue(constants.length > 0, "Enum should have at least one constant: " + cls.getName());
93+
94+
for (Object constant : constants) {
95+
// Serialize enum constant to JSON
96+
String json = MAPPER.writeValueAsString(constant);
97+
assertNotNull(json, "Serialized JSON should not be null for " + constant);
98+
99+
// Deserialize back
100+
Object deserialized = MAPPER.readValue(json, cls);
101+
assertEquals(constant, deserialized,
102+
"Round-trip should produce same enum constant for " + constant);
103+
}
104+
}));
105+
}
106+
assertFalse(tests.isEmpty(), "Should discover at least one generated enum");
107+
return tests;
108+
}
109+
110+
/**
111+
* Discovers all top-level classes in the generated packages by scanning
112+
* compiled {@code .class} files on disk. The packages
113+
* {@code com.github.copilot.generated} and
114+
* {@code com.github.copilot.generated.rpc} contain <em>only</em> generated
115+
* code, so every loadable top-level class is included.
116+
*/
117+
private static List<Class<?>> discoverGeneratedClasses() {
118+
List<Class<?>> result = new ArrayList<>();
119+
for (String pkg : GENERATED_PACKAGES) {
120+
result.addAll(findClassesInPackage(pkg));
121+
}
122+
return result;
123+
}
124+
125+
private static List<Class<?>> findClassesInPackage(String packageName) {
126+
List<Class<?>> classes = new ArrayList<>();
127+
128+
// Load a known anchor class from the target package, then derive the
129+
// compiled .class directory from its code-source location. This works
130+
// on both JDK 17 (where Class.getResource also works) and JDK 25
131+
// (where stricter JPMS encapsulation can make Class.getResource
132+
// return null for classes in named modules).
133+
String anchorName = packageName + ".AbortReason";
134+
Class<?> anchor;
135+
try {
136+
anchor = Class.forName(anchorName);
137+
} catch (ClassNotFoundException e) {
138+
fail("Anchor class not found: " + anchorName);
139+
return classes; // unreachable
140+
}
141+
142+
Path packageDir;
143+
try {
144+
URL codeSourceUrl = anchor.getProtectionDomain().getCodeSource().getLocation();
145+
assertNotNull(codeSourceUrl, "Could not determine code source for " + packageName);
146+
Path classesRoot = Path.of(codeSourceUrl.toURI());
147+
packageDir = classesRoot.resolve(packageName.replace('.', '/'));
148+
} catch (URISyntaxException e) {
149+
fail("Bad URI scanning " + packageName + ": " + e.getMessage());
150+
return classes; // unreachable
151+
}
152+
assertTrue(Files.isDirectory(packageDir), "Expected a directory at " + packageDir);
153+
154+
try (var files = Files.list(packageDir)) {
155+
files.filter(p -> p.toString().endsWith(".class")).map(p -> p.getFileName().toString())
156+
.filter(name -> !name.contains("$")).forEach(name -> {
157+
String className = packageName + '.' + name.substring(0, name.length() - 6);
158+
try {
159+
classes.add(Class.forName(className));
160+
} catch (ClassNotFoundException | NoClassDefFoundError e) {
161+
// Skip classes that can't be loaded
162+
}
163+
});
164+
} catch (IOException e) {
165+
fail("Failed to scan package " + packageName + ": " + e.getMessage());
166+
}
167+
return classes;
168+
}
169+
}

0 commit comments

Comments
 (0)