| // <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 |
| )); |
| } |
| } |
| } |
| } |