diff --git a/.github/actions/java-test-report/action.yml b/.github/actions/java-test-report/action.yml index 7d610e6b6..e826628a0 100644 --- a/.github/actions/java-test-report/action.yml +++ b/.github/actions/java-test-report/action.yml @@ -66,10 +66,10 @@ runs: for file in ${{ inputs.report-path }}; do if [ -f "$file" ]; then CLASS=$(basename "$file" .xml | sed 's/TEST-//') - T=$(grep -o 'tests="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g') - F=$(grep -o 'failures="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g') - E=$(grep -o 'errors="[0-9]*"' "$file" | head -1 | sed 's/[^0-9]//g') - TIME=$(grep -o 'time="[0-9.]*"' "$file" | head -1 | sed 's/[^0-9.]//g') + T=$(grep -m 1 -o 'tests="[0-9]*"' "$file" | sed 's/[^0-9]//g') + F=$(grep -m 1 -o 'failures="[0-9]*"' "$file" | sed 's/[^0-9]//g') + E=$(grep -m 1 -o 'errors="[0-9]*"' "$file" | sed 's/[^0-9]//g') + TIME=$(grep -m 1 -o 'time="[0-9.]*"' "$file" | sed 's/[^0-9.]//g') P=$((T - F - E)) STATUS="✅" diff --git a/java/src/test/java/com/github/copilot/generated/GeneratedTypesJacksonRoundTripTest.java b/java/src/test/java/com/github/copilot/generated/GeneratedTypesJacksonRoundTripTest.java new file mode 100644 index 000000000..c882a3b09 --- /dev/null +++ b/java/src/test/java/com/github/copilot/generated/GeneratedTypesJacksonRoundTripTest.java @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.generated; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Reflection-based Jackson round-trip test for all generated types in the + * {@code com.github.copilot.generated} and + * {@code com.github.copilot.generated.rpc} packages. + * + *

+ * Records are deserialized from {@code {}} (empty JSON object) and + * re-serialized to verify the Jackson annotations work. Enums have every + * variant serialized and deserialized back via {@code @JsonValue} / + * {@code @JsonCreator}. + * + *

+ * This test automatically discovers classes at runtime, so it never needs + * updating when generated types are added or removed. + */ +class GeneratedTypesJacksonRoundTripTest { + + private static final ObjectMapper MAPPER = createMapper(); + + private static final String[] GENERATED_PACKAGES = {"com.github.copilot.generated", + "com.github.copilot.generated.rpc"}; + + private static ObjectMapper createMapper() { + var mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + return mapper; + } + + @TestFactory + Collection roundTripAllGeneratedRecords() { + List tests = new ArrayList<>(); + for (Class cls : discoverGeneratedClasses()) { + if (!cls.isRecord()) + continue; + // Skip abstract/sealed event base class — it requires a "type" discriminator + if (cls == SessionEvent.class) + continue; + tests.add(DynamicTest.dynamicTest("record round-trip: " + cls.getSimpleName(), () -> { + // Deserialize from empty JSON — all fields will be null/default + Object instance = MAPPER.readValue("{}", cls); + assertNotNull(instance, "Deserialized instance should not be null for " + cls.getName()); + + // Serialize back to JSON + String json = MAPPER.writeValueAsString(instance); + assertNotNull(json, "Serialized JSON should not be null for " + cls.getName()); + + // Round-trip: deserialize the serialized output + Object roundTripped = MAPPER.readValue(json, cls); + assertEquals(instance, roundTripped, "Round-trip should produce equal instance for " + cls.getName()); + })); + } + assertFalse(tests.isEmpty(), "Should discover at least one generated record"); + return tests; + } + + @TestFactory + Collection roundTripAllGeneratedEnums() { + List tests = new ArrayList<>(); + for (Class cls : discoverGeneratedClasses()) { + if (!cls.isEnum()) + continue; + tests.add(DynamicTest.dynamicTest("enum round-trip: " + cls.getSimpleName(), () -> { + Object[] constants = cls.getEnumConstants(); + assertNotNull(constants, "Enum constants should not be null for " + cls.getName()); + assertTrue(constants.length > 0, "Enum should have at least one constant: " + cls.getName()); + + for (Object constant : constants) { + // Serialize enum constant to JSON + String json = MAPPER.writeValueAsString(constant); + assertNotNull(json, "Serialized JSON should not be null for " + constant); + + // Deserialize back + Object deserialized = MAPPER.readValue(json, cls); + assertEquals(constant, deserialized, + "Round-trip should produce same enum constant for " + constant); + } + })); + } + assertFalse(tests.isEmpty(), "Should discover at least one generated enum"); + return tests; + } + + /** + * Discovers all top-level classes in the generated packages by scanning + * compiled {@code .class} files on disk. The packages + * {@code com.github.copilot.generated} and + * {@code com.github.copilot.generated.rpc} contain only generated + * code, so every loadable top-level class is included. + */ + private static List> discoverGeneratedClasses() { + List> result = new ArrayList<>(); + for (String pkg : GENERATED_PACKAGES) { + result.addAll(findClassesInPackage(pkg)); + } + return result; + } + + private static List> findClassesInPackage(String packageName) { + List> classes = new ArrayList<>(); + + // Load a known anchor class from the target package, then derive the + // compiled .class directory from its code-source location. This works + // on both JDK 17 (where Class.getResource also works) and JDK 25 + // (where stricter JPMS encapsulation can make Class.getResource + // return null for classes in named modules). + String anchorName = packageName + ".AbortReason"; + Class anchor; + try { + anchor = Class.forName(anchorName); + } catch (ClassNotFoundException e) { + fail("Anchor class not found: " + anchorName); + return classes; // unreachable + } + + Path packageDir; + try { + URL codeSourceUrl = anchor.getProtectionDomain().getCodeSource().getLocation(); + assertNotNull(codeSourceUrl, "Could not determine code source for " + packageName); + Path classesRoot = Path.of(codeSourceUrl.toURI()); + packageDir = classesRoot.resolve(packageName.replace('.', '/')); + } catch (URISyntaxException e) { + fail("Bad URI scanning " + packageName + ": " + e.getMessage()); + return classes; // unreachable + } + assertTrue(Files.isDirectory(packageDir), "Expected a directory at " + packageDir); + + try (var files = Files.list(packageDir)) { + files.filter(p -> p.toString().endsWith(".class")).map(p -> p.getFileName().toString()) + .filter(name -> !name.contains("$")).forEach(name -> { + String className = packageName + '.' + name.substring(0, name.length() - 6); + try { + classes.add(Class.forName(className)); + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Skip classes that can't be loaded + } + }); + } catch (IOException e) { + fail("Failed to scan package " + packageName + ": " + e.getMessage()); + } + return classes; + } +}