diff --git a/allure-okhttp3/README.md b/allure-okhttp3/README.md index 76b1ea8e..62dc34e9 100644 --- a/allure-okhttp3/README.md +++ b/allure-okhttp3/README.md @@ -8,7 +8,7 @@ Use this module when your tests or test clients use OkHttp and you want request, - Allure Java 3.x requires Java 17 or newer. - This module targets the OkHttp `okhttp3` API used by OkHttp 3, 4, and 5. -- The current build validates against OkHttp 5.4.0. +- The current build validates against OkHttp 5.5.0. ## Installation @@ -42,6 +42,21 @@ OkHttpClient client = new OkHttpClient.Builder() .build(); ``` +## Server-Sent Events + +OkHttp invokes server-sent event callbacks on reusable dispatcher threads. Use the Allure event-source factory so +steps and HTTP exchange attachments stay with the test or fixture that opens each source: + +```java +EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client); + +EventSource eventSource = eventSourceFactory.newEventSource(request, listener); +``` + +The returned factory is reusable. It captures context when `newEventSource` is called, so the client, factory, request, +and listener may all be constructed before the owning test or fixture starts. When no Allure test or fixture is +current, callbacks run without an Allure owner instead of inheriting stale context from an OkHttp dispatcher thread. + ## Report Output - Request method, URL, headers, and body when available. diff --git a/allure-okhttp3/build.gradle.kts b/allure-okhttp3/build.gradle.kts index 33f1c669..366b2574 100644 --- a/allure-okhttp3/build.gradle.kts +++ b/allure-okhttp3/build.gradle.kts @@ -5,6 +5,7 @@ val okhttpVersion = "5.5.0" dependencies { api(project(":allure-java-commons")) compileOnly("com.squareup.okhttp3:okhttp:$okhttpVersion") + compileOnly("com.squareup.okhttp3:okhttp-sse:$okhttpVersion") testImplementation("org.wiremock:wiremock") testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") testImplementation("com.squareup.okhttp3:okhttp-sse:$okhttpVersion") diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java new file mode 100644 index 00000000..e9348d86 --- /dev/null +++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.okhttp3; + +import io.qameta.allure.AllureThreadBinding; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; + +import java.util.Objects; + +/** + * Allure-aware server-sent event source factories for OkHttp. + */ +public final class AllureEventSources { + + private AllureEventSources() { + throw new IllegalStateException("Do not instance"); + } + + /** + * Creates a reusable event source factory that captures the current Allure test or fixture when each event source + * is opened. The captured context is restored for the HTTP interceptor and every listener callback. + * + *

The client, factory, request, and listener may be created before a test or fixture starts. Context is captured + * only when {@link EventSource.Factory#newEventSource(Request, EventSourceListener)} is called.

+ * + * @param client the OkHttp client + * @return the context-aware event source factory + */ + public static EventSource.Factory createFactory(final OkHttpClient client) { + final EventSource.Factory delegate = EventSources.createFactory(Objects.requireNonNull(client, "client")); + return (request, listener) -> { + final AllureOkHttp3Context context = AllureOkHttp3Context.capture(); + final Request contextRequest = Objects.requireNonNull(request, "request") + .newBuilder() + .tag(AllureOkHttp3Context.class, context) + .build(); + final EventSourceListener contextListener = new ContextEventSourceListener( + Objects.requireNonNull(listener, "listener"), + context + ); + return delegate.newEventSource(contextRequest, contextListener); + }; + } + + private static final class ContextEventSourceListener extends EventSourceListener { + + private final EventSourceListener delegate; + private final AllureOkHttp3Context context; + + private ContextEventSourceListener(final EventSourceListener delegate, final AllureOkHttp3Context context) { + this.delegate = delegate; + this.context = context; + } + + @Override + public void onOpen(final EventSource eventSource, final Response response) { + runInContext(() -> delegate.onOpen(eventSource, response)); + } + + @Override + public void onEvent(final EventSource eventSource, final String id, + final String type, final String data) { + runInContext(() -> delegate.onEvent(eventSource, id, type, data)); + } + + @Override + public void onClosed(final EventSource eventSource) { + runInContext(() -> delegate.onClosed(eventSource)); + } + + @Override + public void onFailure(final EventSource eventSource, final Throwable throwable, + final Response response) { + runInContext(() -> delegate.onFailure(eventSource, throwable, response)); + } + + private void runInContext(final Runnable callback) { + try (AllureThreadBinding ignored = context.bind()) { + callback.run(); + } + } + } +} diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java index 7dbecba5..5e8bd5a5 100644 --- a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java +++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java @@ -16,6 +16,7 @@ package io.qameta.allure.okhttp3; import io.qameta.allure.Allure; +import io.qameta.allure.AllureThreadBinding; import io.qameta.allure.http.HttpExchange; import io.qameta.allure.http.HttpExchangeBody; import io.qameta.allure.http.HttpExchangeError; @@ -63,13 +64,23 @@ public AllureOkHttp3 configureHttpExchange(final Consumer */ @Override public Response intercept(final Chain chain) throws IOException { + final Request request = chain.request(); + final AllureOkHttp3Context context = request.tag(AllureOkHttp3Context.class); + if (Objects.isNull(context)) { + return interceptInCurrentContext(chain, request); + } + try (AllureThreadBinding ignored = context.bind()) { + return interceptInCurrentContext(chain, request); + } + } + + private Response interceptInCurrentContext(final Chain chain, final Request request) throws IOException { // enrichment-only integration: pass the call through untouched when no executable is // running — no warnings, no request/response body buffering if (Allure.getLifecycle().getCurrentExecutableKey().isEmpty()) { - return chain.proceed(chain.request()); + return chain.proceed(request); } final long start = System.currentTimeMillis(); - final Request request = chain.request(); final HttpExchangeRequest.Builder requestBuilder = HttpExchangeRequest .builder(request.method(), request.url().toString()) .addHeaders(toNameValues(request.headers().toMultimap())); diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java new file mode 100644 index 00000000..e93eeb26 --- /dev/null +++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016-2026 Qameta Software Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.qameta.allure.okhttp3; + +import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.AllureThreadBinding; + +final class AllureOkHttp3Context { + + private static final AllureExternalKey NO_OWNER = AllureExternalKey.random(AllureOkHttp3Context.class); + + private final AllureLifecycle lifecycle; + private final AllureExternalKey owner; + + private AllureOkHttp3Context(final AllureLifecycle lifecycle, final AllureExternalKey owner) { + this.lifecycle = lifecycle; + this.owner = owner; + } + + static AllureOkHttp3Context capture() { + final AllureLifecycle lifecycle = Allure.getLifecycle(); + final AllureExternalKey owner = lifecycle.getCurrentRootKey().orElse(NO_OWNER); + return new AllureOkHttp3Context(lifecycle, owner); + } + + AllureThreadBinding bind() { + return lifecycle.bindDetached(owner); + } +} diff --git a/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java b/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java index bbea491c..8ca7e33b 100644 --- a/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java +++ b/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java @@ -16,7 +16,16 @@ package io.qameta.allure.okhttp3; import com.sun.net.httpserver.HttpServer; +import io.qameta.allure.Allure; +import io.qameta.allure.AllureExternalKey; +import io.qameta.allure.AllureLifecycle; +import io.qameta.allure.Description; +import io.qameta.allure.Issue; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.AllureResults; import io.qameta.allure.test.IsolatedLifecycle; +import okhttp3.Dispatcher; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.sse.EventSource; @@ -32,10 +41,15 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import static io.qameta.allure.test.RunUtils.runTests; import static io.qameta.allure.test.RunUtils.runWithinTestContext; import static org.assertj.core.api.Assertions.assertThat; @@ -51,6 +65,8 @@ class AllureOkHttp3SseTest { private static final List EVENTS = List.of("first", "second"); + private static final String FINITE_EVENT = "context event"; + private HttpServer server; private final CountDownLatch connectionHold = new CountDownLatch(1); @@ -74,6 +90,14 @@ void setUp() throws IOException { } } }); + server.createContext("/finite-sse", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + try (OutputStream os = exchange.getResponseBody()) { + os.write(("data: " + FINITE_EVENT + "\n\n").getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + }); server.start(); } @@ -129,4 +153,166 @@ public void onFailure(final EventSource eventSource, final Throwable t, assertThat(received).containsExactlyElementsOf(EVENTS); } + + /** + * Verifies that callbacks and HTTP exchanges from a reused OkHttp dispatcher thread report to the test that opens + * each event source, even when the shared client, factory, request, and listeners were all created earlier. + */ + @Description + @Issue("1036") + @Test + void shouldCaptureContextWhenOpeningEventSourceWithSharedObjects() { + final ExecutorService dispatcherExecutor = Executors.newSingleThreadExecutor(); + final OkHttpClient client = new OkHttpClient.Builder() + .dispatcher(new Dispatcher(dispatcherExecutor)) + .addInterceptor(new AllureOkHttp3()) + .build(); + final EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client); + final Request request = finiteSseRequest(); + final SseProbe firstProbe = new SseProbe("event received by first test"); + final SseProbe secondProbe = new SseProbe("event received by second test"); + + try { + final AllureResults results = runTests(lifecycle -> { + final AllureExternalKey firstTestKey = scheduleTest(lifecycle, "first SSE test"); + final AllureExternalKey secondTestKey = scheduleTest(lifecycle, "second SSE test"); + + lifecycle.startTest(firstTestKey); + receiveEvent(eventSourceFactory, request, firstProbe); + lifecycle.stopTest(firstTestKey); + + lifecycle.startTest(secondTestKey); + receiveEvent(eventSourceFactory, request, secondProbe); + lifecycle.stopTest(secondTestKey); + + lifecycle.writeTest(firstTestKey); + lifecycle.writeTest(secondTestKey); + }); + + final TestResult firstTest = results.getTestResultByName("first SSE test"); + final TestResult secondTest = results.getTestResultByName("second SSE test"); + + assertThat(firstTest.getSteps()) + .extracting(StepResult::getName) + .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep) + .containsExactly("HTTP exchange", "event received by first test"); + assertThat(secondTest.getSteps()) + .extracting(StepResult::getName) + .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep) + .containsExactly("HTTP exchange", "event received by second test"); + } finally { + dispatcherExecutor.shutdownNow(); + } + } + + /** + * Verifies that an event source opened without a running Allure test or fixture cannot reuse context retained by + * an OkHttp dispatcher thread from an earlier request. + */ + @Description + @Issue("1036") + @Test + void shouldSuppressStaleDispatcherContextForUnownedEventSource() { + final ExecutorService dispatcherExecutor = Executors.newSingleThreadExecutor(); + final OkHttpClient client = new OkHttpClient.Builder() + .dispatcher(new Dispatcher(dispatcherExecutor)) + .addInterceptor(new AllureOkHttp3()) + .build(); + final EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client); + final Request request = finiteSseRequest(); + final SseProbe ownedProbe = new SseProbe("event received by owned test"); + final SseProbe unownedProbe = new SseProbe("event received without owner"); + + try { + final AllureResults results = runTests(lifecycle -> { + final AllureExternalKey testKey = scheduleTest(lifecycle, "owning SSE test"); + + lifecycle.startTest(testKey); + receiveEvent(eventSourceFactory, request, ownedProbe); + lifecycle.stopTest(testKey); + + receiveEvent(eventSourceFactory, request, unownedProbe); + lifecycle.writeTest(testKey); + }); + + final TestResult owningTest = results.getTestResultByName("owning SSE test"); + + assertThat(owningTest.getSteps()) + .extracting(StepResult::getName) + .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep) + .containsExactly("HTTP exchange", "event received by owned test"); + } finally { + dispatcherExecutor.shutdownNow(); + } + } + + private static AllureExternalKey scheduleTest(final AllureLifecycle lifecycle, final String name) { + final AllureExternalKey key = AllureExternalKey.random(AllureOkHttp3SseTest.class); + lifecycle.scheduleTest(key, new TestResult().setUuid(UUID.randomUUID().toString()).setName(name)); + return key; + } + + private Request finiteSseRequest() { + return new Request.Builder() + .url("http://localhost:" + server.getAddress().getPort() + "/finite-sse") + .build(); + } + + private static void receiveEvent(final EventSource.Factory eventSourceFactory, final Request request, + final SseProbe probe) { + eventSourceFactory.newEventSource(request, probe); + probe.await(); + } + + private static boolean isSseEvidenceStep(final String name) { + return "HTTP exchange".equals(name) || name.startsWith("event received"); + } + + private static final class SseProbe extends EventSourceListener { + + private final String stepName; + private final CountDownLatch eventReceived = new CountDownLatch(1); + private final CountDownLatch eventSourceClosed = new CountDownLatch(1); + private final AtomicReference failure = new AtomicReference<>(); + + private SseProbe(final String stepName) { + this.stepName = stepName; + } + + @Override + public void onEvent(final EventSource eventSource, final String id, + final String type, final String data) { + Allure.step(stepName); + eventReceived.countDown(); + } + + @Override + public void onClosed(final EventSource eventSource) { + eventSourceClosed.countDown(); + } + + @Override + public void onFailure(final EventSource eventSource, final Throwable throwable, + final okhttp3.Response response) { + failure.set(throwable); + eventSourceClosed.countDown(); + } + + private void await() { + try { + assertThat(eventReceived.await(5, TimeUnit.SECONDS)) + .as("SSE event received") + .isTrue(); + assertThat(eventSourceClosed.await(5, TimeUnit.SECONDS)) + .as("SSE event source closed") + .isTrue(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for the SSE callback", e); + } + assertThat(failure.get()) + .as("SSE callback failure") + .isNull(); + } + } } diff --git a/allure-okhttp3/src/test/resources/allure.properties b/allure-okhttp3/src/test/resources/allure.properties index a835d79e..b68f37af 100644 --- a/allure-okhttp3/src/test/resources/allure.properties +++ b/allure-okhttp3/src/test/resources/allure.properties @@ -1,3 +1,4 @@ allure.results.directory=build/allure-results +allure.link.issue.pattern=https://github.com/allure-framework/allure-java/issues/{} allure.label.epic=#project.description# allure.label.module=allure-okhttp3