Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run Selenium tests on Playwright via an adapter [experiment] #45284

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAppSample", "src\Framewo
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F43CC5EA-6032-4A11-A9B2-6D48CB5EB082}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.E2ETests.Playwright", "src\Components\test\E2ETest.Playwright\Microsoft.AspNetCore.Components.E2ETests.Playwright.csproj", "{6B89A6B7-0FFA-402D-880B-0ED961986E43}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -10513,6 +10515,22 @@ Global
{A8E2AB77-8F57-47C2-A961-2F316793CAFF}.Release|x64.Build.0 = Release|Any CPU
{A8E2AB77-8F57-47C2-A961-2F316793CAFF}.Release|x86.ActiveCfg = Release|Any CPU
{A8E2AB77-8F57-47C2-A961-2F316793CAFF}.Release|x86.Build.0 = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|arm64.ActiveCfg = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|arm64.Build.0 = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|x64.ActiveCfg = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|x64.Build.0 = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|x86.ActiveCfg = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Debug|x86.Build.0 = Debug|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|Any CPU.Build.0 = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|arm64.ActiveCfg = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|arm64.Build.0 = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|x64.ActiveCfg = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|x64.Build.0 = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|x86.ActiveCfg = Release|Any CPU
{6B89A6B7-0FFA-402D-880B-0ED961986E43}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -11378,6 +11396,7 @@ Global
{890B5210-48EF-488F-93A2-F13BCB07C780} = {4DA84F2B-1948-439B-85AB-E99E31331A9C}
{A8E2AB77-8F57-47C2-A961-2F316793CAFF} = {890B5210-48EF-488F-93A2-F13BCB07C780}
{F43CC5EA-6032-4A11-A9B2-6D48CB5EB082} = {4DA84F2B-1948-439B-85AB-E99E31331A9C}
{6B89A6B7-0FFA-402D-880B-0ED961986E43} = {0508E463-0269-40C9-B5C2-3B600FB2A28B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
Expand Down
1 change: 1 addition & 0 deletions src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"src\\Components\\Web\\test\\Microsoft.AspNetCore.Components.Web.Tests.csproj",
"src\\Components\\benchmarkapps\\Wasm.Performance\\Driver\\Wasm.Performance.Driver.csproj",
"src\\Components\\benchmarkapps\\Wasm.Performance\\TestApp\\Wasm.Performance.TestApp.csproj",
"src\\Components\\test\\E2ETest.Playwright\\Microsoft.AspNetCore.Components.E2ETests.Playwright.csproj",
"src\\Components\\test\\E2ETest\\Microsoft.AspNetCore.Components.E2ETests.csproj",
"src\\Components\\test\\testassets\\BasicTestApp\\BasicTestApp.csproj",
"src\\Components\\test\\testassets\\ComponentsApp.App\\ComponentsApp.App.csproj",
Expand Down
24 changes: 24 additions & 0 deletions src/Components/test/E2ETest.Playwright/E2ETestOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Xml.Linq;

namespace Microsoft.AspNetCore.E2ETesting;

internal class E2ETestOptions
{
public static readonly E2ETestOptions Instance = new E2ETestOptions();

public int DefaultWaitTimeoutInSeconds { get; set; } = 15;

public double DefaultAfterFailureWaitTimeoutInSeconds { get; set; } = 10;

public bool SauceTest => false;

public SauceOptions Sauce { get; } = new();

public class SauceOptions
{
public string HostName => throw new NotImplementedException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using Microsoft.Playwright;
using OpenQA.Selenium;

namespace Microsoft.AspNetCore.Components.E2ETests.Playwright.Infrastructure.Adapters;

public class BrowserAdapter : IAsyncDisposable, IJavaScriptExecutor, IWebDriver
{
private readonly IBrowserContext _browserContext;
public IPage CurrentPage { get; private set; }

public BrowserAdapter(IBrowserContext browserContext)
{
_browserContext = browserContext;
}

public async ValueTask DisposeAsync()
{
await _browserContext.DisposeAsync();
}

public void Navigate(Uri rootUri, string relativeUrl, bool noReload)
{
if (!noReload || CurrentPage is null)
{
CurrentPage?.CloseAsync().Wait();
CurrentPage = _browserContext.NewPageAsync().Result;
}

var destination = new Uri(rootUri, relativeUrl);
CurrentPage.GotoAsync(destination.ToString(), new() { WaitUntil = WaitUntilState.NetworkIdle }).Wait();
}

public IReadOnlyList<WebElement> FindElements(By selector)
{
return selector.MatchAsync(CurrentPage).Result.Select(elem => new WebElement(elem)).ToArray();
}

public object ExecuteScript(string script)
=> ExecuteScriptAsync(script).Result;

public async Task<object> ExecuteScriptAsync(string script)
{
var prefix = "return ";
if (script.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
script = script.Substring(prefix.Length);
}

if (script.EndsWith(";", StringComparison.OrdinalIgnoreCase))
{
script = script.Substring(0, script.Length - 1);
}

var result = await CurrentPage.EvaluateAsync(script);
if (result.HasValue)
{
return result.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => result.Value.GetString(),
JsonValueKind.Number => result.Value.GetDouble(),
JsonValueKind.Array => result.Value,
JsonValueKind.Object => result.Value,
JsonValueKind.Undefined => null,
JsonValueKind.Null => null,
_ => throw new NotImplementedException($"Unknown value kind {result.Value.ValueKind}"),
};
}

return null;
}

public string Title => CurrentPage.TitleAsync().Result;
}
109 changes: 109 additions & 0 deletions src/Components/test/E2ETest.Playwright/Infrastructure/Adapters/By.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Playwright;

namespace OpenQA.Selenium;

public class By
{
private readonly ByType _byType;
private string CssSelectorValue { get; init; }
private string ClassNameValue { get; init; }
private string IdValue { get; init; }
private string TagNameValue { get; init; }
private string LinkTextValue { get; init; }
private string XPathValue { get; init; }

public enum ByType { CssSelector, TagName, LinkText,
Id,
XPath,
ClassName
}
private By(ByType byType)
{
_byType = byType;
}

public static By CssSelector(string cssSelector)
=> new By(ByType.CssSelector) { CssSelectorValue = cssSelector };

public static By Id(string id)
=> new By(ByType.Id) { IdValue = id };

public static By TagName(string tagName)
=> new By(ByType.TagName) { TagNameValue = tagName };

public static By LinkText(string linkText)
=> new By(ByType.LinkText) { LinkTextValue = linkText };

public static By XPath(string xpathValue)
=> new By(ByType.XPath) { XPathValue = xpathValue };

public static By ClassName(string classNameValue)
=> new By(ByType.ClassName) { ClassNameValue = classNameValue };

private string ToQuerySelector() => _byType switch
{
ByType.TagName => TagNameValue,
ByType.CssSelector => CssSelectorValue,
ByType.Id => $"#{IdValue}",
ByType.XPath => $"xpath={XPathValue}",
ByType.ClassName => $".{ClassNameValue}",
_ => throw new NotImplementedException(),
};

public override string ToString() => _byType switch
{
ByType.TagName => $"[Tagname: {TagNameValue}]",
ByType.Id => $"[Id: {IdValue}]",
ByType.CssSelector => $"[CssSelector: {CssSelectorValue}]",
ByType.LinkText => $"[LinkText: {LinkTextValue}]",
ByType.XPath => $"[XPath: {XPathValue}]",
ByType.ClassName => $"[ClassName: {ClassNameValue}]",
_ => throw new NotImplementedException(),
};

public Task<IReadOnlyList<IElementHandle>> MatchAsync(IElementHandle elem)
=> MatchAsync(new QuerySelectable(elem));

public Task<IReadOnlyList<IElementHandle>> MatchAsync(IPage page)
=> MatchAsync(new QuerySelectable(page));

private async Task<IReadOnlyList<IElementHandle>> MatchAsync(QuerySelectable root)
{
if (_byType == ByType.LinkText)
{
var links = await root.QuerySelectorAllAsync("a");
var linksWithText = await Task.WhenAll(links.Select(async l =>
{
var text = await l.TextContentAsync();
return new { Link = l, Text = text };
}));
return linksWithText.Where(l => l.Text.Trim() == LinkTextValue).Select(l => l.Link).ToList();
}

return await root.QuerySelectorAllAsync(ToQuerySelector());
}

// Playwright has both IPage and IElementHandle with QuerySelectorAllAsync, but there's no
// common base type. This normalizes over them both.
private readonly struct QuerySelectable
{
private readonly IPage _page;
private readonly IElementHandle _elem;

public QuerySelectable(IPage page)
=> _page = page ?? throw new ArgumentNullException(nameof(page));

public QuerySelectable(IElementHandle elem)
=> _elem = elem ?? throw new ArgumentNullException(nameof(elem));

public Task<IReadOnlyList<IElementHandle>> QuerySelectorAllAsync(string selector)
=> _page is not null
? _page.QuerySelectorAllAsync(selector)
: _elem.QuerySelectorAllAsync(selector);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace OpenQA.Selenium;

public interface IJavaScriptExecutor
{
object ExecuteScript(string script);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Playwright;

namespace OpenQA.Selenium;

public interface IWebDriver
{
IPage CurrentPage { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.E2ETests.Playwright.Infrastructure.Adapters;
using Microsoft.Playwright;

namespace OpenQA.Selenium;

public interface IWebElement
{
IElementHandle Element { get; }

public string Text => new WebElement(Element).Text;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Playwright;

namespace OpenQA.Selenium.Support.UI;

public class SelectElement
{
private readonly IElementHandle _elem;

public SelectElement(IWebElement webElement)
{
_elem = webElement.Element;
}

public void SelectByValue(string value)
{
_elem.SelectOptionAsync(value).Wait();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.E2ETests.Playwright.Infrastructure.Adapters;

namespace OpenQA.Selenium;

public static class WebDriverExtensions
{
public static IReadOnlyList<IWebElement> FindElements(this IWebDriver webDriver, By selector)
{
return selector.MatchAsync(webDriver.CurrentPage).Result.Select(elem => new WebElement(elem)).Cast<IWebElement>().ToArray();
}

public static IWebElement FindElement(this IWebDriver webDriver, By by)
{
// TODO: Is it really this, or should it be SingleByDefault? Need to check Selenium semantics.
return webDriver.FindElements(by).FirstOrDefault();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Playwright;
using OpenQA.Selenium;

namespace Microsoft.AspNetCore.Components.E2ETests.Playwright.Infrastructure.Adapters;

public class WebElement : IWebElement
{
public WebElement(IElementHandle match)
{
Element = match;
}

public IElementHandle Element { get; }

public string Text => Element.TextContentAsync().Result.Trim(); // Selenium trims
}
Loading