diff --git a/.gitignore b/.gitignore index 246f2c4bdaf7d..aeb238e4e07a0 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,7 @@ javascript/selenium-webdriver/.vscode/settings.json dotnet-bin .metadata/ .npmrc + +# HAR files and directories generated during tests +*.har +selenium-har-*/ diff --git a/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs b/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs new file mode 100644 index 0000000000000..5499681e0963c --- /dev/null +++ b/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs @@ -0,0 +1,73 @@ +// +// 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.Threading.Tasks; + +namespace OpenQA.Selenium.BiDi.Network.Har; + +/// +/// Extension methods for BiDi class to capture network traffic as HAR. +/// +public static class BiDiHarExtensions +{ + /// + /// Records network traffic and returns a HAR recorder that can be used to save the recorded traffic. + /// + /// The BiDi instance. + /// Optional configuration options. + /// A task that represents the asynchronous operation and returns a IHarRecorder. + public static async Task RecordHarAsync(this BiDi bidi, HarRecordingOptions? options = null) + { + if (bidi is null) throw new ArgumentNullException(nameof(bidi)); + + var recorder = new HarRecorder(bidi, options ?? new HarRecordingOptions()); + await recorder.StartAsync().ConfigureAwait(false); + return recorder; + } +} + +/// +/// Options for HAR recording. +/// +public sealed class HarRecordingOptions +{ + /// + /// Gets or sets the browser name to include in the HAR file. + /// + public string? BrowserName { get; set; } + + /// + /// Gets or sets the browser version to include in the HAR file. + /// + public string? BrowserVersion { get; set; } +} + +/// +/// Interface for recording network traffic and saving it as HAR format. +/// +public interface IHarRecorder : IAsyncDisposable +{ + /// + /// Saves the captured network traffic to a HAR file. + /// + /// The path where the HAR file should be saved. + /// A task that represents the asynchronous operation. + Task SaveAsync(string filePath); +} diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs new file mode 100644 index 0000000000000..e633874fca361 --- /dev/null +++ b/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs @@ -0,0 +1,469 @@ +// +// 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.Collections.Generic; + +namespace OpenQA.Selenium.BiDi.Network.Har; + +/// +/// Represents a HAR entry object containing request and response information. +/// +public sealed class HarEntry +{ + /// + /// Gets or sets the reference to the parent page. + /// + public string? Pageref { get; set; } + + /// + /// Gets or sets the date and time stamp of the request start. + /// + public string StartedDateTime { get; set; } = string.Empty; + + /// + /// Gets or sets the total elapsed time in milliseconds. + /// + public double Time { get; set; } + + /// + /// Gets or sets the request information. + /// + public HarRequest Request { get; set; } = new HarRequest(); + + /// + /// Gets or sets the response information. + /// + public HarResponse Response { get; set; } = new HarResponse(); + + /// + /// Gets or sets the cache information. + /// + public HarCache Cache { get; set; } = new HarCache(); + + /// + /// Gets or sets the timing information. + /// + public HarTimings Timings { get; set; } = new HarTimings(); + + /// + /// Gets or sets the server IP address. + /// + public string? ServerIPAddress { get; set; } + + /// + /// Gets or sets the connection information. + /// + public string? Connection { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR request object. +/// +public sealed class HarRequest +{ + /// + /// Gets or sets the request method. + /// + public string Method { get; set; } = string.Empty; + + /// + /// Gets or sets the absolute URL of the request. + /// + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the request HTTP version. + /// + public string HttpVersion { get; set; } = string.Empty; + + /// + /// Gets or sets the list of cookie objects. + /// + public List Cookies { get; set; } = new List(); + + /// + /// Gets or sets the list of header objects. + /// + public List Headers { get; set; } = new List(); + + /// + /// Gets or sets the list of query parameter objects. + /// + public List QueryString { get; set; } = new List(); + + /// + /// Gets or sets the posted data information. + /// + public HarPostData? PostData { get; set; } + + /// + /// Gets or sets the total number of bytes from the start of the HTTP request message. + /// + public long HeadersSize { get; set; } = -1; + + /// + /// Gets or sets the size of the request body in bytes. + /// + public long BodySize { get; set; } = -1; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR response object. +/// +public sealed class HarResponse +{ + /// + /// Gets or sets the response status code. + /// + public int Status { get; set; } + + /// + /// Gets or sets the response status description. + /// + public string StatusText { get; set; } = string.Empty; + + /// + /// Gets or sets the response HTTP version. + /// + public string HttpVersion { get; set; } = string.Empty; + + /// + /// Gets or sets the list of cookie objects. + /// + public List Cookies { get; set; } = new List(); + + /// + /// Gets or sets the list of header objects. + /// + public List Headers { get; set; } = new List(); + + /// + /// Gets or sets the response body content. + /// + public HarContent Content { get; set; } = new HarContent(); + + /// + /// Gets or sets the redirection target URL from the Location response header. + /// + public string RedirectURL { get; set; } = string.Empty; + + /// + /// Gets or sets the total number of bytes from the start of the HTTP response message. + /// + public long HeadersSize { get; set; } = -1; + + /// + /// Gets or sets the size of the received response body in bytes. + /// + public long BodySize { get; set; } = -1; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR cookie object. +/// +public sealed class HarCookie +{ + /// + /// Gets or sets the cookie name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the cookie value. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Gets or sets the path pertaining to the cookie. + /// + public string? Path { get; set; } + + /// + /// Gets or sets the host of the cookie. + /// + public string? Domain { get; set; } + + /// + /// Gets or sets the cookie expiration time. + /// + public string? Expires { get; set; } + + /// + /// Gets or sets a value indicating whether the cookie is HTTP only. + /// + public bool? HttpOnly { get; set; } + + /// + /// Gets or sets a value indicating whether the cookie is secure. + /// + public bool? Secure { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR header object. +/// +public sealed class HarHeader +{ + /// + /// Gets or sets the header name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the header value. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR query parameter object. +/// +public sealed class HarQueryParam +{ + /// + /// Gets or sets the query parameter name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the query parameter value. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents HAR post data information. +/// +public sealed class HarPostData +{ + /// + /// Gets or sets the MIME type of the posted data. + /// + public string MimeType { get; set; } = string.Empty; + + /// + /// Gets or sets the list of posted parameters. + /// + public List? Params { get; set; } + + /// + /// Gets or sets the plain text posted data. + /// + public string? Text { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR post parameter object. +/// +public sealed class HarPostParam +{ + /// + /// Gets or sets the parameter name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the parameter value. + /// + public string? Value { get; set; } + + /// + /// Gets or sets the name of the uploaded file. + /// + public string? FileName { get; set; } + + /// + /// Gets or sets the content type of the uploaded file. + /// + public string? ContentType { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents HAR content information. +/// +public sealed class HarContent +{ + /// + /// Gets or sets the length of the content in bytes. + /// + public long Size { get; set; } + + /// + /// Gets or sets the length of the returned content in bytes. + /// + public long? Compression { get; set; } + + /// + /// Gets or sets the MIME type of the response. + /// + public string MimeType { get; set; } = string.Empty; + + /// + /// Gets or sets the response body text. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the encoding used for the response text. + /// + public string? Encoding { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents HAR cache information. +/// +public sealed class HarCache +{ + /// + /// Gets or sets the state of the cache entry before the request. + /// + public HarCacheEntry? BeforeRequest { get; set; } + + /// + /// Gets or sets the state of the cache entry after the request. + /// + public HarCacheEntry? AfterRequest { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a HAR cache entry object. +/// +public sealed class HarCacheEntry +{ + /// + /// Gets or sets the expiration time of the cache entry. + /// + public string? Expires { get; set; } + + /// + /// Gets or sets the last accessed time of the cache entry. + /// + public string LastAccess { get; set; } = string.Empty; + + /// + /// Gets or sets the ETag. + /// + public string ETag { get; set; } = string.Empty; + + /// + /// Gets or sets the number of times the entry has been opened. + /// + public int HitCount { get; set; } + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents HAR timing information. +/// +public sealed class HarTimings +{ + /// + /// Gets or sets the time spent in a queue waiting for a network connection. + /// + public double Blocked { get; set; } = -1; + + /// + /// Gets or sets the DNS resolution time. + /// + public double Dns { get; set; } = -1; + + /// + /// Gets or sets the time required to create a TCP connection. + /// + public double Connect { get; set; } = -1; + + /// + /// Gets or sets the time required for SSL/TLS negotiation. + /// + public double Ssl { get; set; } = -1; + + /// + /// Gets or sets the time required to send the HTTP request to the server. + /// + public double Send { get; set; } = -1; + + /// + /// Gets or sets the waiting for a response from the server. + /// + public double Wait { get; set; } = -1; + + /// + /// Gets or sets the time required to read the entire response from the server. + /// + public double Receive { get; set; } = -1; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs new file mode 100644 index 0000000000000..213e75daf48dd --- /dev/null +++ b/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs @@ -0,0 +1,163 @@ +// +// 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.Collections.Generic; + +namespace OpenQA.Selenium.BiDi.Network.Har; + +/// +/// Represents the root object of exported HAR data. +/// +public sealed class HarFile +{ + /// + /// Gets or sets the log object. + /// + public HarLog Log { get; set; } = new HarLog(); +} + +/// +/// Represents a HAR log object. +/// +public sealed class HarLog +{ + /// + /// Gets or sets the version of the HAR format. + /// + public string Version { get; set; } = "1.2"; + + /// + /// Gets or sets the creator information. + /// + public HarCreator Creator { get; set; } = new HarCreator(); + + /// + /// Gets or sets the browser information. + /// + public HarBrowser? Browser { get; set; } + + /// + /// Gets or sets the list of page objects. + /// + public List Pages { get; set; } = new List(); + + /// + /// Gets or sets the list of entry objects. + /// + public List Entries { get; set; } = new List(); + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents the creator information. +/// +public sealed class HarCreator +{ + /// + /// Gets or sets the name of the creator. + /// + public string Name { get; set; } = "Selenium"; + + /// + /// Gets or sets the version of the creator. + /// + public string Version { get; set; } = "4.0"; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents the browser information. +/// +public sealed class HarBrowser +{ + /// + /// Gets or sets the name of the browser. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the version of the browser. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents a page object. +/// +public sealed class HarPage +{ + /// + /// Gets or sets the unique identifier for the page. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the page title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the date and time stamp for the page. + /// + public string StartedDateTime { get; set; } = string.Empty; + + /// + /// Gets or sets the page timings. + /// + public HarPageTimings PageTimings { get; set; } = new HarPageTimings(); + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} + +/// +/// Represents page timing information. +/// +public sealed class HarPageTimings +{ + /// + /// Gets or sets the time in milliseconds for the page to load. + /// + public double OnContentLoad { get; set; } = -1; + + /// + /// Gets or sets the time in milliseconds for the page to finish loading. + /// + public double OnLoad { get; set; } = -1; + + /// + /// Gets or sets an optional comment. + /// + public string? Comment { get; set; } +} diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs new file mode 100644 index 0000000000000..34e62d7b3a392 --- /dev/null +++ b/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs @@ -0,0 +1,410 @@ +// +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace OpenQA.Selenium.BiDi.Network.Har; + +/// +/// Records network traffic and provides methods to save it as HAR format. +/// +public sealed class HarRecorder : IHarRecorder +{ + private readonly BiDi _bidi; + private readonly HarRecordingOptions _options; + private readonly HarFile _harFile; + private readonly Dictionary _pendingRequests; + private readonly string _tempDirectoryPath; + private Subscription? _beforeRequestSubscription; + private Subscription? _responseStartedSubscription; + private Subscription? _responseCompletedSubscription; + private Collector? _dataCollector; + + internal HarRecorder(BiDi bidi, HarRecordingOptions options) + { + _bidi = bidi ?? throw new ArgumentNullException(nameof(bidi)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _harFile = new HarFile(); + _pendingRequests = new Dictionary(); + _tempDirectoryPath = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDirectoryPath); + + if (!string.IsNullOrEmpty(options.BrowserName)) + { + _harFile.Log.Browser = new HarBrowser + { + Name = options.BrowserName, + Version = options.BrowserVersion ?? string.Empty + }; + } + } + + internal async Task StartAsync() + { + // Always create data collector for capturing request and response bodies + _dataCollector = await _bidi.Network.AddDataCollectorAsync([DataType.Request, DataType.Response], 200000000).ConfigureAwait(false); + + _beforeRequestSubscription = await _bidi.Network.OnBeforeRequestSentAsync(OnBeforeRequestSent).ConfigureAwait(false); + _responseStartedSubscription = await _bidi.Network.OnResponseStartedAsync(OnResponseStarted).ConfigureAwait(false); + _responseCompletedSubscription = await _bidi.Network.OnResponseCompletedAsync(OnResponseCompleted).ConfigureAwait(false); + } + + private void OnBeforeRequestSent(BeforeRequestSentEventArgs args) + { + var entry = new HarEntry + { + StartedDateTime = args.Timestamp.ToString("o"), + Request = ConvertRequest(args.Request), + Timings = ConvertTimings(args.Request.Timings), + Time = 0 + }; + + if (args.Context != null) + { + entry.Pageref = args.Context.Id; + } + + lock (_pendingRequests) + { + _pendingRequests[args.Request.Request.Id] = entry; + } + } + + private void OnResponseStarted(ResponseStartedEventArgs args) + { + lock (_pendingRequests) + { + if (_pendingRequests.TryGetValue(args.Request.Request.Id, out var entry)) + { + entry.Response = ConvertResponse(args.Response); + } + } + } + + private async void OnResponseCompleted(ResponseCompletedEventArgs args) + { + HarEntry? entry = null; + + lock (_pendingRequests) + { + if (_pendingRequests.TryGetValue(args.Request.Request.Id, out entry)) + { + entry.Response = ConvertResponse(args.Response); + + // Calculate total time + var timings = args.Request.Timings; + entry.Time = CalculateTotalTime(timings); + } + } + + if (entry != null) + { + // Retrieve request and response bodies + if (_dataCollector != null) + { + try + { + // Get request body + var requestBody = await _bidi.Network.GetDataAsync(DataType.Request, args.Request.Request).ConfigureAwait(false); + if (requestBody != null) + { + entry.Request.PostData = new HarPostData + { + MimeType = GetContentType(entry.Request.Headers), + Text = (string)requestBody + }; + } + } + catch + { + // Request body may not be available for all requests (e.g., GET requests) + } + + try + { + // Get response body + var responseBody = await _bidi.Network.GetDataAsync(DataType.Response, args.Request.Request).ConfigureAwait(false); + if (responseBody != null) + { + var bodyText = (string)responseBody; + entry.Response.Content.Text = bodyText; + entry.Response.Content.Size = System.Text.Encoding.UTF8.GetByteCount(bodyText); + } + } + catch + { + // Response body may not be available for all responses + } + } + + // Flush entry to dedicated temp file (outside of lock for better concurrency) + FlushEntryToTempFile(entry, args.Request.Request.Id); + + lock (_pendingRequests) + { + _pendingRequests.Remove(args.Request.Request.Id); + } + } + } + + private void FlushEntryToTempFile(HarEntry entry, string requestId) + { + // Use dedicated file per entry for better concurrency safety + var sanitizedId = string.Concat(requestId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + var fileName = $"{sanitizedId}_{Guid.NewGuid()}.json"; + var filePath = Path.Combine(_tempDirectoryPath, fileName); + + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + var entryJson = JsonSerializer.Serialize(entry, jsonOptions); + File.WriteAllText(filePath, entryJson); + } + + private string GetContentType(List headers) + { + var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)); + return contentTypeHeader?.Value ?? "application/octet-stream"; + } + + private HarRequest ConvertRequest(RequestData request) + { + var harRequest = new HarRequest + { + Method = request.Method, + Url = request.Url, + HttpVersion = "HTTP/1.1", + HeadersSize = request.HeadersSize ?? -1, + BodySize = request.BodySize ?? -1 + }; + + foreach (var header in request.Headers) + { + harRequest.Headers.Add(new HarHeader + { + Name = header.Name, + Value = (string)header.Value + }); + } + + foreach (var cookie in request.Cookies) + { + harRequest.Cookies.Add(new HarCookie + { + Name = cookie.Name, + Value = (string)cookie.Value, + Domain = cookie.Domain, + Path = cookie.Path, + HttpOnly = cookie.HttpOnly, + Secure = cookie.Secure, + Expires = cookie.Expiry?.ToString("o") + }); + } + + // Parse query string from URL + var uri = new Uri(request.Url); + if (!string.IsNullOrEmpty(uri.Query)) + { + var queryString = uri.Query.TrimStart('?'); + var queryParams = queryString.Split('&'); + foreach (var param in queryParams) + { + var parts = param.Split('=', 2); + harRequest.QueryString.Add(new HarQueryParam + { + Name = Uri.UnescapeDataString(parts[0]), + Value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty + }); + } + } + + return harRequest; + } + + private HarResponse ConvertResponse(ResponseData response) + { + var harResponse = new HarResponse + { + Status = response.Status, + StatusText = response.StatusText, + HttpVersion = response.Protocol, + HeadersSize = response.HeadersSize ?? -1, + BodySize = response.BodySize ?? -1, + Content = new HarContent + { + Size = response.BodySize ?? 0, + MimeType = response.MimeType + } + }; + + foreach (var header in response.Headers) + { + var headerValue = (string)header.Value; + harResponse.Headers.Add(new HarHeader + { + Name = header.Name, + Value = headerValue + }); + + // Check for redirect URL + if (header.Name.Equals("Location", StringComparison.OrdinalIgnoreCase)) + { + harResponse.RedirectURL = headerValue; + } + } + + return harResponse; + } + + private HarTimings ConvertTimings(FetchTimingInfo timings) + { + return new HarTimings + { + Blocked = -1, + Dns = CalculateDuration(timings.DnsStart, timings.DnsEnd), + Connect = CalculateDuration(timings.ConnectStart, timings.ConnectEnd), + Ssl = CalculateDuration(timings.TlsStart, timings.ConnectEnd), + Send = CalculateDuration(timings.RequestStart, timings.RequestStart), + Wait = CalculateDuration(timings.RequestStart, timings.ResponseStart), + Receive = CalculateDuration(timings.ResponseStart, timings.ResponseEnd) + }; + } + + private double CalculateDuration(double start, double end) + { + if (start < 0 || end < 0 || end < start) + { + return -1; + } + return end - start; + } + + private double CalculateTotalTime(FetchTimingInfo timings) + { + if (timings.FetchStart >= 0 && timings.ResponseEnd >= 0 && timings.ResponseEnd >= timings.FetchStart) + { + return timings.ResponseEnd - timings.FetchStart; + } + return 0; + } + + private void LoadEntriesFromTempFiles() + { + _harFile.Log.Entries.Clear(); + + if (Directory.Exists(_tempDirectoryPath)) + { + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var files = Directory.GetFiles(_tempDirectoryPath, "*.json"); + foreach (var file in files) + { + try + { + var entryJson = File.ReadAllText(file); + var entry = JsonSerializer.Deserialize(entryJson, jsonOptions); + if (entry != null) + { + _harFile.Log.Entries.Add(entry); + } + } + catch + { + // Skip corrupted or incomplete files + } + } + } + } + + /// + /// Saves the captured network traffic to a HAR file. + /// + /// The path where the HAR file should be saved. + /// A task that represents the asynchronous operation. + public async Task SaveAsync(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException("File path cannot be null or empty.", nameof(filePath)); + } + + // Load entries from temp files before saving + LoadEntriesFromTempFiles(); + + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + var json = JsonSerializer.Serialize(_harFile, options); + await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + } + + /// + /// Disposes the recorder and unsubscribes from network events. + /// + public async ValueTask DisposeAsync() + { + if (_beforeRequestSubscription != null) + { + await _beforeRequestSubscription.DisposeAsync().ConfigureAwait(false); + } + + if (_responseStartedSubscription != null) + { + await _responseStartedSubscription.DisposeAsync().ConfigureAwait(false); + } + + if (_responseCompletedSubscription != null) + { + await _responseCompletedSubscription.DisposeAsync().ConfigureAwait(false); + } + + if (_dataCollector != null) + { + await _bidi.Network.RemoveDataCollectorAsync(_dataCollector).ConfigureAwait(false); + } + + // Clean up temp directory + if (Directory.Exists(_tempDirectoryPath)) + { + try + { + Directory.Delete(_tempDirectoryPath, recursive: true); + } + catch + { + // Ignore errors when deleting temp directory + } + } + } +} diff --git a/dotnet/src/webdriver/BiDi/Network/Har/README.md b/dotnet/src/webdriver/BiDi/Network/Har/README.md new file mode 100644 index 0000000000000..8179a1b5c7d06 --- /dev/null +++ b/dotnet/src/webdriver/BiDi/Network/Har/README.md @@ -0,0 +1,73 @@ +# HAR Recording Extension for BiDi + +This extension provides the ability to record network traffic using the BiDi protocol and export it to HAR (HTTP Archive) format, including request and response body content. + +## Usage Example + +```csharp +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.BiDi; +using OpenQA.Selenium.BiDi.Network.Har; + +// Create a WebDriver instance with BiDi enabled +var options = new ChromeOptions(); +options.AddArgument("--remote-allow-origins=*"); +options.EnableBiDi(); + +using var driver = new ChromeDriver(options); + +// Connect to BiDi +await using var bidi = await driver.AsBiDiAsync(); + +// Start recording network traffic (includes request/response bodies by default) +await using var recorder = await bidi.RecordHarAsync(new HarRecordingOptions +{ + BrowserName = "Chrome", + BrowserVersion = "120.0" +}); + +// Navigate to a page +driver.Navigate().GoToUrl("https://www.example.com"); + +// Wait for some network activity +await Task.Delay(2000); + +// Save the recorded traffic to a HAR file +await recorder.SaveAsync("network-traffic.har"); +``` + +## HAR Recording Options + +The `HarRecordingOptions` class allows you to configure the recording: + +- `BrowserName`: The browser name to include in the HAR metadata +- `BrowserVersion`: The browser version to include in the HAR metadata + +## HAR File Format + +The generated HAR file follows the HAR 1.2 specification and includes: + +- Request details (method, URL, headers, cookies, query parameters) +- Response details (status code, headers, content type) +- Timing information (DNS, connect, SSL, send, wait, receive) +- Request/response body content (automatically recorded) +- Metadata (browser info, timestamps) + +## Body Content Recording + +By default, the HAR recorder records request and response bodies for all network traffic. A network data collector is automatically created when you start recording traffic. This provides complete visibility into all request and response payloads. + +**Memory Optimization:** To minimize memory usage, recorded network entries are written to a temporary file as they are completed. The entries are only loaded into memory when you call `SaveAsync()`. This allows for recording large amounts of network traffic without consuming excessive memory. + +**Note:** Recording request/response bodies may increase memory usage for large requests/responses. + +## Disposing the Recorder + +The `IHarRecorder` implements `IAsyncDisposable` and should be disposed properly to unsubscribe from network events and clean up the data collector: + +```csharp +await using var recorder = await bidi.RecordHarAsync(); +// ... record network traffic ... +// Dispose is called automatically when leaving the using block +``` diff --git a/dotnet/test/common/BiDi/Network/HarRecordingTest.cs b/dotnet/test/common/BiDi/Network/HarRecordingTest.cs new file mode 100644 index 0000000000000..9c0db50188be6 --- /dev/null +++ b/dotnet/test/common/BiDi/Network/HarRecordingTest.cs @@ -0,0 +1,190 @@ +// +// 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 NUnit.Framework; +using OpenQA.Selenium.BiDi.BrowsingContext; +using OpenQA.Selenium.BiDi.Network.Har; +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace OpenQA.Selenium.BiDi.Network; + +class HarRecordingTest : BiDiTestFixture +{ + [Test] + public async Task CanRecordNetworkTrafficToHar() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har"); + + try + { + await using var recorder = await bidi.RecordHarAsync(new HarRecordingOptions + { + BrowserName = "TestBrowser", + BrowserVersion = "1.0" + }); + + await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete }); + + await recorder.SaveAsync(tempFile); + + Assert.That(File.Exists(tempFile), Is.True); + + var jsonContent = await File.ReadAllTextAsync(tempFile); + Assert.That(jsonContent, Is.Not.Empty); + + var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.That(harFile, Is.Not.Null); + Assert.That(harFile.Log, Is.Not.Null); + Assert.That(harFile.Log.Version, Is.EqualTo("1.2")); + Assert.That(harFile.Log.Creator.Name, Is.EqualTo("Selenium")); + Assert.That(harFile.Log.Browser, Is.Not.Null); + Assert.That(harFile.Log.Browser.Name, Is.EqualTo("TestBrowser")); + Assert.That(harFile.Log.Browser.Version, Is.EqualTo("1.0")); + Assert.That(harFile.Log.Entries, Is.Not.Empty); + + var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html")); + Assert.That(entry, Is.Not.Null); + Assert.That(entry.Request.Method, Is.EqualTo("GET")); + Assert.That(entry.Response.Status, Is.EqualTo(200)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Test] + public async Task CanSaveHarToFile() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har"); + + try + { + await using var recorder = await bidi.RecordHarAsync(); + + await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete }); + + await recorder.SaveAsync(tempFile); + + Assert.That(File.Exists(tempFile), Is.True); + + var jsonContent = await File.ReadAllTextAsync(tempFile); + Assert.That(jsonContent, Is.Not.Empty); + + var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.That(harFile, Is.Not.Null); + Assert.That(harFile.Log, Is.Not.Null); + Assert.That(harFile.Log.Entries, Is.Not.Empty); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Test] + public async Task HarEntriesContainRequestDetails() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har"); + + try + { + await using var recorder = await bidi.RecordHarAsync(); + + await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete }); + + await recorder.SaveAsync(tempFile); + + var jsonContent = await File.ReadAllTextAsync(tempFile); + var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html")); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry.StartedDateTime, Is.Not.Empty); + Assert.That(entry.Time, Is.GreaterThanOrEqualTo(0)); + Assert.That(entry.Request.Headers, Is.Not.Empty); + Assert.That(entry.Response.Headers, Is.Not.Empty); + Assert.That(entry.Timings, Is.Not.Null); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Test] + public async Task CanRecordRequestAndResponseBodies() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har"); + + try + { + await using var recorder = await bidi.RecordHarAsync(); + + await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete }); + + await recorder.SaveAsync(tempFile); + + var jsonContent = await File.ReadAllTextAsync(tempFile); + var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html")); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry.Response.Content.Text, Is.Not.Null); + Assert.That(entry.Response.Content.Text, Is.Not.Empty); + Assert.That(entry.Response.Content.Size, Is.GreaterThan(0)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +}