Path: blob/trunk/dotnet/test/webdriver/BiDi/FakeTransport.cs
11817 views
// <copyright file="FakeTransport.cs" company="Selenium Committers">
// 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.
// </copyright>
using System.Buffers;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using OpenQA.Selenium.BiDi;
namespace OpenQA.Selenium.Tests.BiDi;
/// <summary>
/// A controllable in-process <see cref="ITransport"/> for unit-testing BiDi
/// functionality without a real browser or network connection.
/// </summary>
/// <remarks>
/// <para>
/// Outgoing commands (BiDi → transport) are captured in <see cref="SentMessages"/>
/// and can be inspected after the fact.
/// </para>
/// <para>
/// Incoming messages (transport → BiDi) are delivered one at a time from an
/// internal queue. Tests push pre-scripted JSON strings via <see cref="Enqueue"/>,
/// <see cref="EnqueueSuccess"/>, <see cref="EnqueueError"/>, or
/// <see cref="EnqueueEvent"/>. If the queue is empty, <see cref="ReceiveAsync"/>
/// blocks until a message is enqueued or the cancellation token fires — which is
/// exactly what makes timeout and cancellation tests deterministic.
/// </para>
/// </remarks>
internal sealed class FakeTransport : ITransport
{
private readonly Channel<string> _incoming = Channel.CreateUnbounded<string>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly SemaphoreSlim _sendSignal = new(0);
/// <summary>All JSON strings that BiDi has sent through this transport.</summary>
public List<string> SentMessages { get; } = [];
/// <summary>
/// Waits asynchronously until at least <paramref name="count"/> commands
/// have been sent, then returns a snapshot of <see cref="SentMessages"/>.
/// </summary>
public async Task<IReadOnlyList<string>> WaitForSentMessagesAsync(
int count = 1,
CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
while (SentMessages.Count < count)
{
await Task.Delay(5, cts.Token).ConfigureAwait(false);
}
return [.. SentMessages];
}
/// <summary>Enqueues a raw JSON string to be returned by the next <see cref="ReceiveAsync"/> call.</summary>
public void Enqueue(string json)
=> _incoming.Writer.TryWrite(json);
/// <summary>
/// Enqueues a BiDi <c>success</c> response for the command with the given <paramref name="id"/>.
/// </summary>
/// <param name="id">The command id taken from the outgoing message.</param>
/// <param name="resultJson">The JSON object to embed in the <c>result</c> field.</param>
public void EnqueueSuccess(long id, string resultJson = "{}")
=> Enqueue($$"""{"id":{{id}},"type":"success","result":{{resultJson}}}""");
/// <summary>
/// Enqueues a BiDi <c>error</c> response for the command with the given <paramref name="id"/>.
/// </summary>
public void EnqueueError(long id, string error = "unknown error", string message = "")
=> Enqueue($$"""{"id":{{id}},"type":"error","error":"{{error}}","message":"{{message}}"}""");
/// <summary>Enqueues a BiDi event message.</summary>
/// <param name="method">The fully-qualified event method name, e.g. <c>log.entryAdded</c>.</param>
/// <param name="paramsJson">The JSON object to embed in the <c>params</c> field.</param>
public void EnqueueEvent(string method, string paramsJson = "{}")
=> Enqueue($$"""{"type":"event","method":"{{method}}","params":{{paramsJson}}}""");
/// <summary>
/// Reads the command id from the last sent message.
/// Useful for building a matching success / error response.
/// </summary>
public long LastSentCommandId()
{
var json = SentMessages[^1];
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("id").GetInt64();
}
public async Task ReceiveAsync(IBufferWriter<byte> writer, CancellationToken cancellationToken)
{
var json = await _incoming.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var bytes = Encoding.UTF8.GetBytes(json);
writer.Write(bytes);
}
public Task SendAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
SentMessages.Add(Encoding.UTF8.GetString(data.Span));
_sendSignal.Release();
return Task.CompletedTask;
}
/// <summary>
/// Waits until the next command is sent through this transport.
/// </summary>
internal async Task WaitForNextSendAsync(CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
await _sendSignal.WaitAsync(cts.Token).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
_incoming.Writer.TryComplete();
return ValueTask.CompletedTask;
}
}
internal static class FakeTransportExtensions
{
/// <summary>
/// Waits for the command to be sent, enqueues a success response, and awaits the result.
/// </summary>
public static async Task<T> WithResponse<T>(this Task<T> task, FakeTransport transport, string resultJson = "{}")
{
await transport.WaitForNextSendAsync();
transport.EnqueueSuccess(transport.LastSentCommandId(), resultJson);
return await task;
}
/// <inheritdoc cref="WithResponse{T}(Task{T}, FakeTransport, string)"/>
public static async ValueTask WithResponse(this ValueTask task, FakeTransport transport, string resultJson = "{}")
{
await transport.WaitForNextSendAsync();
transport.EnqueueSuccess(transport.LastSentCommandId(), resultJson);
await task;
}
/// <summary>
/// Like <see cref="WithResponse{T}"/> but enqueues a raw JSON string instead of a success envelope.
/// </summary>
public static async Task<T> WithRawResponse<T>(this Task<T> task, FakeTransport transport, string json)
{
await transport.WaitForNextSendAsync();
transport.Enqueue(json);
return await task;
}
/// <summary>
/// Waits for the command to be sent, enqueues an error response, and awaits the (faulted) result.
/// </summary>
public static async Task<T> WithErrorResponse<T>(this Task<T> task, FakeTransport transport, string error = "unknown error", string message = "")
{
await transport.WaitForNextSendAsync();
transport.EnqueueError(transport.LastSentCommandId(), error, message);
return await task;
}
}