From 267fc5aa508b63e3e44c141fa2d21d91b288576f Mon Sep 17 00:00:00 2001 From: Dmitry Baev Date: Fri, 8 May 2026 14:06:49 +0100 Subject: [PATCH] selenium bidi --- .idea/vcs.xml | 5 +- .../allure/test/AllureTestCommonsUtils.java | 35 +- allure-selenium-bidi/build.gradle.kts | 47 + .../seleniumbidi/AllureWebDriverBiDi.java | 182 ++++ .../seleniumbidi/BiDiAttachmentStorage.java | 140 +++ .../seleniumbidi/BiDiConfiguration.java | 83 ++ .../allure/seleniumbidi/BiDiJsonKeys.java | 32 + .../allure/seleniumbidi/BiDiLogEvent.java | 144 +++ .../allure/seleniumbidi/BiDiNetworkEvent.java | 196 ++++ .../seleniumbidi/BiDiSessionFactory.java | 29 + .../allure/seleniumbidi/BiDiSessionState.java | 97 ++ .../allure/seleniumbidi/HeaderRedactor.java | 127 +++ .../allure/seleniumbidi/RecordingSession.java | 22 + .../SeleniumBiDiSessionFactory.java | 91 ++ .../seleniumbidi/AllureWebDriverBiDiTest.java | 900 ++++++++++++++++++ .../src/test/resources/allure.properties | 3 + settings.gradle.kts | 1 + 17 files changed, 2127 insertions(+), 7 deletions(-) create mode 100644 allure-selenium-bidi/build.gradle.kts create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDi.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiAttachmentStorage.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiConfiguration.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiJsonKeys.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiLogEvent.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiNetworkEvent.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionFactory.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionState.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/HeaderRedactor.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/RecordingSession.java create mode 100644 allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/SeleniumBiDiSessionFactory.java create mode 100644 allure-selenium-bidi/src/test/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDiTest.java create mode 100644 allure-selenium-bidi/src/test/resources/allure.properties diff --git a/.idea/vcs.xml b/.idea/vcs.xml index aeaa9e459..95443a122 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -10,7 +10,4 @@ - - - - + \ No newline at end of file diff --git a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java index 6a00c5c4b..75fa00300 100644 --- a/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java +++ b/allure-java-commons-test/src/main/java/io/qameta/allure/test/AllureTestCommonsUtils.java @@ -41,6 +41,11 @@ */ public final class AllureTestCommonsUtils { + private static final String DOT = "."; + private static final String JSON_EXTENSION = "json"; + private static final String JSON_TYPE = "application/json"; + private static final String TEXT_EXTENSION = "txt"; + private static final String TEXT_TYPE = "text/plain"; private static final ObjectWriter WRITER = JsonMapper .builder() .configure(USE_WRAPPER_NAME_AS_PROPERTY_NAME, true) @@ -65,7 +70,9 @@ public static void attach(final AllureResults allureResults) { try { Allure.addAttachment( testResult.getUuid() + AllureConstants.TEST_RESULT_FILE_SUFFIX, - WRITER.writeValueAsString(testResult) + JSON_TYPE, + WRITER.writeValueAsString(testResult), + JSON_EXTENSION ); } catch (JsonProcessingException e) { throw new UncheckedIOException(e); @@ -76,7 +83,9 @@ public static void attach(final AllureResults allureResults) { try { Allure.addAttachment( container.getUuid() + AllureConstants.TEST_RESULT_CONTAINER_FILE_SUFFIX, - WRITER.writeValueAsString(container) + JSON_TYPE, + WRITER.writeValueAsString(container), + JSON_EXTENSION ); } catch (JsonProcessingException e) { throw new UncheckedIOException(e); @@ -86,11 +95,31 @@ public static void attach(final AllureResults allureResults) { allureResults.getAttachments().forEach((fileName, body) -> Allure .addAttachment( fileName, - new ByteArrayInputStream(body) + type(fileName), + new ByteArrayInputStream(body), + extension(fileName) ) ); } + private static String type(final String fileName) { + if (fileName.endsWith(DOT + JSON_EXTENSION)) { + return JSON_TYPE; + } + if (fileName.endsWith(DOT + TEXT_EXTENSION)) { + return TEXT_TYPE; + } + return null; + } + + private static String extension(final String fileName) { + final int index = fileName.lastIndexOf('.'); + if (index < 0 || index == fileName.length() - 1) { + return null; + } + return fileName.substring(index + 1); + } + /** * Parameter mode serializer. */ diff --git a/allure-selenium-bidi/build.gradle.kts b/allure-selenium-bidi/build.gradle.kts new file mode 100644 index 000000000..392d791ef --- /dev/null +++ b/allure-selenium-bidi/build.gradle.kts @@ -0,0 +1,47 @@ +description = "Allure Selenium WebDriver BiDi Integration" + +val agent: Configuration by configurations.creating + +val seleniumVersion = "4.23.0" +val testcontainersVersion = "1.21.4" + +dependencies { + agent("org.aspectj:aspectjweaver") + api(project(":allure-java-commons")) + compileOnly("org.seleniumhq.selenium:selenium-java:$seleniumVersion") + testAnnotationProcessor(project(":allure-descriptions-javadoc")) + testImplementation("org.seleniumhq.selenium:selenium-java:$seleniumVersion") + testImplementation("org.assertj:assertj-core") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.slf4j:slf4j-simple") + testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion") + testImplementation("org.testcontainers:testcontainers:$testcontainersVersion") + testImplementation(project(":allure-assertj")) + testImplementation(project(":allure-java-commons-test")) + testImplementation(project(":allure-junit-platform")) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks { + compileJava { + options.release.set(17) + } + compileTestJava { + options.release.set(17) + } + jar { + manifest { + attributes( + mapOf( + "Automatic-Module-Name" to "io.qameta.allure.seleniumbidi" + ) + ) + } + } + test { + useJUnitPlatform() + jvmArgs("-javaagent:${agent.singleFile}") + systemProperty("allure.model.indentOutput", "true") + systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDi.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDi.java new file mode 100644 index 000000000..264d44862 --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDi.java @@ -0,0 +1,182 @@ +/* + * 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.seleniumbidi; + +import io.qameta.allure.Allure; +import io.qameta.allure.AllureLifecycle; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.support.events.EventFiringDecorator; +import org.openqa.selenium.support.events.WebDriverListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Selenium WebDriver BiDi listener that captures browser log and network events + * as aggregated Allure attachments. + */ +@SuppressWarnings("unused") +public class AllureWebDriverBiDi implements WebDriverListener, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(AllureWebDriverBiDi.class); + + private final AllureLifecycle lifecycle; + private final BiDiSessionFactory sessionFactory; + private final BiDiConfiguration configuration = new BiDiConfiguration(); + private final Map sessions = new IdentityHashMap<>(); + private final Lock sessionsLock = new ReentrantLock(); + + public AllureWebDriverBiDi() { + this(Allure.getLifecycle(), new SeleniumBiDiSessionFactory()); + } + + AllureWebDriverBiDi(final AllureLifecycle lifecycle, + final BiDiSessionFactory sessionFactory) { + this.lifecycle = lifecycle; + this.sessionFactory = sessionFactory; + } + + public T decorate(final T driver) { + return new EventFiringDecorator(this).decorate(driver); + } + + public AllureWebDriverBiDi logs(final boolean enabled) { + configuration.setLogsEnabled(enabled); + return this; + } + + public AllureWebDriverBiDi network(final boolean enabled) { + configuration.setNetworkEnabled(enabled); + return this; + } + + public AllureWebDriverBiDi maxLogEntries(final int maxLogEntries) { + configuration.setMaxLogEntries(maxLogEntries); + return this; + } + + public AllureWebDriverBiDi maxNetworkEvents(final int maxNetworkEvents) { + configuration.setMaxNetworkEvents(maxNetworkEvents); + return this; + } + + public AllureWebDriverBiDi redactHeaders(final String... headerNames) { + configuration.redactHeaders(headerNames); + return this; + } + + @Override + public void beforeAnyWebDriverCall(final WebDriver driver, + final Method method, + final Object[] args) { + captureActiveAllureContext(driver); + } + + @Override + public void afterAnyWebDriverCall(final WebDriver driver, + final Method method, + final Object[] args, + final Object result) { + captureActiveAllureContext(driver); + } + + @Override + public void beforeQuit(final WebDriver driver) { + closeSession(driver); + } + + @Override + public void afterQuit(final WebDriver driver) { + closeSession(driver); + } + + @Override + public void close() { + final List activeSessions = new ArrayList<>(); + sessionsLock.lock(); + try { + activeSessions.addAll(sessions.values()); + sessions.clear(); + } finally { + sessionsLock.unlock(); + } + activeSessions.forEach(this::safeFlushAndClose); + } + + private void captureActiveAllureContext(final WebDriver driver) { + if (!configuration.isAnyEnabled()) { + return; + } + + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + getOrCreateSession(driver); + } + + private BiDiSessionState getOrCreateSession(final WebDriver driver) { + sessionsLock.lock(); + try { + final BiDiSessionState existing = sessions.get(driver); + if (existing != null) { + return existing; + } + + try { + final BiDiSessionState created = BiDiSessionState.start(driver, configuration, sessionFactory); + if (created != null) { + sessions.put(driver, created); + } + return created; + } catch (RuntimeException e) { + LOGGER.debug("Could not start WebDriver BiDi capture", e); + return null; + } + } finally { + sessionsLock.unlock(); + } + } + + private void closeSession(final WebDriver driver) { + final BiDiSessionState state; + sessionsLock.lock(); + try { + state = sessions.remove(driver); + } finally { + sessionsLock.unlock(); + } + safeFlushAndClose(state); + } + + private void safeFlushAndClose(final BiDiSessionState state) { + if (state == null) { + return; + } + try { + state.flushAndClose(lifecycle); + } catch (RuntimeException e) { + LOGGER.debug("Could not flush WebDriver BiDi capture", e); + } + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiAttachmentStorage.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiAttachmentStorage.java new file mode 100644 index 000000000..251bb2989 --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiAttachmentStorage.java @@ -0,0 +1,140 @@ +/* + * 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.seleniumbidi; + +import io.qameta.allure.AllureLifecycle; +import org.openqa.selenium.json.Json; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +final class BiDiAttachmentStorage { + + static final String LOG_ATTACHMENT_NAME = "WebDriver BiDi logs"; + static final String NETWORK_ATTACHMENT_NAME = "WebDriver BiDi network"; + + private static final String JSON_TYPE = "application/json"; + private static final String JSON_EXTENSION = "json"; + private static final Json JSON = new Json(); + + private final List> logs = new ArrayList<>(); + private final List> network = new ArrayList<>(); + private final Lock lock = new ReentrantLock(); + private long droppedLogs; + private long droppedNetworkEvents; + + void addLog(final BiDiLogEvent event, final int maxEntries) { + lock.lock(); + try { + if (logs.size() < maxEntries) { + logs.add(event.toMap()); + } else { + droppedLogs++; + } + } finally { + lock.unlock(); + } + } + + void addNetwork(final BiDiNetworkEvent event, + final int maxEntries, + final HeaderRedactor redactor) { + lock.lock(); + try { + if (network.size() < maxEntries) { + network.add(event.toMap(redactor)); + } else { + droppedNetworkEvents++; + } + } finally { + lock.unlock(); + } + } + + void flush(final AllureLifecycle lifecycle) { + try { + if (!lifecycle.getCurrentTestCaseOrStep().isPresent()) { + return; + } + + logsAttachment() + .ifPresent(body -> lifecycle.addAttachment( + LOG_ATTACHMENT_NAME, + JSON_TYPE, + JSON_EXTENSION, + body + )); + networkAttachment() + .ifPresent(body -> lifecycle.addAttachment( + NETWORK_ATTACHMENT_NAME, + JSON_TYPE, + JSON_EXTENSION, + body + )); + } finally { + clear(); + } + } + + void clear() { + lock.lock(); + try { + logs.clear(); + network.clear(); + droppedLogs = 0; + droppedNetworkEvents = 0; + } finally { + lock.unlock(); + } + } + + Optional logsAttachment() { + lock.lock(); + try { + if (logs.isEmpty() && droppedLogs == 0) { + return Optional.empty(); + } + return Optional.of(toJson(new ArrayList<>(logs), droppedLogs)); + } finally { + lock.unlock(); + } + } + + Optional networkAttachment() { + lock.lock(); + try { + if (network.isEmpty() && droppedNetworkEvents == 0) { + return Optional.empty(); + } + return Optional.of(toJson(new ArrayList<>(network), droppedNetworkEvents)); + } finally { + lock.unlock(); + } + } + + private static byte[] toJson(final List> entries, final long dropped) { + final Map payload = new LinkedHashMap<>(); + payload.put("entries", entries); + payload.put("dropped", dropped); + return JSON.toJson(payload).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiConfiguration.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiConfiguration.java new file mode 100644 index 000000000..e313ca8bc --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiConfiguration.java @@ -0,0 +1,83 @@ +/* + * 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.seleniumbidi; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +final class BiDiConfiguration { + + static final int DEFAULT_MAX_LOG_ENTRIES = 1_000; + static final int DEFAULT_MAX_NETWORK_EVENTS = 2_000; + + private final AtomicBoolean logsEnabled = new AtomicBoolean(true); + private final AtomicBoolean networkEnabled = new AtomicBoolean(true); + private final AtomicInteger maxLogEntries = new AtomicInteger(DEFAULT_MAX_LOG_ENTRIES); + private final AtomicInteger maxNetworkEvents = new AtomicInteger(DEFAULT_MAX_NETWORK_EVENTS); + private final AtomicReference headerRedactor = new AtomicReference<>(HeaderRedactor.defaults()); + + boolean isLogsEnabled() { + return logsEnabled.get(); + } + + void setLogsEnabled(final boolean logsEnabled) { + this.logsEnabled.set(logsEnabled); + } + + boolean isNetworkEnabled() { + return networkEnabled.get(); + } + + void setNetworkEnabled(final boolean networkEnabled) { + this.networkEnabled.set(networkEnabled); + } + + boolean isAnyEnabled() { + return logsEnabled.get() || networkEnabled.get(); + } + + int getMaxLogEntries() { + return maxLogEntries.get(); + } + + void setMaxLogEntries(final int maxLogEntries) { + this.maxLogEntries.set(requireNonNegative(maxLogEntries, "maxLogEntries")); + } + + int getMaxNetworkEvents() { + return maxNetworkEvents.get(); + } + + void setMaxNetworkEvents(final int maxNetworkEvents) { + this.maxNetworkEvents.set(requireNonNegative(maxNetworkEvents, "maxNetworkEvents")); + } + + HeaderRedactor getHeaderRedactor() { + return headerRedactor.get(); + } + + void redactHeaders(final String... headerNames) { + this.headerRedactor.set(HeaderRedactor.defaults().withAdditionalHeaders(headerNames)); + } + + private static int requireNonNegative(final int value, final String name) { + if (value < 0) { + throw new IllegalArgumentException(name + " must be greater than or equal to zero"); + } + return value; + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiJsonKeys.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiJsonKeys.java new file mode 100644 index 000000000..d0720c0df --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiJsonKeys.java @@ -0,0 +1,32 @@ +/* + * 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.seleniumbidi; + +final class BiDiJsonKeys { + + static final String EVENT = "event"; + static final String HEADERS = "headers"; + static final String HEADERS_SIZE = "headersSize"; + static final String REQUEST_ID = "requestId"; + static final String TEXT = "text"; + static final String TYPE = "type"; + static final String URL = "url"; + static final String VALUE = "value"; + + private BiDiJsonKeys() { + throw new IllegalStateException("Do not instance"); + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiLogEvent.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiLogEvent.java new file mode 100644 index 000000000..7d04ad6eb --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiLogEvent.java @@ -0,0 +1,144 @@ +/* + * 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.seleniumbidi; + +import org.openqa.selenium.bidi.log.BaseLogEntry; +import org.openqa.selenium.bidi.log.ConsoleLogEntry; +import org.openqa.selenium.bidi.log.GenericLogEntry; +import org.openqa.selenium.bidi.log.JavascriptLogEntry; +import org.openqa.selenium.bidi.log.LogEntry; +import org.openqa.selenium.bidi.log.StackFrame; +import org.openqa.selenium.bidi.log.StackTrace; +import org.openqa.selenium.bidi.script.RemoteValue; +import org.openqa.selenium.bidi.script.Source; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull") +final class BiDiLogEvent { + + private final Map values; + + private BiDiLogEvent(final Map values) { + this.values = values; + } + + static List from(final LogEntry entry) { + final List events = new ArrayList<>(3); + entry.getConsoleLogEntry().ifPresent(console -> events.add(console(console))); + entry.getJavascriptLogEntry().ifPresent(javascript -> events.add(javascript(javascript))); + entry.getGenericLogEntry().ifPresent(generic -> events.add(generic(generic))); + return events; + } + + static BiDiLogEvent generic(final GenericLogEntry entry) { + final Map values = base("generic", entry); + putIfNotNull(values, BiDiJsonKeys.TYPE, entry.getType()); + return new BiDiLogEvent(values); + } + + static BiDiLogEvent console(final ConsoleLogEntry entry) { + final Map values = base("console", entry); + putIfNotNull(values, BiDiJsonKeys.TYPE, entry.getType()); + putIfNotNull(values, "method", entry.getMethod()); + values.put("args", remoteValues(entry.getArgs())); + return new BiDiLogEvent(values); + } + + static BiDiLogEvent javascript(final JavascriptLogEntry entry) { + final Map values = base("javascript", entry); + putIfNotNull(values, BiDiJsonKeys.TYPE, entry.getType()); + return new BiDiLogEvent(values); + } + + static BiDiLogEvent of(final String text) { + final Map values = new LinkedHashMap<>(); + values.put(BiDiJsonKeys.EVENT, "log"); + values.put(BiDiJsonKeys.TEXT, text); + return new BiDiLogEvent(values); + } + + Map toMap() { + return new LinkedHashMap<>(values); + } + + private static Map base(final String event, final BaseLogEntry entry) { + final Map values = new LinkedHashMap<>(); + values.put(BiDiJsonKeys.EVENT, event); + putIfNotNull(values, "level", entry.getLevel()); + putIfNotNull(values, BiDiJsonKeys.TEXT, entry.getText()); + values.put("timestamp", entry.getTimestamp()); + putIfNotNull(values, "source", source(entry.getSource())); + putIfNotNull(values, "stackTrace", stackTrace(entry.getStackTrace())); + return values; + } + + private static Map source(final Source source) { + if (source == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + putIfNotNull(values, "realm", source.getRealm()); + source.getBrowsingContext().ifPresent(context -> values.put("browsingContextId", context)); + return values; + } + + private static List> remoteValues(final List remoteValues) { + final List> result = new ArrayList<>(); + if (remoteValues == null) { + return result; + } + remoteValues.forEach(remoteValue -> { + final Map value = new LinkedHashMap<>(); + putIfNotNull(value, BiDiJsonKeys.TYPE, remoteValue.getType()); + remoteValue.getValue().ifPresent(content -> value.put(BiDiJsonKeys.VALUE, content)); + remoteValue.getHandle().ifPresent(handle -> value.put("handle", handle)); + remoteValue.getInternalId().ifPresent(id -> value.put("internalId", id)); + remoteValue.getSharedId().ifPresent(id -> value.put("sharedId", id)); + result.add(value); + }); + return result; + } + + static Map stackTrace(final StackTrace stackTrace) { + if (stackTrace == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + final List> frames = new ArrayList<>(); + stackTrace.getCallFrames().forEach(frame -> frames.add(stackFrame(frame))); + values.put("callFrames", frames); + return values; + } + + private static Map stackFrame(final StackFrame frame) { + final Map values = new LinkedHashMap<>(); + putIfNotNull(values, "url", frame.getUrl()); + putIfNotNull(values, "functionName", frame.getFunctionName()); + values.put("lineNumber", frame.getLineNumber()); + values.put("columnNumber", frame.getColumnNumber()); + return values; + } + + static void putIfNotNull(final Map values, final String name, final Object value) { + if (value != null) { + values.put(name, value); + } + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiNetworkEvent.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiNetworkEvent.java new file mode 100644 index 000000000..dc7da7d9f --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiNetworkEvent.java @@ -0,0 +1,196 @@ +/* + * 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.seleniumbidi; + +import org.openqa.selenium.bidi.network.BaseParameters; +import org.openqa.selenium.bidi.network.BeforeRequestSent; +import org.openqa.selenium.bidi.network.BytesValue; +import org.openqa.selenium.bidi.network.FetchError; +import org.openqa.selenium.bidi.network.FetchTimingInfo; +import org.openqa.selenium.bidi.network.Header; +import org.openqa.selenium.bidi.network.Initiator; +import org.openqa.selenium.bidi.network.RequestData; +import org.openqa.selenium.bidi.network.ResponseData; +import org.openqa.selenium.bidi.network.ResponseDetails; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull") +final class BiDiNetworkEvent { + + private final Map values; + + private BiDiNetworkEvent(final Map values) { + this.values = values; + } + + static BiDiNetworkEvent beforeRequestSent(final BeforeRequestSent event) { + final Map values = base("beforeRequestSent", event); + BiDiLogEvent.putIfNotNull(values, "initiator", initiator(event.getInitiator())); + return new BiDiNetworkEvent(values); + } + + static BiDiNetworkEvent fetchError(final FetchError event) { + final Map values = base("fetchError", event); + BiDiLogEvent.putIfNotNull(values, "errorText", event.getErrorText()); + return new BiDiNetworkEvent(values); + } + + static BiDiNetworkEvent responseStarted(final ResponseDetails event) { + return response("responseStarted", event); + } + + static BiDiNetworkEvent responseCompleted(final ResponseDetails event) { + return response("responseCompleted", event); + } + + static BiDiNetworkEvent authRequired(final ResponseDetails event) { + return response("authRequired", event); + } + + static BiDiNetworkEvent of(final String event, final Map values) { + final Map copy = new LinkedHashMap<>(); + copy.put(BiDiJsonKeys.EVENT, event); + copy.putAll(values); + return new BiDiNetworkEvent(copy); + } + + Map toMap(final HeaderRedactor redactor) { + return redactor.redact(values); + } + + private static BiDiNetworkEvent response(final String name, final ResponseDetails event) { + final Map values = base(name, event); + BiDiLogEvent.putIfNotNull(values, "response", responseData(event.getResponseData())); + return new BiDiNetworkEvent(values); + } + + private static Map responseData(final ResponseData response) { + if (response == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(values, BiDiJsonKeys.URL, response.getUrl()); + BiDiLogEvent.putIfNotNull(values, "protocol", response.getProtocol()); + values.put("status", response.getStatus()); + BiDiLogEvent.putIfNotNull(values, "statusText", response.getStatusText()); + values.put("fromCache", response.isFromCache()); + values.put(BiDiJsonKeys.HEADERS, headers(response.getHeaders())); + BiDiLogEvent.putIfNotNull(values, "mimeType", response.getMimeType()); + values.put("bytesReceived", response.getBytesReceived()); + values.put(BiDiJsonKeys.HEADERS_SIZE, response.getHeadersSize()); + values.put("bodySize", response.getBodySize()); + response.getContent().ifPresent(content -> values.put("contentLength", content)); + response.getAuthChallenge().ifPresent(challenge -> { + final Map authChallenge = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(authChallenge, "scheme", challenge.getScheme()); + BiDiLogEvent.putIfNotNull(authChallenge, "realm", challenge.getRealm()); + values.put("authChallenge", authChallenge); + }); + return values; + } + + private static Map base(final String event, final BaseParameters parameters) { + final Map values = new LinkedHashMap<>(); + values.put(BiDiJsonKeys.EVENT, event); + BiDiLogEvent.putIfNotNull(values, "browsingContextId", parameters.getBrowsingContextId()); + values.put("blocked", parameters.isBlocked()); + BiDiLogEvent.putIfNotNull(values, "navigationId", parameters.getNavigationId()); + values.put("redirectCount", parameters.getRedirectCount()); + BiDiLogEvent.putIfNotNull(values, "request", request(parameters.getRequest())); + values.put("timestamp", parameters.getTimestamp()); + values.put("intercepts", parameters.getIntercepts()); + return values; + } + + private static Map request(final RequestData request) { + if (request == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(values, BiDiJsonKeys.REQUEST_ID, request.getRequestId()); + BiDiLogEvent.putIfNotNull(values, BiDiJsonKeys.URL, request.getUrl()); + BiDiLogEvent.putIfNotNull(values, "method", request.getMethod()); + values.put(BiDiJsonKeys.HEADERS, headers(request.getHeaders())); + BiDiLogEvent.putIfNotNull(values, BiDiJsonKeys.HEADERS_SIZE, request.getHeadersSize()); + BiDiLogEvent.putIfNotNull(values, "timings", timings(request.getTimings())); + return values; + } + + private static List> headers(final List
headers) { + final List> result = new ArrayList<>(); + if (headers == null) { + return result; + } + headers.forEach(header -> { + final Map value = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(value, "name", header.getName()); + BiDiLogEvent.putIfNotNull(value, BiDiJsonKeys.VALUE, bytesValue(header.getValue())); + result.add(value); + }); + return result; + } + + private static Map bytesValue(final BytesValue bytesValue) { + if (bytesValue == null) { + return null; + } + final Map value = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(value, BiDiJsonKeys.TYPE, bytesValue.getType()); + BiDiLogEvent.putIfNotNull(value, BiDiJsonKeys.VALUE, bytesValue.getValue()); + return value; + } + + private static Map timings(final FetchTimingInfo timings) { + if (timings == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + values.put("timeOrigin", timings.getTimeOrigin()); + values.put("requestTime", timings.getRequestTime()); + values.put("redirectStart", timings.getRedirectStart()); + values.put("redirectEnd", timings.getRedirectEnd()); + values.put("fetchStart", timings.getFetchStart()); + values.put("dnsStart", timings.getDnsStart()); + values.put("dnsEnd", timings.getDnsEnd()); + values.put("connectStart", timings.getConnectStart()); + values.put("connectEnd", timings.getConnectEnd()); + values.put("tlsStart", timings.getTlsStart()); + values.put("requestStart", timings.getRequestStart()); + values.put("responseStart", timings.getResponseStart()); + values.put("responseEnd", timings.getResponseEnd()); + return values; + } + + private static Map initiator(final Initiator initiator) { + if (initiator == null) { + return null; + } + final Map values = new LinkedHashMap<>(); + BiDiLogEvent.putIfNotNull(values, BiDiJsonKeys.TYPE, initiator.getType()); + initiator.getColumnNumber().ifPresent(column -> values.put("columnNumber", column)); + initiator.getLineNumber().ifPresent(line -> values.put("lineNumber", line)); + initiator.getRequestId().ifPresent(requestId -> values.put(BiDiJsonKeys.REQUEST_ID, requestId)); + initiator.getStackTrace().ifPresent(stackTrace -> values.put( + "stackTrace", + BiDiLogEvent.stackTrace(stackTrace) + )); + return values; + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionFactory.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionFactory.java new file mode 100644 index 000000000..e0578beb5 --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionFactory.java @@ -0,0 +1,29 @@ +/* + * 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.seleniumbidi; + +import org.openqa.selenium.WebDriver; + +import java.util.function.Consumer; + +@FunctionalInterface +interface BiDiSessionFactory { + + RecordingSession start(WebDriver driver, + BiDiConfiguration configuration, + Consumer logConsumer, + Consumer networkConsumer); +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionState.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionState.java new file mode 100644 index 000000000..c1332a83f --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/BiDiSessionState.java @@ -0,0 +1,97 @@ +/* + * 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.seleniumbidi; + +import io.qameta.allure.AllureLifecycle; +import org.openqa.selenium.WebDriver; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +final class BiDiSessionState { + + private final BiDiConfiguration configuration; + private final BiDiAttachmentStorage storage = new BiDiAttachmentStorage(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicReference recordingSession = new AtomicReference<>(); + + private BiDiSessionState(final BiDiConfiguration configuration) { + this.configuration = configuration; + } + + static BiDiSessionState start(final WebDriver driver, + final BiDiConfiguration configuration, + final BiDiSessionFactory sessionFactory) { + final BiDiSessionState state = new BiDiSessionState(configuration); + final RecordingSession session = sessionFactory.start( + driver, + configuration, + state::recordLog, + state::recordNetwork + ); + if (session == null) { + return null; + } + state.recordingSession.set(session); + return state; + } + + void recordLog(final BiDiLogEvent event) { + if (closed.get() || !configuration.isLogsEnabled()) { + return; + } + storage.addLog( + event, + configuration.getMaxLogEntries() + ); + } + + void recordNetwork(final BiDiNetworkEvent event) { + if (closed.get() || !configuration.isNetworkEnabled()) { + return; + } + storage.addNetwork( + event, + configuration.getMaxNetworkEvents(), + configuration.getHeaderRedactor() + ); + } + + void flushAndClose(final AllureLifecycle lifecycle) { + closed.set(true); + try { + closeRecordingSession(); + } finally { + storage.flush(lifecycle); + } + } + + void close() { + closed.set(true); + try { + closeRecordingSession(); + } finally { + storage.clear(); + } + } + + private void closeRecordingSession() { + final RecordingSession session = recordingSession.getAndSet(null); + if (session != null) { + session.close(); + } + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/HeaderRedactor.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/HeaderRedactor.java new file mode 100644 index 000000000..d20b32327 --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/HeaderRedactor.java @@ -0,0 +1,127 @@ +/* + * 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.seleniumbidi; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +final class HeaderRedactor { + + static final String REDACTED = "[REDACTED]"; + + private static final Set DEFAULT_HEADERS = new LinkedHashSet<>(Arrays.asList( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" + )); + + private final Set headerNames; + + private HeaderRedactor(final Set headerNames) { + this.headerNames = headerNames; + } + + static HeaderRedactor defaults() { + return new HeaderRedactor(DEFAULT_HEADERS); + } + + HeaderRedactor withAdditionalHeaders(final String... additionalHeaders) { + final Set headers = new LinkedHashSet<>(headerNames); + if (additionalHeaders != null) { + Arrays.stream(additionalHeaders) + .map(HeaderRedactor::normalize) + .filter(value -> !value.isEmpty()) + .forEach(headers::add); + } + return new HeaderRedactor(headers); + } + + Map redact(final Map values) { + return redactMap(values); + } + + private Map redactMap(final Map values) { + final Map result = new LinkedHashMap<>(); + values.forEach((key, value) -> { + if (BiDiJsonKeys.HEADERS.equals(key) && value instanceof List) { + result.put(key, redactHeaders((List) value)); + } else if (value instanceof Map) { + result.put(key, redactMap(castMap(value))); + } else if (value instanceof List) { + result.put(key, redactList((List) value)); + } else { + result.put(key, value); + } + }); + return result; + } + + private List redactList(final List values) { + final List result = new ArrayList<>(); + values.forEach(value -> { + if (value instanceof Map) { + result.add(redactMap(castMap(value))); + } else if (value instanceof List) { + result.add(redactList((List) value)); + } else { + result.add(value); + } + }); + return result; + } + + private List redactHeaders(final List headers) { + return headers.stream() + .map(header -> header instanceof Map ? redactHeader(castMap(header)) : header) + .collect(Collectors.toList()); + } + + private Map redactHeader(final Map header) { + final Map result = new LinkedHashMap<>(header); + final Object name = result.get("name"); + if (name instanceof String && headerNames.contains(normalize((String) name))) { + result.put(BiDiJsonKeys.VALUE, redactedValue(result.get(BiDiJsonKeys.VALUE))); + } + return result; + } + + private Object redactedValue(final Object value) { + if (value instanceof Map) { + final Map redacted = new LinkedHashMap<>(castMap(value)); + redacted.put(BiDiJsonKeys.VALUE, REDACTED); + return redacted; + } + return REDACTED; + } + + @SuppressWarnings("unchecked") + private static Map castMap(final Object value) { + return (Map) value; + } + + private static String normalize(final String headerName) { + return headerName == null ? "" : headerName.toLowerCase(Locale.ROOT); + } +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/RecordingSession.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/RecordingSession.java new file mode 100644 index 000000000..b1db141cf --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/RecordingSession.java @@ -0,0 +1,22 @@ +/* + * 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.seleniumbidi; + +@FunctionalInterface +interface RecordingSession { + + void close(); +} diff --git a/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/SeleniumBiDiSessionFactory.java b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/SeleniumBiDiSessionFactory.java new file mode 100644 index 000000000..3a91fb1af --- /dev/null +++ b/allure-selenium-bidi/src/main/java/io/qameta/allure/seleniumbidi/SeleniumBiDiSessionFactory.java @@ -0,0 +1,91 @@ +/* + * 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.seleniumbidi; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.module.LogInspector; +import org.openqa.selenium.bidi.module.Network; +import org.openqa.selenium.remote.Augmenter; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +final class SeleniumBiDiSessionFactory implements BiDiSessionFactory { + + @Override + @SuppressWarnings("PMD.CloseResource") + public RecordingSession start(final WebDriver driver, + final BiDiConfiguration configuration, + final Consumer logConsumer, + final Consumer networkConsumer) { + final WebDriver bidiDriver = toBiDiDriver(driver); + if (bidiDriver == null) { + return null; + } + + final List inspectors = new ArrayList<>(); + if (configuration.isLogsEnabled()) { + final LogInspector logInspector = new LogInspector(bidiDriver); + logInspector.onLog(entry -> BiDiLogEvent.from(entry).forEach(logConsumer)); + inspectors.add(logInspector); + } + if (configuration.isNetworkEnabled()) { + final Network network = new Network(bidiDriver); + network.onBeforeRequestSent(event -> networkConsumer.accept(BiDiNetworkEvent.beforeRequestSent(event))); + network.onFetchError(event -> networkConsumer.accept(BiDiNetworkEvent.fetchError(event))); + network.onResponseStarted(event -> networkConsumer.accept(BiDiNetworkEvent.responseStarted(event))); + network.onResponseCompleted(event -> networkConsumer.accept(BiDiNetworkEvent.responseCompleted(event))); + network.onAuthRequired(event -> networkConsumer.accept(BiDiNetworkEvent.authRequired(event))); + inspectors.add(network); + } + return new SeleniumRecordingSession(inspectors); + } + + private WebDriver toBiDiDriver(final WebDriver driver) { + if (isBiDiAvailable(driver)) { + return driver; + } + + final WebDriver augmented = new Augmenter().augment(driver); + return isBiDiAvailable(augmented) ? augmented : null; + } + + private boolean isBiDiAvailable(final WebDriver driver) { + return driver instanceof HasBiDi && ((HasBiDi) driver).maybeGetBiDi().isPresent(); + } + + private static final class SeleniumRecordingSession implements RecordingSession { + + private final List inspectors; + + private SeleniumRecordingSession(final List inspectors) { + this.inspectors = inspectors; + } + + @Override + public void close() { + inspectors.forEach(inspector -> { + try { + inspector.close(); + } catch (Exception ignored) { + // ignore cleanup failures; reporting must not affect WebDriver teardown + } + }); + } + } +} diff --git a/allure-selenium-bidi/src/test/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDiTest.java b/allure-selenium-bidi/src/test/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDiTest.java new file mode 100644 index 000000000..b8463bc3d --- /dev/null +++ b/allure-selenium-bidi/src/test/java/io/qameta/allure/seleniumbidi/AllureWebDriverBiDiTest.java @@ -0,0 +1,900 @@ +/* + * 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.seleniumbidi; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; +import io.qameta.allure.Param; +import io.qameta.allure.Step; +import io.qameta.allure.model.Attachment; +import io.qameta.allure.model.Status; +import io.qameta.allure.model.StepResult; +import io.qameta.allure.model.TestResult; +import io.qameta.allure.test.AllureFeatures; +import io.qameta.allure.test.AllureResults; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static io.qameta.allure.model.Parameter.Mode.HIDDEN; +import static io.qameta.allure.test.RunUtils.runWithinTestContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; + +/** + * Real browser integration tests for the Selenium WebDriver BiDi adapter. + */ +@ResourceLock(value = "io.qameta.allure.seleniumbidi.lifecycle", mode = READ_WRITE) +class AllureWebDriverBiDiTest { + + private static final Json JSON = new Json(); + private static final int SELENIUM_PORT = 4444; + private static final long SELENIUM_SHARED_MEMORY = 2L * 1024 * 1024 * 1024; + private static final String IMAGE_PROPERTY = "allure.selenium.bidi.integration.image"; + private static final String SELENIUM_IMAGE_TAG = "4.23.0"; + private static final String LOCALHOST_URL_FORMAT = "http://localhost:%d"; + private static final String DEFAULT_PAGE = "/"; + private static final String LOGS_PAGE = "/logs"; + private static final String NETWORK_PAGE = "/network"; + private static final String PING_PATH = "/ping"; + private static final String HTML_TYPE = "text/html"; + private static final String WAIT_FOR_BIDI_INTERRUPTED = "Interrupted while waiting for BiDi events"; + private static final String DROPPED_KEY = "dropped"; + private static final String ENTRIES_KEY = "entries"; + private static final String CONSOLE_LOG = "bidi-it-console"; + private static final String FETCH_LOG = "bidi-it-fetch-ok"; + private static final String SECOND_CONSOLE_LOG = "bidi-it-second-console"; + private static final String CUSTOM_PAGE = "/custom"; + private static final String API_KEY_HEADER_NAME = "X-Api-Key"; + private static final String CUSTOM_HEADER_NAME = "X-Secret-Token"; + private static final String TRACE_HEADER_NAME = "X-Trace-Id"; + private static final String CUSTOM_SECRET = "custom-integration-secret"; + private static final String VISIBLE_HEADER_VALUE = "visible-integration-value"; + private static final String API_KEY = "integration-secret"; + private static final ThreadLocal SERVER_PORT = new ThreadLocal<>(); + + /** + * Checks that console log entries are attached as WebDriver BiDi log data. + * + * The test emits two browser console messages and verifies that the log attachment contains exactly those messages + * without exercising network assertions. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldAttachConsoleLogs() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false), + driver -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + assertThat(attachmentNames(results)) + .containsExactly(BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + assertThat(entries(payload)) + .hasSize(2) + .extracting(entry -> entry.get("text")) + .containsExactlyInAnyOrder(CONSOLE_LOG, SECOND_CONSOLE_LOG); + assertDropped(payload, 0); + } + + /** + * Checks that console log metadata is attached as WebDriver BiDi log data. + * + * The test emits a browser console message and verifies event metadata such as level, method, source, stack trace, + * timestamp, and argument data. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldAttachConsoleLogMetadata() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false), + driver -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + final Map entry = entries(payload).stream() + .filter(item -> CONSOLE_LOG.equals(item.get("text"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Console log entry not found")); + assertThat(entry) + .containsEntry("event", "console") + .containsEntry("level", "info") + .containsEntry("method", "log") + .containsEntry("type", "console"); + assertThat(((Number) entry.get("timestamp")).longValue()).isPositive(); + assertThat(object(entry, "source").get("browsingContextId")) + .isInstanceOf(String.class) + .asString() + .isNotBlank(); + assertThat(objects(object(entry, "stackTrace"), "callFrames")) + .extracting(frame -> String.valueOf(frame.get("url"))) + .anyMatch(url -> url.contains(LOGS_PAGE)); + final Map argument = objects(entry, "args").get(0); + assertThat(argument) + .containsEntry("type", "string") + .containsEntry("value", CONSOLE_LOG); + } + + /** + * Checks that request and response metadata are attached as WebDriver BiDi network data. + * + * The test performs a browser fetch and verifies that the network attachment contains request and completed response + * events for the fetched URL. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldAttachNetworkEvents() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false), + driver -> { + driver.get(testServerUrl(NETWORK_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + assertThat(attachmentNames(results)) + .containsExactly(BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + assertThat(object(networkEntry(payload, "beforeRequestSent", PING_PATH), "request").get("url")) + .asString() + .contains(PING_PATH); + assertThat(object(networkEntry(payload, "responseCompleted", PING_PATH), "response").get("url")) + .asString() + .contains(PING_PATH); + assertDropped(payload, 0); + } + + /** + * Checks that request metadata is attached as WebDriver BiDi network data. + * + * The test performs a browser fetch and verifies the captured request URL, method, request id, timing object, and + * browser context fields. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldAttachNetworkRequestMetadata() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false), + driver -> { + driver.get(testServerUrl(NETWORK_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + final Map entry = networkEntry(payload, "beforeRequestSent", PING_PATH); + final Map request = object(entry, "request"); + assertThat(String.valueOf(request.get("url"))).contains(PING_PATH); + assertThat(request.get("method")).isEqualTo("GET"); + assertThat(request.get("requestId")) + .isInstanceOf(String.class) + .asString() + .isNotBlank(); + assertThat(object(request, "timings")) + .containsKeys("requestStart", "responseEnd"); + assertThat(entry.get("browsingContextId")) + .isInstanceOf(String.class) + .asString() + .isNotBlank(); + assertThat(((Number) entry.get("timestamp")).longValue()).isPositive(); + assertThat(entry.get("blocked")).isEqualTo(false); + assertThat(((Number) entry.get("redirectCount")).longValue()).isZero(); + assertThat(objects(entry, "intercepts")).isEmpty(); + assertThat(object(entry, "initiator").get("type")).isEqualTo("script"); + } + + /** + * Checks that response metadata is attached as WebDriver BiDi network data. + * + * The test performs a browser fetch and verifies the captured response URL, status, MIME type, protocol, cache flag, + * size counters, and headers. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldAttachNetworkResponseMetadata() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false), + driver -> { + driver.get(testServerUrl(NETWORK_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + final Map response = object(networkEntry(payload, "responseCompleted", PING_PATH), "response"); + assertThat(String.valueOf(response.get("url"))).contains(PING_PATH); + assertThat(response) + .containsEntry("protocol", "http/1.1") + .containsEntry("statusText", "OK") + .containsEntry("fromCache", false) + .containsEntry("mimeType", "text/plain"); + assertThat(((Number) response.get("status")).longValue()).isEqualTo(200L); + assertThat(((Number) response.get("bytesReceived")).longValue()).isPositive(); + assertThat(((Number) response.get("headersSize")).longValue()).isPositive(); + assertThat(headerValue(response, "Content-type")).isEqualTo("text/plain"); + } + + /** + * Checks that built-in sensitive headers are redacted in network attachments. + * + * The test sends an API key request header and verifies that the attached request metadata contains the header name + * but not the header value. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldRedactDefaultSensitiveHeaders() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false), + driver -> { + driver.get(testServerUrl(NETWORK_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + final Map request = object(networkEntry(payload, "beforeRequestSent", PING_PATH), "request"); + assertThat(headerValue(request, API_KEY_HEADER_NAME)).isEqualTo(HeaderRedactor.REDACTED); + assertThat(attachmentContent(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME)) + .doesNotContain(API_KEY); + } + + /** + * Checks that user-configured sensitive headers are redacted in network attachments. + * + * The test sends one custom secret header and one visible tracing header, then verifies that only the tracing value + * survives redaction. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldRedactCustomHeaders() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false).redactHeaders(CUSTOM_HEADER_NAME), + driver -> { + driver.get(testServerUrl(CUSTOM_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + final Map request = object(networkEntry(payload, "beforeRequestSent", PING_PATH), "request"); + assertThat(headerValue(request, CUSTOM_HEADER_NAME)).isEqualTo(HeaderRedactor.REDACTED); + assertThat(headerValue(request, TRACE_HEADER_NAME)).isEqualTo(VISIBLE_HEADER_VALUE); + assertThat(attachmentContent(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME)) + .doesNotContain(CUSTOM_SECRET); + } + + /** + * Checks that disabled log capture does not write a log attachment. + * + * The test opens a page that emits console logs while log capture is disabled and verifies that only network data is + * attached. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldDisableLogCapture() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false), + driver -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + assertThat(attachmentNames(results)) + .containsExactly(BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + } + + /** + * Checks that disabled network capture does not write a network attachment. + * + * The test opens a page that emits both console logs and network activity while network capture is disabled, then + * verifies that only log data is attached. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldDisableNetworkCapture() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false), + driver -> { + driver.get(testServerUrl(DEFAULT_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + assertThat(attachmentNames(results)) + .containsExactly(BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + assertThat(entries(payload)) + .extracting(entry -> entry.get("text")) + .contains(CONSOLE_LOG, SECOND_CONSOLE_LOG, FETCH_LOG); + } + + /** + * Checks that the log attachment reports dropped entries when the log cap is reached. + * + * The test limits log capture to one entry, emits two console messages, and verifies both the retained entry count + * and dropped counter. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldTrackDroppedLogEntries() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false).maxLogEntries(1), + driver -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + assertThat(entries(payload)).hasSize(1); + assertDropped(payload, 1); + } + + /** + * Checks that the network attachment reports dropped events when the network cap is reached. + * + * The test limits network capture to one event, performs browser network activity, and verifies that additional + * events are counted as dropped. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldTrackDroppedNetworkEvents() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.logs(false).maxNetworkEvents(1), + driver -> { + driver.get(testServerUrl(NETWORK_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.NETWORK_ATTACHMENT_NAME); + assertThat(entries(payload)).hasSize(1); + assertThat(((Number) payload.get(DROPPED_KEY)).longValue()).isPositive(); + } + + /** + * Checks that closing the listener flushes buffered BiDi data. + * + * The test closes {@link AllureWebDriverBiDi} before WebDriver quit and verifies that collected log data is still + * attached. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldFlushBufferedEventsWhenListenerCloses() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false), + true, + driver -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + } + ); + + final Map payload = attachmentPayload(results, BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + assertThat(entries(payload)) + .extracting(entry -> entry.get("text")) + .contains(CONSOLE_LOG, SECOND_CONSOLE_LOG); + } + + /** + * Checks that a driver session without BiDi support does not attach data. + * + * The test starts Chrome without BiDi enabled and verifies that browser activity does not produce BiDi attachments. + */ + @Description + @AllureFeatures.Attachments + @Test + void shouldNotAttachDataWhenBiDiIsUnavailable() throws IOException { + final AllureResults results = runTestWithSeleniumBidi(chromeOptionsWithoutBiDi(), driver -> { + driver.get(testServerUrl(DEFAULT_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + }); + + assertThat(attachmentNames(results)).isEmpty(); + } + + /** + * Checks that WebDriver interactions inside a user-created runtime step keep the expected Allure step tree. + * + * The test opens a real browser page inside {@code Allure.step(...)} and verifies that the runtime step is preserved + * as the parent for helper steps executed from the lambda, while BiDi data is still attached to the generated result. + */ + @Description + @AllureFeatures.Attachments + @AllureFeatures.Steps + @Test + void shouldNestRuntimeStepAroundWebDriverInteractions() throws IOException { + final AllureResults results = runTestWithSeleniumBidi( + chromeOptions(), + listener -> listener.network(false), + driver -> Allure.step("Open page in runtime step", () -> { + driver.get(testServerUrl(LOGS_PAGE)); + waitUntilPageDone(driver); + driver.findElement(By.tagName("body")); + }) + ); + + final TestResult testResult = singleTestResult(results); + assertThat(attachments(testResult)) + .extracting(Attachment::getName) + .contains(BiDiAttachmentStorage.LOG_ATTACHMENT_NAME); + + final StepResult scenarioStep = step(testResult.getSteps(), "Run ready Selenium WebDriver scenario"); + assertThat(scenarioStep.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsSubsequence( + tuple("Open page in runtime step", Status.PASSED), + tuple("Wait for asynchronous WebDriver BiDi events", Status.PASSED) + ); + + final StepResult runtimeStep = step(scenarioStep.getSteps(), "Open page in runtime step"); + assertThat(runtimeStep) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly("Open page in runtime step", Status.PASSED); + assertThat(runtimeStep.getSteps()) + .extracting(StepResult::getName, StepResult::getStatus) + .containsExactly(tuple("Wait until test page finishes browser activity", Status.PASSED)); + } + + @Step("Start Selenium Testcontainers browser") + private static GenericContainer startSeleniumContainer(final int seleniumPort) { + final GenericContainer selenium = seleniumContainer(seleniumPort); + selenium.start(); + return selenium; + } + + private static GenericContainer seleniumContainer(final int seleniumPort) { + final GenericContainer container = new GenericContainer<>(seleniumImage()) + .withExposedPorts(SELENIUM_PORT) + .withSharedMemorySize(SELENIUM_SHARED_MEMORY) + .withEnv("SE_NODE_GRID_URL", seleniumEndpoint(seleniumPort)) + .waitingFor(Wait.forHttp("/status").forPort(SELENIUM_PORT).forStatusCode(200)); + container.setPortBindings(Collections.singletonList(String.format("%d:%d", seleniumPort, SELENIUM_PORT))); + return container; + } + + private static DockerImageName seleniumImage() { + return DockerImageName.parse(System.getProperty(IMAGE_PROPERTY, defaultImage())); + } + + private static String defaultImage() { + final String architecture = System.getProperty("os.arch", ""); + final String image = architecture.contains("aarch64") || architecture.contains("arm64") + ? "selenium/standalone-chromium" + : "selenium/standalone-chrome"; + return String.format("%s:%s", image, SELENIUM_IMAGE_TAG); + } + + @Step("Start local HTTP server") + private static HttpServer startServer() throws IOException { + final HttpServer server = HttpServer.create( + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), + 0 + ); + server.createContext(DEFAULT_PAGE, AllureWebDriverBiDiTest::handleIndex); + server.createContext(LOGS_PAGE, AllureWebDriverBiDiTest::handleLogs); + server.createContext(NETWORK_PAGE, AllureWebDriverBiDiTest::handleNetwork); + server.createContext(CUSTOM_PAGE, AllureWebDriverBiDiTest::handleCustom); + server.createContext(PING_PATH, AllureWebDriverBiDiTest::handlePing); + server.start(); + return server; + } + + private static void handleIndex(final HttpExchange exchange) throws IOException { + respond( + exchange, + HTML_TYPE, + pageScript( + consoleLog(CONSOLE_LOG) + + consoleLog(SECOND_CONSOLE_LOG) + + fetchPing(header(API_KEY_HEADER_NAME, API_KEY)) + + ".then(response=>response.text())" + + ".then(text=>console.log('bidi-it-fetch-'+text))" + ) + ); + } + + private static void handleLogs(final HttpExchange exchange) throws IOException { + respond( + exchange, + HTML_TYPE, + pageScript( + consoleLog(CONSOLE_LOG) + + consoleLog(SECOND_CONSOLE_LOG) + + "Promise.resolve()" + ) + ); + } + + private static void handleNetwork(final HttpExchange exchange) throws IOException { + respond( + exchange, + HTML_TYPE, + pageScript(fetchPing(header(API_KEY_HEADER_NAME, API_KEY))) + ); + } + + private static void handleCustom(final HttpExchange exchange) throws IOException { + respond( + exchange, + HTML_TYPE, + pageScript( + fetchPing(header(CUSTOM_HEADER_NAME, CUSTOM_SECRET) + + "," + header(TRACE_HEADER_NAME, VISIBLE_HEADER_VALUE)) + ) + ); + } + + private static void handlePing(final HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("Set-Cookie", "session=server-secret"); + respond(exchange, "text/plain", "ok"); + } + + private static void respond(final HttpExchange exchange, + final String contentType, + final String body) throws IOException { + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static AllureResults runTestWithSeleniumBidi(final ChromeOptions options, + final WebDriverScenario scenario) throws IOException { + return runTestWithSeleniumBidi(options, listener -> listener, false, scenario); + } + + private static AllureResults runTestWithSeleniumBidi(final ChromeOptions options, + final ListenerConfigurer configurer, + final WebDriverScenario scenario) throws IOException { + return runTestWithSeleniumBidi(options, configurer, false, scenario); + } + + @Step("Run Selenium WebDriver BiDi test") + private static AllureResults runTestWithSeleniumBidi(@Param(mode = HIDDEN) final ChromeOptions options, + @Param(mode = HIDDEN) final ListenerConfigurer configurer, + final boolean closeListener, + @Param(mode = HIDDEN) final WebDriverScenario scenario) + throws IOException { + final HttpServer server = startServer(); + try { + final int serverPort = server.getAddress().getPort(); + SERVER_PORT.set(serverPort); + Testcontainers.exposeHostPorts(serverPort); + + final int seleniumPort = availableTcpPort(); + try (GenericContainer selenium = startSeleniumContainer(seleniumPort)) { + final URL remoteUrl = seleniumUrl(seleniumPort); + return runWithinTestContext(() -> { + final AllureWebDriverBiDi listener = configurer.configure( + new AllureWebDriverBiDi(Allure.getLifecycle(), new SeleniumBiDiSessionFactory()) + ); + runWebDriverScenario( + listener, + new RemoteWebDriver(remoteUrl, options), + closeListener, + scenario + ); + }); + } + } finally { + SERVER_PORT.remove(); + server.stop(0); + } + } + + @Step("Run ready Selenium WebDriver scenario") + private static void runWebDriverScenario(@Param(mode = HIDDEN) final AllureWebDriverBiDi listener, + @Param(mode = HIDDEN) final WebDriver rawDriver, + final boolean closeListener, + @Param(mode = HIDDEN) final WebDriverScenario scenario) { + WebDriver driver = null; + boolean closed = false; + try { + driver = listener.decorate(rawDriver); + scenario.run(driver); + waitForBiDiEvents(); + if (closeListener) { + listener.close(); + rawDriver.quit(); + } else { + driver.quit(); + } + closed = true; + } finally { + if (!closed) { + quit(driver, rawDriver); + } + } + } + + private static ChromeOptions chromeOptions() { + final ChromeOptions options = new ChromeOptions().enableBiDi(); + addChromeArguments(options); + return options; + } + + private static ChromeOptions chromeOptionsWithoutBiDi() { + final ChromeOptions options = new ChromeOptions(); + addChromeArguments(options); + return options; + } + + private static void addChromeArguments(final ChromeOptions options) { + options.addArguments("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"); + } + + @Step("Wait until test page finishes browser activity") + private static void waitUntilPageDone(@Param(mode = HIDDEN) final WebDriver driver) { + new WebDriverWait(driver, Duration.ofSeconds(10)) + .until(AllureWebDriverBiDiTest::isPageDone); + } + + @Step("Wait for asynchronous WebDriver BiDi events") + private static void waitForBiDiEvents() { + try { + TimeUnit.MILLISECONDS.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(WAIT_FOR_BIDI_INTERRUPTED, e); + } + } + + private static boolean isPageDone(final WebDriver driver) { + return Boolean.TRUE.equals(((JavascriptExecutor) driver).executeScript("return window.__bidiDone === true;")); + } + + private static URL seleniumUrl(final int seleniumPort) throws IOException { + return URI.create(seleniumEndpoint(seleniumPort)).toURL(); + } + + private static String seleniumEndpoint(final int seleniumPort) { + return String.format(LOCALHOST_URL_FORMAT, seleniumPort); + } + + private static int availableTcpPort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static String testServerUrl(final String path) { + final Integer port = SERVER_PORT.get(); + if (port == null) { + throw new IllegalStateException("Test server is not running"); + } + return containerHostServerUrl(port, path); + } + + private static String containerHostServerUrl(final int port, final String path) { + return String.format("http://host.testcontainers.internal:%d%s", port, path); + } + + private static void quit(final WebDriver driver, final WebDriver rawDriver) { + if (driver != null) { + driver.quit(); + } else { + rawDriver.quit(); + } + } + + @Step("Get single generated test result") + private static TestResult singleTestResult(@Param(mode = HIDDEN) final AllureResults results) { + assertThat(results.getTestResults()).hasSize(1); + final TestResult result = results.getTestResults().iterator().next(); + assertThat(result.getStatus()).isNull(); + return result; + } + + private static List attachmentNames(final AllureResults results) { + final List names = new ArrayList<>(); + attachments(singleTestResult(results)).forEach(attachment -> names.add(attachment.getName())); + return names; + } + + private static String attachmentContent(final AllureResults results, final String name) { + return attachmentContent(results, attachments(singleTestResult(results)), name); + } + + @Step("Read attachment {name}") + private static String attachmentContent(@Param(mode = HIDDEN) final AllureResults results, + @Param(mode = HIDDEN) final List attachments, + final String name) { + final Attachment attachment = attachments.stream() + .filter(item -> name.equals(item.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Attachment not found: " + name)); + return new String(results.getAttachments().get(attachment.getSource()), StandardCharsets.UTF_8); + } + + @Step("Read JSON attachment {name}") + private static Map attachmentPayload(@Param(mode = HIDDEN) final AllureResults results, + final String name) { + return JSON.toType(attachmentContent(results, name), Json.MAP_TYPE); + } + + private static List attachments(final TestResult testResult) { + final List attachments = new ArrayList<>(testResult.getAttachments()); + testResult.getSteps().forEach(step -> collectAttachments(step, attachments)); + return attachments; + } + + private static void collectAttachments(final StepResult step, final List attachments) { + attachments.addAll(step.getAttachments()); + step.getSteps().forEach(child -> collectAttachments(child, attachments)); + } + + private static void assertDropped(final Map payload, final long expected) { + assertThat(((Number) payload.get(DROPPED_KEY)).longValue()).isEqualTo(expected); + } + + @SuppressWarnings("unchecked") + private static List> entries(final Map payload) { + return objects(payload, ENTRIES_KEY); + } + + @SuppressWarnings("unchecked") + private static Map object(final Map payload, final String key) { + return (Map) payload.get(key); + } + + @SuppressWarnings("unchecked") + private static List> objects(final Map payload, final String key) { + return (List>) payload.get(key); + } + + private static Map networkEntry(final Map payload, + final String event, + final String path) { + return entries(payload).stream() + .filter(entry -> event.equals(entry.get("event"))) + .filter(entry -> networkUrl(entry).contains(path)) + .findFirst() + .orElseThrow(() -> new AssertionError("Network entry not found: " + event + " " + path)); + } + + private static String networkUrl(final Map entry) { + final Map response = object(entry, "response"); + if (response != null) { + return String.valueOf(response.get("url")); + } + return String.valueOf(object(entry, "request").get("url")); + } + + private static String headerValue(final Map owner, final String name) { + return objects(owner, "headers").stream() + .filter(header -> name.equalsIgnoreCase(String.valueOf(header.get("name")))) + .map(header -> object(header, "value")) + .map(value -> String.valueOf(value.get("value"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Header not found: " + name)); + } + + private static StepResult step(final List steps, final String name) { + return steps.stream() + .filter(step -> name.equals(step.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Step not found: " + name)); + } + + private static String consoleLog(final String text) { + return String.format("console.log('%s');", text); + } + + private static String fetchPing(final String headers) { + return String.format("fetch('%s',{headers:{%s}})", PING_PATH, headers); + } + + private static String header(final String name, final String value) { + return String.format("'%s':'%s'", name, value); + } + + private static String pageScript(final String script) { + return "" + + "" + + ""; + } + + @FunctionalInterface + private interface ListenerConfigurer { + + AllureWebDriverBiDi configure(AllureWebDriverBiDi listener); + } + + @FunctionalInterface + private interface WebDriverScenario { + + void run(WebDriver driver); + } +} diff --git a/allure-selenium-bidi/src/test/resources/allure.properties b/allure-selenium-bidi/src/test/resources/allure.properties new file mode 100644 index 000000000..1176dba48 --- /dev/null +++ b/allure-selenium-bidi/src/test/resources/allure.properties @@ -0,0 +1,3 @@ +allure.results.directory=build/allure-results +allure.label.epic=#project.description# +allure.label.module=allure-selenium-bidi diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f2e058a9..b8a4309a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ include("allure-playwright") include("allure-reader") include("allure-rest-assured") include("allure-scalatest") +include("allure-selenium-bidi") include("allure-selenide") include("allure-servlet-api") include("allure-spock")