diff --git a/dotnet/src/webdriver/DevTools/DevToolsSession.cs b/dotnet/src/webdriver/DevTools/DevToolsSession.cs index aad579a7b1ed7..1d6466c2436fa 100644 --- a/dotnet/src/webdriver/DevTools/DevToolsSession.cs +++ b/dotnet/src/webdriver/DevTools/DevToolsSession.cs @@ -19,10 +19,7 @@ using System; using System.Collections.Concurrent; using System.Globalization; -using System.IO; using System.Net.Http; -using System.Net.WebSockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; @@ -50,15 +47,14 @@ public class DevToolsSession : IDevToolsSession private bool isDisposed = false; private string attachedTargetId; - private ClientWebSocket sessionSocket; + private WebSocketConnection connection; private ConcurrentDictionary pendingCommands = new ConcurrentDictionary(); + private readonly BlockingCollection messageQueue = new BlockingCollection(); + private readonly Task messageQueueMonitorTask; private long currentCommandId = 0; private DevToolsDomains domains; - private CancellationTokenSource receiveCancellationToken; - private Task receiveTask; - /// /// Initializes a new instance of the DevToolsSession class, using the specified WebSocket endpoint. /// @@ -76,6 +72,8 @@ public DevToolsSession(string endpointAddress) { this.websocketAddress = endpointAddress; } + this.messageQueueMonitorTask = Task.Run(() => this.MonitorMessageQueue()); + this.messageQueueMonitorTask.ConfigureAwait(false); } /// @@ -213,15 +211,13 @@ public T GetVersionSpecificDomains() where T : DevToolsSessionDomains var message = new DevToolsCommandData(Interlocked.Increment(ref this.currentCommandId), this.ActiveSessionId, commandName, commandParameters); - if (this.sessionSocket != null && this.sessionSocket.State == WebSocketState.Open) + if (this.connection != null && this.connection.IsActive) { LogTrace("Sending {0} {1}: {2}", message.CommandId, message.CommandName, commandParameters.ToString()); - var contents = JsonConvert.SerializeObject(message); - var contentBuffer = Encoding.UTF8.GetBytes(contents); - + string contents = JsonConvert.SerializeObject(message); this.pendingCommands.TryAdd(message.CommandId, message); - await this.sessionSocket.SendAsync(new ArraySegment(contentBuffer), WebSocketMessageType.Text, true, cancellationToken); + await this.connection.SendData(contents); var responseWasReceived = await Task.Run(() => message.SyncEvent.Wait(millisecondsTimeout.Value, cancellationToken)); @@ -230,8 +226,7 @@ public T GetVersionSpecificDomains() where T : DevToolsSessionDomains throw new InvalidOperationException($"A command response was not received: {commandName}"); } - DevToolsCommandData modified; - if (this.pendingCommands.TryRemove(message.CommandId, out modified)) + if (this.pendingCommands.TryRemove(message.CommandId, out DevToolsCommandData modified)) { if (modified.IsError) { @@ -256,10 +251,7 @@ public T GetVersionSpecificDomains() where T : DevToolsSessionDomains } else { - if (this.sessionSocket != null) - { - LogTrace("WebSocket is not connected (current state is {0}); not sending {1}", this.sessionSocket.State, message.CommandName); - } + LogTrace("WebSocket is not connected; not sending {0}", message.CommandName); } return null; @@ -330,11 +322,7 @@ protected void Dispose(bool disposing) { this.Domains.Target.TargetDetached -= this.OnTargetDetached; this.pendingCommands.Clear(); - this.TerminateSocketConnection(); - - // Note: Canceling the receive task will dispose of - // the underlying ClientWebSocket instance. - this.CancelReceiveTask(); + this.TerminateSocketConnection().GetAwaiter().GetResult(); } this.isDisposed = true; @@ -377,28 +365,6 @@ private async Task InitializeProtocol(int requestedProtocolVersion) return protocolVersion; } - private async Task InitializeSocketConnection() - { - LogTrace("Creating WebSocket"); - this.sessionSocket = new ClientWebSocket(); - this.sessionSocket.Options.KeepAliveInterval = TimeSpan.Zero; - - try - { - var timeoutTokenSource = new CancellationTokenSource(this.openConnectionWaitTimeSpan); - await this.sessionSocket.ConnectAsync(new Uri(this.websocketAddress), timeoutTokenSource.Token); - while (this.sessionSocket.State != WebSocketState.Open && !timeoutTokenSource.Token.IsCancellationRequested) ; - } - catch (OperationCanceledException e) - { - throw new WebDriverException(string.Format(CultureInfo.InvariantCulture, "Could not establish WebSocket connection within {0} seconds.", this.openConnectionWaitTimeSpan.TotalSeconds), e); - } - - LogTrace("WebSocket created; starting message listener"); - this.receiveCancellationToken = new CancellationTokenSource(); - this.receiveTask = Task.Run(() => ReceiveMessage().ConfigureAwait(false)); - } - private async Task InitializeSession() { LogTrace("Creating session"); @@ -445,116 +411,56 @@ private void OnTargetDetached(object sender, TargetDetachedEventArgs e) } } - private void TerminateSocketConnection() + private async Task InitializeSocketConnection() { - if (this.sessionSocket != null && this.sessionSocket.State == WebSocketState.Open) - { - var closeConnectionTokenSource = new CancellationTokenSource(this.closeConnectionWaitTimeSpan); - try - { - // Since Chromium-based DevTools does not respond to the close - // request with a correctly echoed WebSocket close packet, but - // rather just terminates the socket connection, so we have to - // catch the exception thrown when the socket is terminated - // unexpectedly. Also, because we are using async, waiting for - // the task to complete might throw a TaskCanceledException, - // which we should also catch. Additiionally, there are times - // when mulitple failure modes can be seen, which will throw an - // AggregateException, consolidating several exceptions into one, - // and this too must be caught. Finally, the call to CloseAsync - // will hang even though the connection is already severed. - // Wait for the task to complete for a short time (since we're - // restricted to localhost, the default of 2 seconds should be - // plenty; if not, change the initialization of the timout), - // and if the task is still running, then we assume the connection - // is properly closed. - LogTrace("Sending socket close request"); - Task closeTask = Task.Run(async () => await this.sessionSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, closeConnectionTokenSource.Token)); - closeTask.Wait(); - } - catch (WebSocketException) - { - } - catch (TaskCanceledException) - { - } - catch (AggregateException) - { - } - } + LogTrace("Creating WebSocket"); + this.connection = new WebSocketConnection(this.openConnectionWaitTimeSpan, this.closeConnectionWaitTimeSpan); + connection.DataReceived += OnConnectionDataReceived; + await connection.Start(this.websocketAddress); + LogTrace("WebSocket created"); } - private void CancelReceiveTask() + private async Task TerminateSocketConnection() { - if (this.receiveTask != null) + LogTrace("Closing WebSocket"); + if (this.connection != null && this.connection.IsActive) { - // Wait for the recieve task to be completely exited (for - // whatever reason) before attempting to dispose it. Also - // note that canceling the receive task will dispose of the - // underlying WebSocket. - this.receiveCancellationToken.Cancel(); - this.receiveTask.Wait(); - this.receiveTask.Dispose(); - this.receiveTask = null; + await this.connection.Stop(); + await this.ShutdownMessageQueue(); } + LogTrace("WebSocket closed"); } - private async Task ReceiveMessage() + private async Task ShutdownMessageQueue() { - var cancellationToken = this.receiveCancellationToken.Token; - try - { - var buffer = WebSocket.CreateClientBuffer(1024, 1024); - while (this.sessionSocket.State != WebSocketState.Closed && !cancellationToken.IsCancellationRequested) - { - WebSocketReceiveResult result = await this.sessionSocket.ReceiveAsync(buffer, cancellationToken); - if (!cancellationToken.IsCancellationRequested) - { - if (result.MessageType == WebSocketMessageType.Close && this.sessionSocket.State == WebSocketState.CloseReceived) - { - LogTrace("Got WebSocket close message from browser"); - await this.sessionSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken); - } - } - - if (this.sessionSocket.State == WebSocketState.Open && result.MessageType != WebSocketMessageType.Close) - { - using (var stream = new MemoryStream()) - { - stream.Write(buffer.Array, 0, result.Count); - while (!result.EndOfMessage) - { - result = await this.sessionSocket.ReceiveAsync(buffer, cancellationToken); - stream.Write(buffer.Array, 0, result.Count); - } - - stream.Seek(0, SeekOrigin.Begin); - using (var reader = new StreamReader(stream, Encoding.UTF8)) - { - string message = reader.ReadToEnd(); - - // fire and forget - // TODO: we need implement some kind of queue - Task.Run(() => ProcessIncomingMessage(message)); - } - } - } - } - } - catch (OperationCanceledException) + // THe WebSockect connection is always closed before this method + // is called, so there will eventually be no more data written + // into the message queue, meaning this loop should be guaranteed + // to complete. + while (this.connection.IsActive) { + await Task.Delay(TimeSpan.FromMilliseconds(10)); } - catch (WebSocketException) - { - } - finally + + this.messageQueue.CompleteAdding(); + await this.messageQueueMonitorTask; + } + + private void MonitorMessageQueue() + { + // GetConsumingEnumerable blocks until if BlockingCollection.IsCompleted + // is false (i.e., is still able to be written to), and there are no items + // in the collection. Once any items are added to the collection, the method + // unblocks and we can process any items in the collection at that moment. + // Once IsCompleted is true, the method unblocks with no items in returned + // in the IEnumerable, meaning the foreach loop will terminate gracefully. + foreach (string message in this.messageQueue.GetConsumingEnumerable()) { - this.sessionSocket.Dispose(); - this.sessionSocket = null; + this.ProcessMessage(message); } } - private void ProcessIncomingMessage(string message) + private void ProcessMessage(string message) { var messageObject = JObject.Parse(message); @@ -594,7 +500,12 @@ private void ProcessIncomingMessage(string message) LogTrace("Recieved Event {0}: {1}", method, eventData.ToString()); - OnDevToolsEventReceived(new DevToolsEventReceivedEventArgs(methodParts[0], methodParts[1], eventData)); + // Dispatch the event on a new thread so that any event handlers + // responding to the event will not block this thread from processing + // DevTools commands that may be sent in the body of the attached + // event handler. If thread pool starvation seems to become a problem, + // we can switch to a channel-based queue. + Task.Run(() => OnDevToolsEventReceived(new DevToolsEventReceivedEventArgs(methodParts[0], methodParts[1], eventData))); return; } @@ -610,6 +521,11 @@ private void OnDevToolsEventReceived(DevToolsEventReceivedEventArgs e) } } + private void OnConnectionDataReceived(object sender, WebSocketConnectionDataReceivedEventArgs e) + { + this.messageQueue.Add(e.Data); + } + private void LogTrace(string message, params object[] args) { if (LogMessage != null) diff --git a/dotnet/src/webdriver/DevTools/WebSocketConnection.cs b/dotnet/src/webdriver/DevTools/WebSocketConnection.cs new file mode 100644 index 0000000000000..939a334f931ee --- /dev/null +++ b/dotnet/src/webdriver/DevTools/WebSocketConnection.cs @@ -0,0 +1,282 @@ +// +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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. +// + +using System; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenQA.Selenium.DevTools +{ + /// + /// Represents a connection to a WebDriver Bidi remote end. + /// + public class WebSocketConnection + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + private readonly CancellationTokenSource clientTokenSource = new CancellationTokenSource(); + private readonly TimeSpan startupTimeout; + private readonly TimeSpan shutdownTimeout; + private readonly int bufferSize = 4096; + private Task dataReceiveTask; + private bool isActive = false; + private ClientWebSocket client = new ClientWebSocket(); + + /// + /// Initializes a new instance of the class. + /// + public WebSocketConnection() + : this(DefaultTimeout) + { + } + + /// + /// Initializes a new instance of the class with a given startup timeout. + /// + /// The timeout before throwing an error when starting up the connection. + public WebSocketConnection(TimeSpan startupTimeout) + : this(startupTimeout, DefaultTimeout) + { + } + + /// + /// Initializes a new instance of the class with a given startup and shutdown timeout. + /// + /// The timeout before throwing an error when starting up the connection. + /// The timeout before throwing an error when shutting down the connection. + public WebSocketConnection(TimeSpan startupTimeout, TimeSpan shutdownTimeout) + { + this.startupTimeout = startupTimeout; + this.shutdownTimeout = shutdownTimeout; + } + + /// + /// Occurs when data is received from this connection. + /// + public event EventHandler DataReceived; + + /// + /// Occurs when a log message is emitted from this connection. + /// + public event EventHandler LogMessage; + + /// + /// Gets a value indicating whether this connection is active. + /// + public bool IsActive => this.isActive; + + /// + /// Gets the buffer size for communication used by this connection. + /// + public int BufferSize => this.bufferSize; + + /// + /// Asynchronously starts communication with the remote end of this connection. + /// + /// The URL used to connect to the remote end. + /// The task object representing the asynchronous operation. + /// Thrown when the connection is not established within the startup timeout. + public virtual async Task Start(string url) + { + this.Log($"Opening connection to URL {url}", DevToolsSessionLogLevel.Trace); + bool connected = false; + DateTime timeout = DateTime.Now.Add(this.startupTimeout); + while (!connected && DateTime.Now <= timeout) + { + try + { + await this.client.ConnectAsync(new Uri(url), this.clientTokenSource.Token); + connected = true; + } + catch (WebSocketException) + { + // If the server-side socket is not yet ready, it leaves the client socket in a closed state, + // which sees the object as disposed, so we must create a new one to try again + await Task.Delay(TimeSpan.FromMilliseconds(500)); + this.client = new ClientWebSocket(); + } + } + + if (!connected) + { + throw new TimeoutException($"Could not connect to browser within {this.startupTimeout.TotalSeconds} seconds"); + } + + this.dataReceiveTask = Task.Run(async () => await this.ReceiveData()); + this.isActive = true; + this.Log($"Connection opened", DevToolsSessionLogLevel.Trace); + } + + /// + /// Asynchronously stops communication with the remote end of this connection. + /// + /// The task object representing the asynchronous operation. + public virtual async Task Stop() + { + this.Log($"Closing connection", DevToolsSessionLogLevel.Trace); + if (this.client.State != WebSocketState.Open) + { + this.Log($"Socket already closed (Socket state: {this.client.State})"); + } + else + { + await this.CloseClientWebSocket(); + } + + // Whether we closed the socket or timed out, we cancel the token causing ReceiveAsync to abort the socket. + // The finally block at the end of the processing loop will dispose of the ClientWebSocket object. + this.clientTokenSource.Cancel(); + if (this.dataReceiveTask != null) + { + await this.dataReceiveTask; + } + } + + /// + /// Asynchronously sends data to the remote end of this connection. + /// + /// The data to be sent to the remote end of this connection. + /// The task object representing the asynchronous operation. + public virtual async Task SendData(string data) + { + ArraySegment messageBuffer = new ArraySegment(Encoding.UTF8.GetBytes(data)); + this.Log($"SEND >>> {data}"); + await this.client.SendAsync(messageBuffer, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); + } + + /// + /// Asynchronously closes the client WebSocket. + /// + /// The task object representing the asynchronous operation. + protected virtual async Task CloseClientWebSocket() + { + // Close the socket first, because ReceiveAsync leaves an invalid socket (state = aborted) when the token is cancelled + CancellationTokenSource timeout = new CancellationTokenSource(this.shutdownTimeout); + try + { + // After this, the socket state which change to CloseSent + await this.client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", timeout.Token); + + // Now we wait for the server response, which will close the socket + while (this.client.State != WebSocketState.Closed && this.client.State != WebSocketState.Aborted && !timeout.Token.IsCancellationRequested) + { + // The loop may be too tight for the cancellation token to get triggered, so add a small delay + await Task.Delay(TimeSpan.FromMilliseconds(10)); + } + + this.Log($"Client state is {this.client.State}", DevToolsSessionLogLevel.Trace); + } + catch (OperationCanceledException) + { + // An OperationCanceledException is normal upon task/token cancellation, so disregard it + } + } + + /// + /// Raises the DataReceived event. + /// + /// The event args used when raising the event. + protected virtual void OnDataReceived(WebSocketConnectionDataReceivedEventArgs e) + { + if (this.DataReceived != null) + { + this.DataReceived(this, e); + } + } + + /// + /// Raises the LogMessage event. + /// + /// The event args used when raising the event. + protected virtual void OnLogMessage(DevToolsSessionLogMessageEventArgs e) + { + if (this.LogMessage != null) + { + this.LogMessage(this, e); + } + } + + private async Task ReceiveData() + { + CancellationToken cancellationToken = this.clientTokenSource.Token; + try + { + StringBuilder messageBuilder = new StringBuilder(); + ArraySegment buffer = WebSocket.CreateClientBuffer(this.bufferSize, this.bufferSize); + while (this.client.State != WebSocketState.Closed && !cancellationToken.IsCancellationRequested) + { + WebSocketReceiveResult receiveResult = await this.client.ReceiveAsync(buffer, cancellationToken); + + // If the token is cancelled while ReceiveAsync is blocking, the socket state changes to aborted and it can't be used + if (!cancellationToken.IsCancellationRequested) + { + // The server is notifying us that the connection will close, and we did + // not initiate the close; send acknowledgement + if (receiveResult.MessageType == WebSocketMessageType.Close && this.client.State != WebSocketState.Closed && this.client.State != WebSocketState.CloseSent) + { + this.Log($"Acknowledging Close frame received from server (client state: {this.client.State})", DevToolsSessionLogLevel.Trace); + await this.client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Acknowledge Close frame", CancellationToken.None); + } + + // Display text or binary data + if (this.client.State == WebSocketState.Open && receiveResult.MessageType != WebSocketMessageType.Close) + { + messageBuilder.Append(Encoding.UTF8.GetString(buffer.Array, 0, receiveResult.Count)); + if (receiveResult.EndOfMessage) + { + string message = messageBuilder.ToString(); + messageBuilder = new StringBuilder(); + if (message.Length > 0) + { + this.Log($"RECV <<< {message}"); + this.OnDataReceived(new WebSocketConnectionDataReceivedEventArgs(message)); + } + } + } + } + } + + this.Log($"Ending processing loop in state {this.client.State}", DevToolsSessionLogLevel.Trace); + } + catch (OperationCanceledException) + { + // An OperationCanceledException is normal upon task/token cancellation, so disregard it + } + catch (WebSocketException e) + { + this.Log($"Unexpected error during receive of data: {e.Message}", DevToolsSessionLogLevel.Error); + } + finally + { + this.client.Dispose(); + this.isActive = false; + } + } + + private void Log(string message) + { + this.Log(message, DevToolsSessionLogLevel.Trace); + } + + private void Log(string message, DevToolsSessionLogLevel level) + { + this.OnLogMessage(new DevToolsSessionLogMessageEventArgs(level, "[{0}] {1}", "Connection", message)); + } + } +} diff --git a/dotnet/src/webdriver/DevTools/WebSocketConnectionDataReceivedEventArgs.cs b/dotnet/src/webdriver/DevTools/WebSocketConnectionDataReceivedEventArgs.cs new file mode 100644 index 0000000000000..4cbe597587291 --- /dev/null +++ b/dotnet/src/webdriver/DevTools/WebSocketConnectionDataReceivedEventArgs.cs @@ -0,0 +1,44 @@ +// +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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. +// + +using System; + +namespace OpenQA.Selenium.DevTools +{ + /// + /// Object containing event data for events raised when data is received from a WebDriver Bidi connection. + /// + public class WebSocketConnectionDataReceivedEventArgs : EventArgs + { + private readonly string data; + + /// + /// Initializes a new instance of the class. + /// + /// The data received from the connection. + public WebSocketConnectionDataReceivedEventArgs(string data) + { + this.data = data; + } + + /// + /// Gets the data received from the connection. + /// + public string Data => this.data; + } +} diff --git a/dotnet/src/webdriver/WebDriver.csproj b/dotnet/src/webdriver/WebDriver.csproj index 4aa2050966f90..f174af0af868a 100644 --- a/dotnet/src/webdriver/WebDriver.csproj +++ b/dotnet/src/webdriver/WebDriver.csproj @@ -48,15 +48,6 @@ - - - - - - - - - diff --git a/dotnet/test/common/CustomDriverConfigs/NightlyChannelFirefoxDriver.cs b/dotnet/test/common/CustomDriverConfigs/NightlyChannelFirefoxDriver.cs index c61fb91e861c2..6543ad38dbb82 100644 --- a/dotnet/test/common/CustomDriverConfigs/NightlyChannelFirefoxDriver.cs +++ b/dotnet/test/common/CustomDriverConfigs/NightlyChannelFirefoxDriver.cs @@ -23,7 +23,7 @@ public NightlyChannelFirefoxDriver(FirefoxDriverService service, FirefoxOptions public static FirefoxOptions DefaultOptions { - get { return new FirefoxOptions() { BrowserVersion = "nightly" }; } + get { return new FirefoxOptions() { BrowserVersion = "nightly", AcceptInsecureCertificates = true, EnableDevToolsProtocol = true }; } } } } diff --git a/dotnet/test/common/CustomDriverConfigs/StableChannelFirefoxDriver.cs b/dotnet/test/common/CustomDriverConfigs/StableChannelFirefoxDriver.cs index 5577d4a8963f9..49bae45c931ff 100644 --- a/dotnet/test/common/CustomDriverConfigs/StableChannelFirefoxDriver.cs +++ b/dotnet/test/common/CustomDriverConfigs/StableChannelFirefoxDriver.cs @@ -23,7 +23,7 @@ public StableChannelFirefoxDriver(FirefoxDriverService service, FirefoxOptions o public static FirefoxOptions DefaultOptions { - get { return new FirefoxOptions() { AcceptInsecureCertificates = true }; } + get { return new FirefoxOptions() { AcceptInsecureCertificates = true, EnableDevToolsProtocol = true }; } } } } diff --git a/dotnet/test/common/NetworkInterceptionTests.cs b/dotnet/test/common/NetworkInterceptionTests.cs new file mode 100644 index 0000000000000..6d29a8d41eaeb --- /dev/null +++ b/dotnet/test/common/NetworkInterceptionTests.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using OpenQA.Selenium.DevTools; + +namespace OpenQA.Selenium +{ + [TestFixture] + public class NetworkInterceptionTests : DriverTestFixture + { + [Test] + [IgnoreBrowser(Browser.Firefox, "Firefox does not implement the CDP Fetch domain required for network interception")] + public async Task TestCanInterceptNetworkCalls() + { + if (driver is IDevTools) + { + INetwork network = driver.Manage().Network; + NetworkResponseHandler handler = new NetworkResponseHandler(); + handler.ResponseMatcher = (responseData) => responseData.Url.Contains("simpleTest.html"); + handler.ResponseTransformer = (responseData) => + { + responseData.Body = "

I intercepted you

"; + return responseData; + }; + network.AddResponseHandler(handler); + await network.StartMonitoring(); + driver.Url = simpleTestPage; + string text = driver.FindElement(By.CssSelector("p")).Text; + await network.StopMonitoring(); + Assert.AreEqual("I intercepted you", text); + } + } + } +}