diff --git a/src/Appium.Net/Appium/Android/AndroidDriver.cs b/src/Appium.Net/Appium/Android/AndroidDriver.cs index 911469f9..1df33546 100644 --- a/src/Appium.Net/Appium/Android/AndroidDriver.cs +++ b/src/Appium.Net/Appium/Android/AndroidDriver.cs @@ -16,11 +16,13 @@ using OpenQA.Selenium.Appium.Enums; using OpenQA.Selenium.Appium.Interfaces; using OpenQA.Selenium.Appium.Service; +using OpenQA.Selenium.Appium.WebSocket; using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using OpenQA.Selenium.Appium.Android.Enums; +using System.Threading.Tasks; namespace OpenQA.Selenium.Appium.Android { @@ -28,9 +30,11 @@ public class AndroidDriver : AppiumDriver, IStartsActivity, INetworkActions, IHasClipboard, IHasPerformanceData, ISendsKeyEvents, - IPushesFiles, IHasSettings + IPushesFiles, IHasSettings, IListensToLogcatMessages { private static readonly string Platform = MobilePlatform.Android; + private readonly StringWebSocketClient _logcatClient = new(); + private const int DefaultAppiumPort = 4723; /// /// Initializes a new instance of the AndroidDriver class @@ -427,5 +431,104 @@ public AppState GetAppState(string appId) => } )?.ToString() ?? throw new InvalidOperationException("ExecuteScript returned null for mobile:queryAppState") ); + + #region Logcat Broadcast + + /// + /// Start logcat messages broadcast via web socket. + /// This method assumes that Appium server is running on localhost and + /// is assigned to the default port (4723). + /// + /// + /// This implementation uses a custom WebSocket endpoint and is temporary. + /// In the future, this functionality will be replaced with WebDriver BiDi log events + /// when BiDi support for Android device logs becomes available. + /// + public async Task StartLogcatBroadcast() => await StartLogcatBroadcast("127.0.0.1", DefaultAppiumPort); + + /// + /// Start logcat messages broadcast via web socket. + /// This method assumes that Appium server is assigned to the default port (4723). + /// + /// The name of the host where Appium server is running. + /// + /// This implementation uses a custom WebSocket endpoint and is temporary. + /// In the future, this functionality will be replaced with WebDriver BiDi log events + /// when BiDi support for Android device logs becomes available. + /// + public async Task StartLogcatBroadcast(string host) => await StartLogcatBroadcast(host, DefaultAppiumPort); + + /// + /// Start logcat messages broadcast via web socket. + /// + /// The name of the host where Appium server is running. + /// The port of the host where Appium server is running. + /// + /// This implementation uses a custom WebSocket endpoint and is temporary. + /// In the future, this functionality will be replaced with WebDriver BiDi log events + /// when BiDi support for Android device logs becomes available. + /// + public async Task StartLogcatBroadcast(string host, int port) + { + ExecuteScript("mobile: startLogsBroadcast", new Dictionary()); + var endpointUri = new Uri($"ws://{host}:{port}/ws/session/{SessionId}/appium/device/logcat"); + await _logcatClient.ConnectAsync(endpointUri); + } + + /// + /// Adds a new log messages broadcasting handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual log message. + public void AddLogcatMessagesListener(Action handler) => + _logcatClient.AddMessageHandler(handler); + + /// + /// Adds a new log broadcasting errors handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual exception instance. + public void AddLogcatErrorsListener(Action handler) => + _logcatClient.AddErrorHandler(handler); + + /// + /// Adds a new log broadcasting connection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully connected to the web socket. + public void AddLogcatConnectionListener(Action handler) => + _logcatClient.AddConnectionHandler(handler); + + /// + /// Adds a new log broadcasting disconnection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully disconnected from the web socket. + public void AddLogcatDisconnectionListener(Action handler) => + _logcatClient.AddDisconnectionHandler(handler); + + /// + /// Removes all existing logcat handlers. + /// + public void RemoveAllLogcatListeners() => _logcatClient.RemoveAllHandlers(); + + /// + /// Stops logcat messages broadcast via web socket. + /// + public async Task StopLogcatBroadcast() + { + ExecuteScript("mobile: stopLogsBroadcast", new Dictionary()); + await _logcatClient.DisconnectAsync(); + } + + #endregion } } \ No newline at end of file diff --git a/src/Appium.Net/Appium/Android/Interfaces/IListensToLogcatMessages.cs b/src/Appium.Net/Appium/Android/Interfaces/IListensToLogcatMessages.cs new file mode 100644 index 00000000..10f128a0 --- /dev/null +++ b/src/Appium.Net/Appium/Android/Interfaces/IListensToLogcatMessages.cs @@ -0,0 +1,92 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Threading.Tasks; + +namespace OpenQA.Selenium.Appium.Android.Interfaces +{ + /// + /// Interface for handling Android logcat message broadcasts via WebSocket. + /// + public interface IListensToLogcatMessages + { + /// + /// Start logcat messages broadcast via web socket. + /// This method assumes that Appium server is running on localhost and + /// is assigned to the default port (4723). + /// + Task StartLogcatBroadcast(); + + /// + /// Start logcat messages broadcast via web socket. + /// This method assumes that Appium server is assigned to the default port (4723). + /// + /// The name of the host where Appium server is running. + Task StartLogcatBroadcast(string host); + + /// + /// Start logcat messages broadcast via web socket. + /// + /// The name of the host where Appium server is running. + /// The port of the host where Appium server is running. + Task StartLogcatBroadcast(string host, int port); + + /// + /// Adds a new log messages broadcasting handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual log message. + void AddLogcatMessagesListener(Action handler); + + /// + /// Adds a new log broadcasting errors handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual exception instance. + void AddLogcatErrorsListener(Action handler); + + /// + /// Adds a new log broadcasting connection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully connected to the web socket. + void AddLogcatConnectionListener(Action handler); + + /// + /// Adds a new log broadcasting disconnection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully disconnected from the web socket. + void AddLogcatDisconnectionListener(Action handler); + + /// + /// Removes all existing logcat handlers. + /// + void RemoveAllLogcatListeners(); + + /// + /// Stops logcat messages broadcast via web socket. + /// + Task StopLogcatBroadcast(); + } +} diff --git a/src/Appium.Net/Appium/WebSocket/ICanHandleConnects.cs b/src/Appium.Net/Appium/WebSocket/ICanHandleConnects.cs new file mode 100644 index 00000000..18fe66d0 --- /dev/null +++ b/src/Appium.Net/Appium/WebSocket/ICanHandleConnects.cs @@ -0,0 +1,41 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Collections.Generic; + +namespace OpenQA.Selenium.Appium.WebSocket +{ + /// + /// Interface for handling WebSocket connections. + /// + public interface ICanHandleConnects + { + /// + /// Gets the list of web socket connection handlers. + /// + List ConnectionHandlers { get; } + + /// + /// Register a new connection handler. + /// + /// A callback function, which is going to be executed when web socket connection event arrives. + void AddConnectionHandler(Action handler); + + /// + /// Removes existing web socket connection handlers. + /// + void RemoveConnectionHandlers(); + } +} diff --git a/src/Appium.Net/Appium/WebSocket/ICanHandleDisconnects.cs b/src/Appium.Net/Appium/WebSocket/ICanHandleDisconnects.cs new file mode 100644 index 00000000..41af4177 --- /dev/null +++ b/src/Appium.Net/Appium/WebSocket/ICanHandleDisconnects.cs @@ -0,0 +1,41 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Collections.Generic; + +namespace OpenQA.Selenium.Appium.WebSocket +{ + /// + /// Interface for handling WebSocket disconnections. + /// + public interface ICanHandleDisconnects + { + /// + /// Gets the list of web socket disconnection handlers. + /// + List DisconnectionHandlers { get; } + + /// + /// Register a new web socket disconnect handler. + /// + /// A callback function, which is going to be executed when web socket disconnect event arrives. + void AddDisconnectionHandler(Action handler); + + /// + /// Removes existing disconnection handlers. + /// + void RemoveDisconnectionHandlers(); + } +} diff --git a/src/Appium.Net/Appium/WebSocket/ICanHandleErrors.cs b/src/Appium.Net/Appium/WebSocket/ICanHandleErrors.cs new file mode 100644 index 00000000..5b33aaf9 --- /dev/null +++ b/src/Appium.Net/Appium/WebSocket/ICanHandleErrors.cs @@ -0,0 +1,41 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Collections.Generic; + +namespace OpenQA.Selenium.Appium.WebSocket +{ + /// + /// Interface for handling WebSocket errors. + /// + public interface ICanHandleErrors + { + /// + /// Gets the list of web socket error handlers. + /// + List> ErrorHandlers { get; } + + /// + /// Register a new error handler. + /// + /// A callback function, which accepts the received exception instance as a parameter. + void AddErrorHandler(Action handler); + + /// + /// Removes existing error handlers. + /// + void RemoveErrorHandlers(); + } +} diff --git a/src/Appium.Net/Appium/WebSocket/ICanHandleMessages.cs b/src/Appium.Net/Appium/WebSocket/ICanHandleMessages.cs new file mode 100644 index 00000000..4b063e80 --- /dev/null +++ b/src/Appium.Net/Appium/WebSocket/ICanHandleMessages.cs @@ -0,0 +1,42 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Collections.Generic; + +namespace OpenQA.Selenium.Appium.WebSocket +{ + /// + /// Interface for handling WebSocket messages. + /// + /// The type of message to handle. + public interface ICanHandleMessages + { + /// + /// Gets the list of web socket message handlers. + /// + List> MessageHandlers { get; } + + /// + /// Register a new message handler. + /// + /// A callback function, which accepts the received message as a parameter. + void AddMessageHandler(Action handler); + + /// + /// Removes existing message handlers. + /// + void RemoveMessageHandlers(); + } +} diff --git a/src/Appium.Net/Appium/WebSocket/StringWebSocketClient.cs b/src/Appium.Net/Appium/WebSocket/StringWebSocketClient.cs new file mode 100644 index 00000000..dd755ff4 --- /dev/null +++ b/src/Appium.Net/Appium/WebSocket/StringWebSocketClient.cs @@ -0,0 +1,331 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Collections.Generic; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenQA.Selenium.Appium.WebSocket +{ + /// + /// WebSocket client for handling string messages. + /// + public class StringWebSocketClient : ICanHandleMessages, ICanHandleErrors, + ICanHandleConnects, ICanHandleDisconnects, IDisposable + { + private ClientWebSocket _clientWebSocket; + private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(1, 1); + private Uri _endpoint; + private CancellationTokenSource _cancellationTokenSource; + private Task _receiveTask; + + /// + /// Initializes a new instance of the class. + /// + public StringWebSocketClient() + { + MessageHandlers = new List>(); + ErrorHandlers = new List>(); + ConnectionHandlers = new List(); + DisconnectionHandlers = new List(); + } + + /// + /// Gets the list of all registered web socket message handlers. + /// + public List> MessageHandlers { get; } + + /// + /// Gets the list of all registered web socket error handlers. + /// + public List> ErrorHandlers { get; } + + /// + /// Gets the list of all registered web socket connection handlers. + /// + public List ConnectionHandlers { get; } + + /// + /// Gets the list of all registered web socket disconnection handlers. + /// + public List DisconnectionHandlers { get; } + + /// + /// Gets the endpoint URI. + /// + public Uri Endpoint => _endpoint; + + /// + /// Register a new message handler. + /// + /// A callback function, which accepts the received message as a parameter. + public void AddMessageHandler(Action handler) => MessageHandlers.Add(handler); + + /// + /// Removes existing message handlers. + /// + public void RemoveMessageHandlers() => MessageHandlers.Clear(); + + /// + /// Register a new error handler. + /// + /// A callback function, which accepts the received exception instance as a parameter. + public void AddErrorHandler(Action handler) => ErrorHandlers.Add(handler); + + /// + /// Removes existing error handlers. + /// + public void RemoveErrorHandlers() => ErrorHandlers.Clear(); + + /// + /// Register a new connection handler. + /// + /// A callback function, which is going to be executed when web socket connection event arrives. + public void AddConnectionHandler(Action handler) => ConnectionHandlers.Add(handler); + + /// + /// Removes existing web socket connection handlers. + /// + public void RemoveConnectionHandlers() => ConnectionHandlers.Clear(); + + /// + /// Register a new web socket disconnect handler. + /// + /// A callback function, which is going to be executed when web socket disconnect event arrives. + public void AddDisconnectionHandler(Action handler) => DisconnectionHandlers.Add(handler); + + /// + /// Removes existing disconnection handlers. + /// + public void RemoveDisconnectionHandlers() => DisconnectionHandlers.Clear(); + + /// + /// Connects to a WebSocket endpoint. + /// + /// The full address of an endpoint to connect to. Usually starts with 'ws://'. + public async Task ConnectAsync(Uri endpoint) + { + await _connectionLock.WaitAsync(); + try + { + // If websocket is already open and connected to the same endpoint, return + if (_clientWebSocket?.State == WebSocketState.Open) + { + if (endpoint.Equals(_endpoint)) + { + return; + } + + await DisconnectInternalAsync(); + } + + // Recreate ClientWebSocket if it's disposed or in a non-connectable state + if (_clientWebSocket == null || + _clientWebSocket.State == WebSocketState.Closed || + _clientWebSocket.State == WebSocketState.Aborted) + { + _clientWebSocket?.Dispose(); + _clientWebSocket = new ClientWebSocket(); + } + + try + { + _endpoint = endpoint; + _cancellationTokenSource = new CancellationTokenSource(); + + await _clientWebSocket.ConnectAsync(endpoint, _cancellationTokenSource.Token); + + // Invoke connection handlers + foreach (var handler in ConnectionHandlers.ToArray()) + { + handler?.Invoke(); + } + + // Start receiving messages + _receiveTask = Task.Run(ReceiveMessagesAsync); + } + catch (WebSocketException ex) + { + // Invoke error handlers + foreach (var handler in ErrorHandlers.ToArray()) + { + handler?.Invoke(ex); + } + throw new WebDriverException($"Failed to connect to WebSocket at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} UTC", ex); + } + catch (TaskCanceledException ex) + { + // Invoke error handlers + foreach (var handler in ErrorHandlers.ToArray()) + { + handler?.Invoke(ex); + } + throw new WebDriverException("WebSocket connection was cancelled", ex); + } + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Disconnects from the WebSocket endpoint. + /// + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync(); + try + { + await DisconnectInternalAsync(); + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Internal disconnect method without lock (called when already holding the lock). + /// + private async Task DisconnectInternalAsync() + { + if (_clientWebSocket.State == WebSocketState.Open) + { + try + { + _cancellationTokenSource?.Cancel(); + await _clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); + + // Wait for receive task to complete + if (_receiveTask != null) + { + await _receiveTask; + } + } + catch (Exception ex) + { + // Invoke error handlers for errors during close + foreach (var handler in ErrorHandlers.ToArray()) + { + handler?.Invoke(ex); + } + } + finally + { + // Invoke disconnection handlers + foreach (var handler in DisconnectionHandlers.ToArray()) + { + handler?.Invoke(); + } + } + } + + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + /// + /// Removes all the registered handlers. + /// + public void RemoveAllHandlers() + { + MessageHandlers.Clear(); + ErrorHandlers.Clear(); + ConnectionHandlers.Clear(); + DisconnectionHandlers.Clear(); + } + + private async Task ReceiveMessagesAsync() + { + var buffer = new byte[1024 * 4]; + var messageBuilder = new StringBuilder(); + + try + { + while (_clientWebSocket.State == WebSocketState.Open && !_cancellationTokenSource.Token.IsCancellationRequested) + { + WebSocketReceiveResult result; + + do + { + result = await _clientWebSocket.ReceiveAsync(new ArraySegment(buffer), _cancellationTokenSource.Token); + + if (result.MessageType == WebSocketMessageType.Text) + { + messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + else if (result.MessageType == WebSocketMessageType.Close) + { + await DisconnectAsync(); + return; + } + } + while (!result.EndOfMessage); + + if (messageBuilder.Length > 0) + { + var message = messageBuilder.ToString(); + messageBuilder.Clear(); + + // Invoke message handlers + foreach (var handler in MessageHandlers.ToArray()) + { + handler?.Invoke(message); + } + } + } + } + catch (OperationCanceledException) + { + // Normal cancellation, ignore + } + catch (Exception ex) + { + // Invoke error handlers + foreach (var handler in ErrorHandlers.ToArray()) + { + handler?.Invoke(ex); + } + } + } + + /// + /// Disposes the web socket client and releases resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the StringWebSocketClient and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DisconnectAsync().Wait(); + _clientWebSocket?.Dispose(); + _cancellationTokenSource?.Dispose(); + _connectionLock?.Dispose(); + } + } + } +} diff --git a/src/Appium.Net/Appium/iOS/Interfaces/IListensToSyslogMessages.cs b/src/Appium.Net/Appium/iOS/Interfaces/IListensToSyslogMessages.cs new file mode 100644 index 00000000..54e9d095 --- /dev/null +++ b/src/Appium.Net/Appium/iOS/Interfaces/IListensToSyslogMessages.cs @@ -0,0 +1,91 @@ +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//See the NOTICE file distributed with this work for additional +//information regarding copyright ownership. +//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.Appium.iOS.Interfaces +{ + /// + /// Interface for handling iOS syslog message broadcasts via WebSocket. + /// + public interface IListensToSyslogMessages + { + /// + /// Start syslog messages broadcast via web socket. + /// This method assumes that Appium server is running on localhost and + /// is assigned to the default port (4723). + /// + void StartSyslogBroadcast(); + + /// + /// Start syslog messages broadcast via web socket. + /// This method assumes that Appium server is assigned to the default port (4723). + /// + /// The name of the host where Appium server is running. + void StartSyslogBroadcast(string host); + + /// + /// Start syslog messages broadcast via web socket. + /// + /// The name of the host where Appium server is running. + /// The port of the host where Appium server is running. + void StartSyslogBroadcast(string host, int port); + + /// + /// Adds a new log messages broadcasting handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual log message. + void AddSyslogMessagesListener(Action handler); + + /// + /// Adds a new log broadcasting errors handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which accepts a single argument, which is the actual exception instance. + void AddSyslogErrorsListener(Action handler); + + /// + /// Adds a new log broadcasting connection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully connected to the web socket. + void AddSyslogConnectionListener(Action handler); + + /// + /// Adds a new log broadcasting disconnection handler. + /// Several handlers might be assigned to a single server. + /// Multiple calls to this method will cause such handler + /// to be called multiple times. + /// + /// A function, which is executed as soon as the client is successfully disconnected from the web socket. + void AddSyslogDisconnectionListener(Action handler); + + /// + /// Removes all existing syslog handlers. + /// + void RemoveAllSyslogListeners(); + + /// + /// Stops syslog messages broadcast via web socket. + /// + void StopSyslogBroadcast(); + } +} diff --git a/test/integration/Android/Session/Logs/LogcatBroadcastTests.cs b/test/integration/Android/Session/Logs/LogcatBroadcastTests.cs new file mode 100644 index 00000000..b9ebc2f2 --- /dev/null +++ b/test/integration/Android/Session/Logs/LogcatBroadcastTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Appium.Net.Integration.Tests.helpers; +using NUnit.Framework; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Android; + +namespace Appium.Net.Integration.Tests.Android.Session.Logs +{ + [TestFixture] + internal class LogcatBroadcastTests + { + private AndroidDriver _driver; + private AppiumOptions _androidOptions; + + [OneTimeSetUp] + public void SetUp() + { + _androidOptions = Caps.GetAndroidUIAutomatorCaps(Apps.Get("androidApiDemos")); + _driver = new AndroidDriver( + Env.ServerIsLocal() ? AppiumServers.LocalServiceUri : AppiumServers.RemoteServerUri, + _androidOptions); + } + + [OneTimeTearDown] + public void TearDown() + { + _driver?.Dispose(); + } + + [Test] + public async Task VerifyLogcatListenerCanBeAssigned() + { + using var messageSemaphore = new SemaphoreSlim(0, 1); + using var connectionSemaphore = new SemaphoreSlim(0, 1); + var messageReceived = false; + var connectionEstablished = false; + var timeout = TimeSpan.FromSeconds(15); + // Add listeners + _driver.AddLogcatMessagesListener(msg => + { + Console.WriteLine($"[LOGCAT] {msg}"); + messageReceived = true; + messageSemaphore.Release(); + }); + _driver.AddLogcatConnectionListener(() => + { + Console.WriteLine("Connected to the logcat web socket"); + connectionEstablished = true; + connectionSemaphore.Release(); + }); + _driver.AddLogcatDisconnectionListener(() => + { + Console.WriteLine("Disconnected from the logcat web socket"); + }); + _driver.AddLogcatErrorsListener(ex => + { + Console.WriteLine($"Logcat error: {ex.Message}"); + }); + try + { + // Start logcat broadcast + await _driver.StartLogcatBroadcast(); + // Wait for connection + Assert.That(connectionSemaphore.Wait(timeout), Is.True, + "Failed to establish WebSocket connection within timeout"); + Assert.That(connectionEstablished, Is.True, + "Connection listener was not invoked"); + // Trigger some activity to generate log messages + _driver.BackgroundApp(TimeSpan.FromSeconds(1)); + // Wait for at least one message + Assert.That(messageSemaphore.Wait(timeout), Is.True, + $"Didn't receive any log message after {timeout.TotalSeconds} seconds timeout"); + Assert.That(messageReceived, Is.True, + "Message listener was not invoked"); + } + finally + { + // Clean up + await _driver.StopLogcatBroadcast(); + _driver.RemoveAllLogcatListeners(); + } + } + + [Test] + public async Task CanStartAndStopLogcatBroadcast() + { + // Should not throw when starting and stopping + await _driver.StartLogcatBroadcast(); + await _driver.StopLogcatBroadcast(); + } + + [Test] + public async Task CanStartLogcatBroadcastWithCustomHost() + { + var host = "127.0.0.1"; + var port = 4723; + + await _driver.StartLogcatBroadcast(host, port); + await _driver.StopLogcatBroadcast(); + } + + [Test] + public async Task CanAddAndRemoveMultipleListeners() + { + var messageCount = 0; + using var messageSemaphore = new SemaphoreSlim(0, 10); + void listener1(string msg) + { + Interlocked.Increment(ref messageCount); + messageSemaphore.Release(); + } + void listener2(string msg) + { + Interlocked.Increment(ref messageCount); + messageSemaphore.Release(); + } + try + { + await _driver.StartLogcatBroadcast(); + // Add multiple listeners + _driver.AddLogcatMessagesListener(listener1); + _driver.AddLogcatMessagesListener(listener2); + + // Trigger activity to generate logs + _driver.BackgroundApp(TimeSpan.FromMilliseconds(500)); + // Wait a bit for messages (both listeners should be called) + var received = messageSemaphore.Wait(TimeSpan.FromSeconds(5)); + if (received) + { + // If we received messages, both listeners should have been invoked + Assert.That(messageCount, Is.GreaterThanOrEqualTo(1), + "At least one listener should have been invoked"); + } + // Remove all listeners + _driver.RemoveAllLogcatListeners(); + // Reset counter + messageCount = 0; + // Trigger more activity + _driver.BackgroundApp(TimeSpan.FromMilliseconds(500)); + // Wait a bit - no new messages should be counted after removing listeners + Thread.Sleep(2000); + Assert.That(messageCount, Is.EqualTo(0), + "No listeners should be invoked after removing all listeners"); + } + finally + { + await _driver.StopLogcatBroadcast(); + _driver.RemoveAllLogcatListeners(); + } + } + + [Test] + public async Task CanHandleErrorsGracefully() + { + var errorReceived = false; + using var errorSemaphore = new SemaphoreSlim(0, 1); + Exception capturedError = null; + _driver.AddLogcatErrorsListener(ex => + { + Console.WriteLine($"Error handler invoked: {ex.Message}"); + capturedError = ex; + errorReceived = true; + errorSemaphore.Release(); + }); + try + { + // Start broadcast - may fail if endpoint is not available + try + { + await _driver.StartLogcatBroadcast(); + + // Give it time to connect + Thread.Sleep(2000); + // If we got here without errors during connection, that's good + if (!errorReceived) + { + Assert.Pass("Logcat broadcast started successfully without errors"); + } + else + { + // If error occurred during startup, verify error handler was invoked + Assert.That(errorReceived, Is.True, + "Error handler should be invoked when connection fails"); + Assert.That(capturedError, Is.Not.Null, + "Error should be captured by the error handler"); + } + } + catch (AggregateException) + { + // Connection failure is expected in some environments + // Verify that error handler was invoked + Assert.That(errorReceived, Is.True, + "Error handler should be invoked when WebSocket connection fails"); + Assert.That(capturedError, Is.Not.Null, + "Error should be captured by the error handler"); + } + } + finally + { + try + { + await _driver.StopLogcatBroadcast(); + } + catch (Exception ex) + { + Console.WriteLine($"Exception during StopLogcatBroadcast: {ex}"); + } + _driver.RemoveAllLogcatListeners(); + } + } + } +}