blob: 86f20c64c6f2c75d2725f37c206564c437684ae3 [file] [log] [blame] [edit]
// <copyright file="JavaScriptEngine.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 OpenQA.Selenium.DevTools;
using OpenQA.Selenium.Internal;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenQA.Selenium;
/// <summary>
/// Provides methods allowing the user to manage settings in the browser's JavaScript engine.
/// </summary>
[RequiresUnreferencedCode("JavaScriptEngine is currently implemented with CDP. When it is implemented with BiDi, AOT will be supported")]
[RequiresDynamicCode("JavaScriptEngine is currently implemented with CDP. When it is implemented with BiDi, AOT will be supported.")]
public class JavaScriptEngine : IJavaScriptEngine
{
private const string MonitorBindingName = "__webdriver_attribute";
private readonly IWebDriver driver;
private readonly Lazy<DevToolsSession> session;
private readonly Dictionary<string, InitializationScript> initializationScripts = new Dictionary<string, InitializationScript>();
private readonly Dictionary<string, PinnedScript> pinnedScripts = new Dictionary<string, PinnedScript>();
private readonly HashSet<string> bindings = new HashSet<string>();
private bool isEnabled = false;
private bool isDisposed = false;
/// <summary>
/// Initializes a new instance of the <see cref="JavaScriptEngine"/> class.
/// </summary>
/// <param name="driver">The <see cref="IWebDriver"/> instance in which the JavaScript engine is executing.</param>
public JavaScriptEngine(IWebDriver driver)
{
// Use of Lazy<T> means this exception won't be thrown until the user first
// attempts to access the DevTools session, probably on the first call to
// StartEventMonitoring() or in adding scripts to the instance.
this.driver = driver;
this.session = new Lazy<DevToolsSession>(() =>
{
if (driver is not IDevTools devToolsDriver)
{
throw new WebDriverException("Driver must implement IDevTools to use these features");
}
return devToolsDriver.GetDevToolsSession();
});
}
/// <summary>
/// Occurs when a JavaScript callback with a named binding is executed.
/// </summary>
public event EventHandler<JavaScriptCallbackExecutedEventArgs>? JavaScriptCallbackExecuted;
/// <summary>
/// Occurs when an exception is thrown by JavaScript being executed in the browser.
/// </summary>
public event EventHandler<JavaScriptExceptionThrownEventArgs>? JavaScriptExceptionThrown;
/// <summary>
/// Occurs when methods on the JavaScript console are called.
/// </summary>
public event EventHandler<JavaScriptConsoleApiCalledEventArgs>? JavaScriptConsoleApiCalled;
/// <summary>
/// Occurs when a value of an attribute in an element is being changed.
/// </summary>
public event EventHandler<DomMutatedEventArgs>? DomMutated;
/// <summary>
/// Gets the read-only list of initialization scripts added for this JavaScript engine.
/// </summary>
public IReadOnlyList<InitializationScript> InitializationScripts
{
get
{
// Return a copy.
return new List<InitializationScript>(this.initializationScripts.Values);
}
}
/// <summary>
/// Gets the read-only list of bindings added for this JavaScript engine.
/// </summary>
public IReadOnlyList<string> ScriptCallbackBindings
{
get
{
// Return a copy.
return new List<string>(this.bindings);
}
}
/// <summary>
/// Asynchronously starts monitoring for events from the browser's JavaScript engine.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartEventMonitoring()
{
this.session.Value.Domains.JavaScript.BindingCalled += OnScriptBindingCalled;
this.session.Value.Domains.JavaScript.ExceptionThrown += OnJavaScriptExceptionThrown;
this.session.Value.Domains.JavaScript.ConsoleApiCalled += OnConsoleApiCalled;
await this.EnableDomains().ConfigureAwait(false);
}
/// <summary>
/// Stops monitoring for events from the browser's JavaScript engine.
/// </summary>
public void StopEventMonitoring()
{
this.session.Value.Domains.JavaScript.ConsoleApiCalled -= OnConsoleApiCalled;
this.session.Value.Domains.JavaScript.ExceptionThrown -= OnJavaScriptExceptionThrown;
this.session.Value.Domains.JavaScript.BindingCalled -= OnScriptBindingCalled;
}
/// <summary>
/// Enables monitoring for DOM changes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EnableDomMutationMonitoring()
{
// Execute the script to have it enabled on the currently loaded page.
string script = ResourceUtilities.MutationListenerAtom;
await this.session.Value.Domains.JavaScript.Evaluate(script).ConfigureAwait(false);
await this.AddScriptCallbackBinding(MonitorBindingName).ConfigureAwait(false);
await this.AddInitializationScript(MonitorBindingName, script).ConfigureAwait(false);
}
/// <summary>
/// Disables monitoring for DOM changes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisableDomMutationMonitoring()
{
await this.RemoveScriptCallbackBinding(MonitorBindingName).ConfigureAwait(false);
await this.RemoveInitializationScript(MonitorBindingName).ConfigureAwait(false);
}
/// <summary>
/// Asynchronously adds JavaScript to be loaded on every document load.
/// </summary>
/// <param name="scriptName">The friendly name by which to refer to this initialization script.</param>
/// <param name="script">The JavaScript to be loaded on every page.</param>
/// <returns>A task containing an <see cref="InitializationScript"/> object representing the script to be loaded on each page.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="scriptName"/> or <paramref name="script"/> are <see langword="null"/>.</exception>
public async Task<InitializationScript> AddInitializationScript(string scriptName, [StringSyntax(StringSyntaxConstants.JavaScript)] string script)
{
if (scriptName is null)
{
throw new ArgumentNullException(nameof(scriptName));
}
if (script is null)
{
throw new ArgumentNullException(nameof(script));
}
if (this.initializationScripts.TryGetValue(scriptName, out InitializationScript? existingScript))
{
return existingScript;
}
await this.EnableDomains().ConfigureAwait(false);
string scriptId = await this.session.Value.Domains.JavaScript.AddScriptToEvaluateOnNewDocument(script).ConfigureAwait(false);
InitializationScript initializationScript = new InitializationScript(scriptId, scriptName, script);
this.initializationScripts[scriptName] = initializationScript;
return initializationScript;
}
/// <summary>
/// Asynchronously removes JavaScript from being loaded on every document load.
/// </summary>
/// <param name="scriptName">The friendly name of the initialization script to be removed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="scriptName"/> is <see langword="null"/>.</exception>
public async Task RemoveInitializationScript(string scriptName)
{
if (scriptName is null)
{
throw new ArgumentNullException(nameof(scriptName));
}
if (this.initializationScripts.TryGetValue(scriptName, out InitializationScript? script))
{
string scriptId = script.ScriptId;
await this.session.Value.Domains.JavaScript.RemoveScriptToEvaluateOnNewDocument(scriptId).ConfigureAwait(false);
this.initializationScripts.Remove(scriptName);
}
}
/// <summary>
/// Asynchronously removes all initialization scripts from being loaded on every document load.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ClearInitializationScripts()
{
// Use a copy of the list to prevent the iterator from becoming invalid
// when we modify the collection.
List<string> scriptNames = new List<string>(this.initializationScripts.Keys);
foreach (string scriptName in scriptNames)
{
await this.RemoveInitializationScript(scriptName).ConfigureAwait(false);
}
}
/// <summary>
/// Pins a JavaScript snippet for execution in the browser without transmitting the
/// entire script across the wire for every execution.
/// </summary>
/// <param name="script">The JavaScript to pin</param>
/// <returns>A task containing a <see cref="PinnedScript"/> object to use to execute the script.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="script"/> is <see langword="null"/>.</exception>
public async Task<PinnedScript> PinScript([StringSyntax(StringSyntaxConstants.JavaScript)] string script)
{
if (script == null)
{
throw new ArgumentNullException(nameof(script));
}
string newScriptHandle = Guid.NewGuid().ToString("N");
// We do an "Evaluate" first so as to immediately create the script on the loaded
// page, then will add it to the initialization of future pages.
await this.EnableDomains().ConfigureAwait(false);
string creationScript = PinnedScript.MakeCreationScript(newScriptHandle, script);
await this.session.Value.Domains.JavaScript.Evaluate(creationScript).ConfigureAwait(false);
string scriptId = await this.session.Value.Domains.JavaScript.AddScriptToEvaluateOnNewDocument(creationScript).ConfigureAwait(false);
PinnedScript pinnedScript = new PinnedScript(script, newScriptHandle, scriptId);
this.pinnedScripts[pinnedScript.Handle] = pinnedScript;
return pinnedScript;
}
/// <summary>
/// Unpins a previously pinned script from the browser.
/// </summary>
/// <param name="script">The <see cref="PinnedScript"/> object to unpin.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="script"/> is <see langword="null"/>.</exception>
public async Task UnpinScript(PinnedScript script)
{
if (script == null)
{
throw new ArgumentNullException(nameof(script));
}
if (this.pinnedScripts.ContainsKey(script.Handle))
{
await this.session.Value.Domains.JavaScript.Evaluate(script.MakeRemovalScript()).ConfigureAwait(false);
await this.session.Value.Domains.JavaScript.RemoveScriptToEvaluateOnNewDocument(script.ScriptId).ConfigureAwait(false);
this.pinnedScripts.Remove(script.Handle);
}
}
/// <summary>
/// Asynchronously adds a binding to a callback method that will raise an event when the named
/// binding is called by JavaScript executing in the browser.
/// </summary>
/// <param name="bindingName">The name of the callback that will trigger events when called by JavaScript executing in the browser.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="bindingName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">If A binding with the specified name already exists.</exception>
public async Task AddScriptCallbackBinding(string bindingName)
{
if (bindingName is null)
{
throw new ArgumentNullException(nameof(bindingName));
}
if (!this.bindings.Add(bindingName))
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "A binding named {0} has already been added", bindingName));
}
await this.EnableDomains().ConfigureAwait(false);
await this.session.Value.Domains.JavaScript.AddBinding(bindingName).ConfigureAwait(false);
}
/// <summary>
/// Asynchronously removes a binding to a JavaScript callback.
/// </summary>
/// <param name="bindingName">The name of the callback to be removed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">If <paramref name="bindingName"/> is <see langword="null"/>.</exception>
public async Task RemoveScriptCallbackBinding(string bindingName)
{
if (bindingName is null)
{
throw new ArgumentNullException(nameof(bindingName));
}
await this.session.Value.Domains.JavaScript.RemoveBinding(bindingName).ConfigureAwait(false);
_ = this.bindings.Remove(bindingName);
}
/// <summary>
/// Asynchronously removes all bindings to JavaScript callbacks.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ClearScriptCallbackBindings()
{
// Use a copy of the list to prevent the iterator from becoming invalid
// when we modify the collection.
List<string> bindingList = new List<string>(this.bindings);
foreach (string binding in bindingList)
{
await this.RemoveScriptCallbackBinding(binding).ConfigureAwait(false);
}
}
/// <summary>
/// Asynchronously removes all bindings to JavaScript callbacks, all
/// initialization scripts from being loaded for each document, and unpins
/// all pinned scripts.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ClearAll()
{
await this.ClearPinnedScripts().ConfigureAwait(false);
await this.ClearInitializationScripts().ConfigureAwait(false);
await this.ClearScriptCallbackBindings().ConfigureAwait(false);
}
/// <summary>
/// Asynchronously removes all bindings to JavaScript callbacks, all
/// initialization scripts from being loaded for each document, all
/// pinned scripts, and stops listening for events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task Reset()
{
this.StopEventMonitoring();
await ClearAll().ConfigureAwait(false);
}
/// <summary>
/// Releases all resources associated with this <see cref="JavaScriptEngine"/>.
/// </summary>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases all resources associated with this <see cref="JavaScriptEngine"/>.
/// </summary>
/// <param name="disposing"><see langword="true"/> if the Dispose method was explicitly called; otherwise, <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (!this.isDisposed)
{
if (disposing)
{
if (this.session.IsValueCreated)
{
this.session.Value.Dispose();
}
}
this.isDisposed = true;
}
}
private async Task ClearPinnedScripts()
{
// Use a copy of the list to prevent the iterator from becoming invalid
// when we modify the collection.
List<string> scriptHandles = new List<string>(this.pinnedScripts.Keys);
foreach (string scriptHandle in scriptHandles)
{
await this.UnpinScript(this.pinnedScripts[scriptHandle]).ConfigureAwait(false);
}
}
private async Task EnableDomains()
{
if (!this.isEnabled)
{
await this.session.Value.Domains.JavaScript.EnablePage().ConfigureAwait(false);
await this.session.Value.Domains.JavaScript.EnableRuntime().ConfigureAwait(false);
this.isEnabled = true;
}
}
private void OnScriptBindingCalled(object? sender, BindingCalledEventArgs e)
{
if (e.Name == MonitorBindingName)
{
DomMutationData valueChangeData = JsonSerializer.Deserialize<DomMutationData>(e.Payload) ?? throw new JsonException("DomMutationData returned null");
var locator = By.CssSelector($"*[data-__webdriver_id='{valueChangeData.TargetId}']");
valueChangeData.Element = driver.FindElements(locator).FirstOrDefault();
this.DomMutated?.Invoke(this, new DomMutatedEventArgs(valueChangeData));
}
this.JavaScriptCallbackExecuted?.Invoke(this, new JavaScriptCallbackExecutedEventArgs
(
scriptPayload: e.Payload,
bindingName: e.Name
));
}
private void OnJavaScriptExceptionThrown(object? sender, ExceptionThrownEventArgs e)
{
this.JavaScriptExceptionThrown?.Invoke(this, new JavaScriptExceptionThrownEventArgs(e.Message));
}
private void OnConsoleApiCalled(object? sender, ConsoleApiCalledEventArgs e)
{
if (this.JavaScriptConsoleApiCalled != null)
{
for (int i = 0; i < e.Arguments.Count; i++)
{
this.JavaScriptConsoleApiCalled(this, new JavaScriptConsoleApiCalledEventArgs
(
messageContent: e.Arguments[i].Value,
messageTimeStamp: e.Timestamp,
messageType: e.Type
));
}
}
}
}