Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix coverage gate false positives and add missing tests
Skip classes with 0 total lines (interfaces, annotations, abstract
classes) in the per-class coverage gate and changed-class display,
fixing 113 false regressions caused by JaCoCo non-determinism for
non-concrete classes. Also exclude these from future baseline
generation.

Add tests for GraphQLList.equals()/hashCode() and for
CompletionStageOrderedSubscriber's cfExceptionUnwrap error path
(catch block in emptyInFlightQueueIfWeCan).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
andimarek and claude committed Mar 6, 2026
commit ac6274f852f3c4b5d328f3a12873e11d25b74a50
5 changes: 4 additions & 1 deletion .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ jobs:
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
coverage.classes[className] = counters;
// Skip classes with 0 total lines (interfaces, annotations, abstract classes)
if (counters.line.covered + counters.line.missed > 0) {
coverage.classes[className] = counters;
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ jobs:
// --- Per-class coverage deltas ---
const changedClasses = [];
for (const [cls, curr] of Object.entries(classCounters)) {
// Skip classes with 0 total lines (interfaces, annotations, abstract classes)
const totalLines = curr.line.covered + curr.line.missed;
if (totalLines === 0) continue;
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
const currLinePct = pct(curr.line.covered, curr.line.missed);
const baseLinePct = pct(base.line.covered, base.line.missed);
Expand All @@ -317,6 +320,9 @@ jobs:
}
// Also detect classes removed (in baseline but not in current)
for (const cls of Object.keys(baseClasses)) {
// Skip baseline classes with 0 total lines (interfaces, annotations)
const baseTotalLines = baseClasses[cls].line.covered + baseClasses[cls].line.missed;
if (baseTotalLines === 0) continue;
if (!classCounters[cls]) {
changedClasses.push({
name: cls,
Expand Down Expand Up @@ -394,6 +400,9 @@ jobs:
// --- Coverage gate: fail if any class regresses on any metric ---
const regressions = [];
for (const [cls, curr] of Object.entries(classCounters)) {
// Skip classes with 0 total lines (interfaces, annotations, abstract classes)
const totalLines = curr.line.covered + curr.line.missed;
if (totalLines === 0) continue;
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
for (const [metric, key] of [['Line', 'line'], ['Branch', 'branch'], ['Method', 'method']]) {
const currPct = pct(curr[key].covered, curr[key].missed);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import graphql.execution.pubsub.CapturingSubscription
import org.reactivestreams.Subscriber

import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.concurrent.CompletionStage
import java.util.function.BiConsumer
import java.util.function.BiFunction
import java.util.function.Consumer
import java.util.function.Function

class CompletionStageOrderedSubscriberTest extends CompletionStageSubscriberTest {
Expand All @@ -19,4 +23,86 @@ class CompletionStageOrderedSubscriberTest extends CompletionStageSubscriberTest
protected ArrayList<String> expectedOrderingOfAsyncCompletion() {
return ["0", "1", "2", "3"]
}

/**
* A CompletableFuture that is already completed exceptionally but suppresses whenComplete
* callbacks. This simulates the race condition where a CF fails but its whenComplete callback
* has not yet run, so another CF's callback discovers the failure during queue drain via cf.join().
*/
static class SilentlyFailedFuture<T> extends CompletableFuture<T> {
SilentlyFailedFuture(Throwable ex) {
super.completeExceptionally(ex)
}

@Override
CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action) {
// Return a new never-completing future instead of registering the callback.
// This simulates the race: the CF is done but its callback hasn't fired yet.
return new CompletableFuture<>()
}
}

def "handles exceptionally completed CF discovered during ordered queue drain (cfExceptionUnwrap)"() {
given: "a subscriber with a mapper that returns a silently-failed CF for item 0 and a normal CF for item 1"
def capturingSubscriber = new CapturingSubscriber<>()
def subscription = new CapturingSubscription()

def originalException = new RuntimeException("boom")
// Item 0: already failed, but whenComplete callback is suppressed
def silentlyFailed = new SilentlyFailedFuture<String>(originalException)
// Item 1: completes normally and immediately
def normalCf = CompletableFuture.completedFuture("one")

int callCount = 0
Function<Integer, CompletionStage<String>> mapper = { Integer i ->
if (callCount++ == 0) return silentlyFailed
return normalCf
}

def subscriber = new CompletionStageOrderedSubscriber<Integer, String>(mapper, capturingSubscriber)

when: "we subscribe and send two items"
subscriber.onSubscribe(subscription)
// Item 0: silently-failed CF is added to the queue; its whenComplete does nothing
subscriber.onNext(0)
// Item 1: normal CF completes immediately; its whenComplete fires and calls
// emptyInFlightQueueIfWeCan, which drains from the head and hits silentlyFailed.join()
subscriber.onNext(1)

then: "the downstream subscriber receives onError with the unwrapped original exception"
capturingSubscriber.isCompletedExceptionally()
// cfExceptionUnwrap should unwrap the CompletionException to the original cause
capturingSubscriber.throwable == originalException
capturingSubscriber.events == []
}

def "handles exceptionally completed CF when exception is not a CompletionException"() {
given: "a subscriber where item 0 fails with a plain RuntimeException (not CompletionException)"
def capturingSubscriber = new CapturingSubscriber<>()
def subscription = new CapturingSubscription()

// Use completeExceptionally with a CompletionException that has no cause,
// so cfExceptionUnwrap returns it as-is
def exceptionWithoutCause = new CompletionException("no cause", null)
def silentlyFailed = new SilentlyFailedFuture<String>(exceptionWithoutCause)
def normalCf = CompletableFuture.completedFuture("one")

int callCount = 0
Function<Integer, CompletionStage<String>> mapper = { Integer i ->
if (callCount++ == 0) return silentlyFailed
return normalCf
}

def subscriber = new CompletionStageOrderedSubscriber<Integer, String>(mapper, capturingSubscriber)

when:
subscriber.onSubscribe(subscription)
subscriber.onNext(0)
subscriber.onNext(1)

then: "CompletionException without a cause is passed through (wrapped in another CompletionException by join)"
capturingSubscriber.isCompletedExceptionally()
// join() wraps in CompletionException; cfExceptionUnwrap unwraps to the original CompletionException
capturingSubscriber.throwable == exceptionWithoutCause
}
}
20 changes: 20 additions & 0 deletions src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import spock.lang.Specification

import java.util.function.UnaryOperator

import static graphql.Scalars.GraphQLInt
import static graphql.Scalars.GraphQLString
import static graphql.StarWarsSchema.characterInterface
import static graphql.StarWarsSchema.droidType
Expand Down Expand Up @@ -638,6 +639,25 @@ class GraphQLSchemaTest extends Specification {

}

def "GraphQLList equals and hashCode use identity semantics"() {
when:
def list1 = new GraphQLList(GraphQLString)
def list2 = new GraphQLList(GraphQLString)
def list3 = new GraphQLList(GraphQLInt)

then: "same instance is equal to itself"
list1.equals(list1)
list1.hashCode() == list1.hashCode()

and: "different instances are not equal even with same wrapped type"
!list1.equals(list2)
!list1.equals(list3)

and: "null and other types are not equal"
!list1.equals(null)
!list1.equals("not a list")
}

def "additionalTypes can contain any type when building programmatically - not restricted to detached types"() {
given: "types that will be directly reachable from Query"
def simpleType = newObject()
Expand Down
Loading