diff --git a/README.md b/README.md index e248716e9d..10c05121ef 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 130.0.6723.31 | ✅ | ✅ | ✅ | -| WebKit 18.0 | ✅ | ✅ | ✅ | -| Firefox 131.0 | ✅ | ✅ | ✅ | +| Chromium 131.0.6778.33 | ✅ | ✅ | ✅ | +| WebKit 18.2 | ✅ | ✅ | ✅ | +| Firefox 132.0 | ✅ | ✅ | ✅ | Playwright for .NET is the official language port of [Playwright](https://playwright.dev), the library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) with a single API. Playwright is built to enable cross-browser web automation that is **ever-green**, **capable**, **reliable** and **fast**. diff --git a/src/Common/Version.props b/src/Common/Version.props index 2322d0ecfa..265fcafe3b 100644 --- a/src/Common/Version.props +++ b/src/Common/Version.props @@ -2,7 +2,7 @@ 1.48.0 $(AssemblyVersion) - 1.48.1 + 1.49.0 $(AssemblyVersion) $(AssemblyVersion) true diff --git a/src/Playwright.Tests/PageAriaSnapshotTests.cs b/src/Playwright.Tests/PageAriaSnapshotTests.cs new file mode 100644 index 0000000000..27f9e3fe8e --- /dev/null +++ b/src/Playwright.Tests/PageAriaSnapshotTests.cs @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and / or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Microsoft.Playwright.Tests; + +public class PageAriaSnapshotTests : PageTestEx +{ + private string _unshift(string snapshot) + { + var lines = snapshot.Split('\n'); + var whitespacePrefixLength = 100; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + var match = System.Text.RegularExpressions.Regex.Match(line, @"^(\s*)"); + if (match.Success && match.Groups[1].Value.Length < whitespacePrefixLength) + whitespacePrefixLength = match.Groups[1].Value.Length; + break; + } + return string.Join('\n', lines.Where(t => !string.IsNullOrWhiteSpace(t)).Select(line => line.Substring(whitespacePrefixLength))); + } + + private async Task CheckAndMatchSnapshot(ILocator locator, string snapshot) + { + Assert.AreEqual(_unshift(snapshot), await locator.AriaSnapshotAsync()); + await Expect(locator).ToMatchAriaSnapshotAsync(snapshot); + } + + [PlaywrightTest("page-aria-snapshot.spec.ts", "should snapshot")] + public async Task ShouldSnapshot() + { + await Page.SetContentAsync("

title

"); + await CheckAndMatchSnapshot(Page.Locator("body"), @" + - heading ""title"" [level=1] + "); + } + + [PlaywrightTest("page-aria-snapshot.spec.ts", "should snapshot list")] + public async Task ShouldSnapshotList() + { + await Page.SetContentAsync(@" +

title

+

title 2

+ "); + await CheckAndMatchSnapshot(Page.Locator("body"), @" + - heading ""title"" [level=1] + - heading ""title 2"" [level=1] + "); + } + + [PlaywrightTest("page-aria-snapshot.spec.ts", "should snapshot list with accessible name")] + public async Task ShouldSnapshotListWithAccessibleName() + { + await Page.SetContentAsync(@" + + "); + await CheckAndMatchSnapshot(Page.Locator("body"), @" + - list ""my list"": + - listitem: one + - listitem: two + "); + } + + [PlaywrightTest("page-aria-snapshot.spec.ts", "should snapshot complex")] + public async Task ShouldSnapshotComplex() + { + await Page.SetContentAsync(@" + + "); + await CheckAndMatchSnapshot(Page.Locator("body"), @" + - list: + - listitem: + - link ""link"" + "); + } +} diff --git a/src/Playwright.Tests/PageRouteWebSocketTests.cs b/src/Playwright.Tests/PageRouteWebSocketTests.cs index 721e2d17ad..beea6a0bf9 100644 --- a/src/Playwright.Tests/PageRouteWebSocketTests.cs +++ b/src/Playwright.Tests/PageRouteWebSocketTests.cs @@ -284,4 +284,31 @@ await AssertAreEqualWithRetriesAsync(() => Page.EvaluateAsync("() => w "close code=3008 reason=oops wasClean=true", ]); } + + [PlaywrightTest("page-route-web-socket.spec.ts", "should work with baseURL")] + public async Task ShouldWorkWithBaseURL() + { + var context = await Browser.NewContextAsync(new() { BaseURL = $"http://localhost:{Server.Port}" }); + var page = await context.NewPageAsync(); + + await page.RouteWebSocketAsync("/ws", ws => + { + ws.OnMessage(message => + { + ws.Send(message.Text); + }); + }); + + await SetupWS(page, Server.Port, "blob"); + + await page.EvaluateAsync(@"async () => { + await window.wsOpened; + window.ws.send('echo'); + }"); + await AssertAreEqualWithRetriesAsync(() => page.EvaluateAsync("() => window.log"), new[] + { + "open", + $"message: data=echo origin=ws://localhost:{Server.Port} lastEventId=", + }); + } } diff --git a/src/Playwright.Tests/TracingTests.cs b/src/Playwright.Tests/TracingTests.cs index 3e4c491ce9..61a01a69b5 100644 --- a/src/Playwright.Tests/TracingTests.cs +++ b/src/Playwright.Tests/TracingTests.cs @@ -333,6 +333,40 @@ string[] ResourceNames(Dictionary resources) } } + [PlaywrightTest("tracing.spec.ts", "should show tracing.group in the action list with location")] + public async Task ShouldShowTracingGroupInActionList() + { + using var tracesDir = new TempDirectory(); + await Context.Tracing.StartAsync(); + var page = await Context.NewPageAsync(); + + await Context.Tracing.GroupAsync("outer group"); + await page.GotoAsync("data:text/html,
Hello world
"); + await Context.Tracing.GroupAsync("inner group 1"); + await page.Locator("body").ClickAsync(); + await Context.Tracing.GroupEndAsync(); + await Context.Tracing.GroupAsync("inner group 2"); + await Expect(page.GetByText("Hello")).ToBeVisibleAsync(); + await Context.Tracing.GroupEndAsync(); + await Context.Tracing.GroupEndAsync(); + + var tracePath = Path.Combine(tracesDir.Path, "trace.zip"); + await Context.Tracing.StopAsync(new() { Path = tracePath }); + + var (events, resources) = ParseTrace(tracePath); + var actions = GetActions(events); + + Assert.AreEqual(new[] { + "BrowserContext.NewPageAsync", + "outer group", + "Page.GotoAsync", + "inner group 1", + "Locator.ClickAsync", + "inner group 2", + "LocatorAssertions.ToBeVisibleAsync" + }, actions); + } + private static (IReadOnlyList Events, Dictionary Resources) ParseTrace(string path) { Dictionary resources = new(); diff --git a/src/Playwright/API/Generated/IClock.cs b/src/Playwright/API/Generated/IClock.cs index 48d30e3f7b..f88e22a890 100644 --- a/src/Playwright/API/Generated/IClock.cs +++ b/src/Playwright/API/Generated/IClock.cs @@ -185,6 +185,12 @@ public partial interface IClock /// Makes Date.now and new Date() return fixed fake time at all times, /// keeps all the timers running. /// + /// + /// Use this method for simple scenarios where you only need to test with a predefined + /// time. For more advanced scenarios, use instead. + /// Read docs on clock emulation + /// to learn more. + /// /// **Usage** /// /// await page.Clock.SetFixedTimeAsync(DateTime.Now);
@@ -200,6 +206,12 @@ public partial interface IClock /// Makes Date.now and new Date() return fixed fake time at all times, /// keeps all the timers running. /// + /// + /// Use this method for simple scenarios where you only need to test with a predefined + /// time. For more advanced scenarios, use instead. + /// Read docs on clock emulation + /// to learn more. + /// /// **Usage** /// /// await page.Clock.SetFixedTimeAsync(DateTime.Now);
@@ -211,7 +223,11 @@ public partial interface IClock Task SetFixedTimeAsync(DateTime time); /// - /// Sets current system time but does not trigger any timers. + /// + /// Sets system time, but does not trigger any timers. Use this to test how the web + /// page reacts to a time shift, for example switching from summer to winter time, or + /// changing time zones. + /// /// **Usage** /// /// await page.Clock.SetSystemTimeAsync(DateTime.Now);
@@ -223,7 +239,11 @@ public partial interface IClock Task SetSystemTimeAsync(string time); /// - /// Sets current system time but does not trigger any timers. + /// + /// Sets system time, but does not trigger any timers. Use this to test how the web + /// page reacts to a time shift, for example switching from summer to winter time, or + /// changing time zones. + /// /// **Usage** /// /// await page.Clock.SetSystemTimeAsync(DateTime.Now);
diff --git a/src/Playwright/API/Generated/IKeyboard.cs b/src/Playwright/API/Generated/IKeyboard.cs index a5b85e915d..0981a9dea3 100644 --- a/src/Playwright/API/Generated/IKeyboard.cs +++ b/src/Playwright/API/Generated/IKeyboard.cs @@ -60,12 +60,7 @@ namespace Microsoft.Playwright; /// await page.Keyboard.PressAsync("Shift+A"); ///
/// An example to trigger select-all with the keyboard -/// -/// // on Windows and Linux
-/// await page.Keyboard.PressAsync("Control+A");
-/// // on macOS
-/// await page.Keyboard.PressAsync("Meta+A"); -///
+/// await page.Keyboard.PressAsync("ControlOrMeta+A"); ///
public partial interface IKeyboard { diff --git a/src/Playwright/API/Generated/ILocator.cs b/src/Playwright/API/Generated/ILocator.cs index ce1d82e585..08cc0c9ecd 100644 --- a/src/Playwright/API/Generated/ILocator.cs +++ b/src/Playwright/API/Generated/ILocator.cs @@ -125,6 +125,35 @@ public partial interface ILocator /// Additional locator to match. ILocator And(ILocator locator); + /// + /// + /// Captures the aria snapshot of the given element. Read more about aria + /// snapshots and for + /// the corresponding assertion. + /// + /// **Usage** + /// await page.GetByRole(AriaRole.Link).AriaSnapshotAsync(); + /// **Details** + /// + /// This method captures the aria snapshot of the given element. The snapshot is a string + /// that represents the state of the element and its children. The snapshot can be used + /// to assert the state of the element in the test, or to compare it to state in the + /// future. + /// + /// + /// The ARIA snapshot is represented using YAML + /// markup language: + /// + /// + /// The keys of the objects are the roles and optional accessible names of the elements. + /// The values are either text content or an array of child elements. + /// Generic static text can be represented with the text key. + /// + /// Below is the HTML markup and the respective ARIA snapshot: + /// + /// Call options + Task AriaSnapshotAsync(LocatorAriaSnapshotOptions? options = default); + ///
/// /// Calls blur diff --git a/src/Playwright/API/Generated/ILocatorAssertions.cs b/src/Playwright/API/Generated/ILocatorAssertions.cs index 3a9cedf0ba..637fadb427 100644 --- a/src/Playwright/API/Generated/ILocatorAssertions.cs +++ b/src/Playwright/API/Generated/ILocatorAssertions.cs @@ -936,6 +936,25 @@ public partial interface ILocatorAssertions /// Expected options currently selected. /// Call options Task ToHaveValuesAsync(IEnumerable values, LocatorAssertionsToHaveValuesOptions? options = default); + + /// + /// + /// Asserts that the target element matches the given accessibility + /// snapshot. + /// + /// **Usage** + /// + /// await page.GotoAsync("https://demo.playwright.dev/todomvc/");
+ /// await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(@"
+ /// - heading ""todos""
+ /// - textbox ""What needs to be done?""
+ /// "); + ///
+ ///
+ /// + /// + /// Call options + Task ToMatchAriaSnapshotAsync(string expected, LocatorAssertionsToMatchAriaSnapshotOptions? options = default); } #nullable disable diff --git a/src/Playwright/API/Generated/IPage.cs b/src/Playwright/API/Generated/IPage.cs index f662dc8cf4..19018300a5 100644 --- a/src/Playwright/API/Generated/IPage.cs +++ b/src/Playwright/API/Generated/IPage.cs @@ -636,8 +636,6 @@ public partial interface IPage /// await page.EvaluateAsync("matchMedia('(prefers-color-scheme: dark)').matches");
/// // → true
/// await page.EvaluateAsync("matchMedia('(prefers-color-scheme: light)').matches");
- /// // → false
- /// await page.EvaluateAsync("matchMedia('(prefers-color-scheme: no-preference)').matches");
/// // → false ///
///
@@ -2187,8 +2185,8 @@ public partial interface IPage /// /// /// await page.RouteWebSocketAsync("/ws", ws => {
- /// ws.OnMessage(message => {
- /// if (message == "request")
+ /// ws.OnMessage(frame => {
+ /// if (frame.Text == "request")
/// ws.Send("response");
/// });
/// }); @@ -2214,8 +2212,8 @@ public partial interface IPage /// /// /// await page.RouteWebSocketAsync("/ws", ws => {
- /// ws.OnMessage(message => {
- /// if (message == "request")
+ /// ws.OnMessage(frame => {
+ /// if (frame.Text == "request")
/// ws.Send("response");
/// });
/// }); @@ -2241,8 +2239,8 @@ public partial interface IPage /// /// /// await page.RouteWebSocketAsync("/ws", ws => {
- /// ws.OnMessage(message => {
- /// if (message == "request")
+ /// ws.OnMessage(frame => {
+ /// if (frame.Text == "request")
/// ws.Send("response");
/// });
/// }); diff --git a/src/Playwright/API/Generated/IRoute.cs b/src/Playwright/API/Generated/IRoute.cs index 61fe1859c3..cac163b2a2 100644 --- a/src/Playwright/API/Generated/IRoute.cs +++ b/src/Playwright/API/Generated/IRoute.cs @@ -85,11 +85,10 @@ public partial interface IRoute ///
/// **Details** /// - /// Note that any overrides such as or - /// only apply to the request being routed. If this request results in a redirect, overrides - /// will not be applied to the new redirected request. If you want to propagate a header - /// through redirects, use the combination of and instead. + /// The option applies to both the routed request + /// and any redirects it initiates. However, , , and only apply + /// to the original request and are not carried over to redirected requests. /// /// /// will immediately send the request to the network, diff --git a/src/Playwright/API/Generated/ITracing.cs b/src/Playwright/API/Generated/ITracing.cs index 351868bc34..4eeb3eb08e 100644 --- a/src/Playwright/API/Generated/ITracing.cs +++ b/src/Playwright/API/Generated/ITracing.cs @@ -119,6 +119,31 @@ public partial interface ITracing /// Call options Task StartChunkAsync(TracingStartChunkOptions? options = default); + /// + /// Use test.step instead when available. + /// + /// Creates a new group within the trace, assigning any subsequent API calls to this + /// group, until is called. Groups can be nested + /// and will be visible in the trace viewer. + /// + /// **Usage** + /// + /// // All actions between GroupAsync and GroupEndAsync
+ /// // will be shown in the trace viewer as a group.
+ /// await Page.Context().Tracing.GroupAsync("Open Playwright.dev > API");
+ /// await Page.GotoAsync("https://playwright.dev/");
+ /// await Page.GetByRole(AriaRole.Link, new() { Name = "API" }).ClickAsync();
+ /// await Page.Context().Tracing.GroupEndAsync(); + ///
+ ///
+ /// Use test.step instead when available. + /// Group name shown in the trace viewer. + /// Call options + Task GroupAsync(string name, TracingGroupOptions? options = default); + + /// Closes the last group created by . + Task GroupEndAsync(); + /// Stop tracing. /// Call options Task StopAsync(TracingStopOptions? options = default); diff --git a/src/Playwright/API/Generated/IWebSocketRoute.cs b/src/Playwright/API/Generated/IWebSocketRoute.cs index 66c01b52e2..baf400c70c 100644 --- a/src/Playwright/API/Generated/IWebSocketRoute.cs +++ b/src/Playwright/API/Generated/IWebSocketRoute.cs @@ -43,9 +43,9 @@ namespace Microsoft.Playwright; /// a "request" with a "response". ///
/// -/// await page.RouteWebSocketAsync("/ws", ws => {
-/// ws.OnMessage(message => {
-/// if (message == "request")
+/// await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
+/// ws.OnMessage(frame => {
+/// if (frame.Text == "request")
/// ws.Send("response");
/// });
/// }); @@ -55,6 +55,21 @@ namespace Microsoft.Playwright; /// route handler, Playwright assumes that WebSocket will be mocked, and opens the WebSocket /// inside the page automatically. /// +/// Here is another example that handles JSON messages: +/// +/// await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
+/// ws.OnMessage(frame => {
+/// using var jsonDoc = JsonDocument.Parse(frame.Text);
+/// JsonElement root = jsonDoc.RootElement;
+/// if (root.TryGetProperty("request", out JsonElement requestElement) && requestElement.GetString() == "question")
+/// {
+/// var response = new Dictionary<string, string> { ["response"] = "answer" };
+/// string jsonResponse = JsonSerializer.Serialize(response);
+/// ws.Send(jsonResponse);
+/// }
+/// });
+/// }); +///
/// **Intercepting** /// /// Alternatively, you may want to connect to the actual server, but intercept messages @@ -70,11 +85,11 @@ namespace Microsoft.Playwright; /// /// await page.RouteWebSocketAsync("/ws", ws => {
/// var server = ws.ConnectToServer();
-/// ws.OnMessage(message => {
-/// if (message == "request")
+/// ws.OnMessage(frame => {
+/// if (frame.Text == "request")
/// server.Send("request2");
/// else
-/// server.Send(message);
+/// server.Send(frame.Text);
/// });
/// }); ///
@@ -100,13 +115,13 @@ namespace Microsoft.Playwright; /// /// await page.RouteWebSocketAsync("/ws", ws => {
/// var server = ws.ConnectToServer();
-/// ws.OnMessage(message => {
-/// if (message != "blocked-from-the-page")
-/// server.Send(message);
+/// ws.OnMessage(frame => {
+/// if (frame.Text != "blocked-from-the-page")
+/// server.Send(frame.Text);
/// });
-/// server.OnMessage(message => {
-/// if (message != "blocked-from-the-server")
-/// ws.Send(message);
+/// server.OnMessage(frame => {
+/// if (frame.Text != "blocked-from-the-server")
+/// ws.Send(frame.Text);
/// });
/// }); ///
diff --git a/src/Playwright/API/Generated/Options/BrowserNewContextOptions.cs b/src/Playwright/API/Generated/Options/BrowserNewContextOptions.cs index 16115891ff..0f30cb0ecb 100644 --- a/src/Playwright/API/Generated/Options/BrowserNewContextOptions.cs +++ b/src/Playwright/API/Generated/Options/BrowserNewContextOptions.cs @@ -148,8 +148,8 @@ public BrowserNewContextOptions(BrowserNewContextOptions clone) /// /// - /// Emulates 'prefers-colors-scheme' media feature, supported values are 'light', - /// 'dark', 'no-preference'. See + /// Emulates prefers-colors-scheme + /// media feature, supported values are 'light' and 'dark'. See /// for more details. Passing 'null' resets emulation to system defaults. Defaults /// to 'light'. /// diff --git a/src/Playwright/API/Generated/Options/BrowserNewPageOptions.cs b/src/Playwright/API/Generated/Options/BrowserNewPageOptions.cs index ec899f6ac7..ada5d468a9 100644 --- a/src/Playwright/API/Generated/Options/BrowserNewPageOptions.cs +++ b/src/Playwright/API/Generated/Options/BrowserNewPageOptions.cs @@ -148,8 +148,8 @@ public BrowserNewPageOptions(BrowserNewPageOptions clone) /// /// - /// Emulates 'prefers-colors-scheme' media feature, supported values are 'light', - /// 'dark', 'no-preference'. See + /// Emulates prefers-colors-scheme + /// media feature, supported values are 'light' and 'dark'. See /// for more details. Passing 'null' resets emulation to system defaults. Defaults /// to 'light'. /// diff --git a/src/Playwright/API/Generated/Options/BrowserTypeLaunchPersistentContextOptions.cs b/src/Playwright/API/Generated/Options/BrowserTypeLaunchPersistentContextOptions.cs index a05688f6ae..45a8fad15a 100644 --- a/src/Playwright/API/Generated/Options/BrowserTypeLaunchPersistentContextOptions.cs +++ b/src/Playwright/API/Generated/Options/BrowserTypeLaunchPersistentContextOptions.cs @@ -194,8 +194,8 @@ public BrowserTypeLaunchPersistentContextOptions(BrowserTypeLaunchPersistentCont /// /// - /// Emulates 'prefers-colors-scheme' media feature, supported values are 'light', - /// 'dark', 'no-preference'. See + /// Emulates prefers-colors-scheme + /// media feature, supported values are 'light' and 'dark'. See /// for more details. Passing 'null' resets emulation to system defaults. Defaults /// to 'light'. /// diff --git a/src/Playwright/API/Generated/Options/LocatorAriaSnapshotOptions.cs b/src/Playwright/API/Generated/Options/LocatorAriaSnapshotOptions.cs new file mode 100644 index 0000000000..db38e71f80 --- /dev/null +++ b/src/Playwright/API/Generated/Options/LocatorAriaSnapshotOptions.cs @@ -0,0 +1,56 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +using System.Text.Json.Serialization; + +#nullable enable + +namespace Microsoft.Playwright; + +public class LocatorAriaSnapshotOptions +{ + public LocatorAriaSnapshotOptions() { } + + public LocatorAriaSnapshotOptions(LocatorAriaSnapshotOptions clone) + { + if (clone == null) + { + return; + } + + Timeout = clone.Timeout; + } + + /// + /// + /// Maximum time in milliseconds. Defaults to 30000 (30 seconds). Pass 0 + /// to disable timeout. The default value can be changed by using the + /// or methods. + /// + /// + [JsonPropertyName("timeout")] + public float? Timeout { get; set; } +} + +#nullable disable diff --git a/src/Playwright/API/Generated/Options/LocatorAssertionsToMatchAriaSnapshotOptions.cs b/src/Playwright/API/Generated/Options/LocatorAssertionsToMatchAriaSnapshotOptions.cs new file mode 100644 index 0000000000..cd0b725017 --- /dev/null +++ b/src/Playwright/API/Generated/Options/LocatorAssertionsToMatchAriaSnapshotOptions.cs @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +using System.Text.Json.Serialization; + +#nullable enable + +namespace Microsoft.Playwright; + +public class LocatorAssertionsToMatchAriaSnapshotOptions +{ + public LocatorAssertionsToMatchAriaSnapshotOptions() { } + + public LocatorAssertionsToMatchAriaSnapshotOptions(LocatorAssertionsToMatchAriaSnapshotOptions clone) + { + if (clone == null) + { + return; + } + + Timeout = clone.Timeout; + } + + /// Time to retry the assertion for in milliseconds. Defaults to 5000. + [JsonPropertyName("timeout")] + public float? Timeout { get; set; } +} + +#nullable disable diff --git a/src/Playwright/API/Generated/Options/PageEmulateMediaOptions.cs b/src/Playwright/API/Generated/Options/PageEmulateMediaOptions.cs index de87e3c719..d6ead8ef30 100644 --- a/src/Playwright/API/Generated/Options/PageEmulateMediaOptions.cs +++ b/src/Playwright/API/Generated/Options/PageEmulateMediaOptions.cs @@ -47,9 +47,9 @@ public PageEmulateMediaOptions(PageEmulateMediaOptions clone) /// /// - /// Emulates 'prefers-colors-scheme' media feature, supported values are 'light', - /// 'dark', 'no-preference'. Passing 'Null' disables color scheme - /// emulation. + /// Emulates prefers-colors-scheme + /// media feature, supported values are 'light' and 'dark'. Passing 'Null' + /// disables color scheme emulation. 'no-preference' is deprecated. /// /// [JsonPropertyName("colorScheme")] diff --git a/src/Playwright/API/Generated/Options/TracingGroupOptions.cs b/src/Playwright/API/Generated/Options/TracingGroupOptions.cs new file mode 100644 index 0000000000..d4747e9afb --- /dev/null +++ b/src/Playwright/API/Generated/Options/TracingGroupOptions.cs @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +using System.Text.Json.Serialization; + +#nullable enable + +namespace Microsoft.Playwright; + +public class TracingGroupOptions +{ + public TracingGroupOptions() { } + + public TracingGroupOptions(TracingGroupOptions clone) + { + if (clone == null) + { + return; + } + + Location = clone.Location; + } + + /// + /// + /// Specifies a custom location for the group to be shown in the trace viewer. Defaults + /// to the location of the call. + /// + /// + [JsonPropertyName("location")] + public Location? Location { get; set; } +} + +#nullable disable diff --git a/src/Playwright/API/Generated/Types/Location.cs b/src/Playwright/API/Generated/Types/Location.cs new file mode 100644 index 0000000000..9a26582e60 --- /dev/null +++ b/src/Playwright/API/Generated/Types/Location.cs @@ -0,0 +1,48 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +#nullable enable + +namespace Microsoft.Playwright; + +public partial class Location +{ + /// + [Required] + [JsonPropertyName("file")] + public string File { get; set; } = default!; + + /// + [JsonPropertyName("line")] + public int? Line { get; set; } + + /// + [JsonPropertyName("column")] + public int? Column { get; set; } +} + +#nullable disable diff --git a/src/Playwright/Core/BrowserContext.cs b/src/Playwright/Core/BrowserContext.cs index 10cf285902..3ecb354ccf 100644 --- a/src/Playwright/Core/BrowserContext.cs +++ b/src/Playwright/Core/BrowserContext.cs @@ -712,7 +712,7 @@ internal async Task OnRouteAsync(Route route) internal async Task OnWebSocketRouteAsync(WebSocketRoute webSocketRoute) { - var routeHandler = _webSocketRoutes.Find(r => r.Regex?.IsMatch(webSocketRoute.Url) == true || r.Function?.Invoke(webSocketRoute.Url) == true); + var routeHandler = _webSocketRoutes.Find(route => route.Matches(webSocketRoute.Url)); if (routeHandler != null) { await routeHandler.HandleAsync(webSocketRoute).ConfigureAwait(false); @@ -728,21 +728,7 @@ internal bool UrlMatches(string url, string glob) internal string CombineUrlWithBase(string url) { - var baseUrl = Options?.BaseURL; - if (string.IsNullOrEmpty(baseUrl) - || (url?.StartsWith("*", StringComparison.InvariantCultureIgnoreCase) ?? false) - || !Uri.IsWellFormedUriString(url, UriKind.RelativeOrAbsolute)) - { - return url; - } - - var mUri = new Uri(url, UriKind.RelativeOrAbsolute); - if (!mUri.IsAbsoluteUri) - { - return new Uri(new Uri(baseUrl), mUri).ToString(); - } - - return url; + return URLMatch.JoinWithBaseURL(Options?.BaseURL, url); } private Task RouteAsync(Regex urlRegex, Func urlFunc, Delegate handler, BrowserContextRouteOptions options) @@ -796,7 +782,10 @@ private async Task UpdateInterceptionAsync() var patterns = RouteHandler.PrepareInterceptionPatterns(_routes); await SendMessageToServerAsync( "setNetworkInterceptionPatterns", - patterns).ConfigureAwait(false); + new Dictionary + { + ["patterns"] = patterns, + }).ConfigureAwait(false); } internal void OnClose() @@ -913,22 +902,27 @@ private void DisposeHarRouters() [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(string url, Action handler) - => RouteWebSocketAsync(new Regex(CombineUrlWithBase(url).GlobToRegex()), null, handler); + => RouteWebSocketAsync(url, null, null, handler); [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(Regex url, Action handler) - => RouteWebSocketAsync(url, null, handler); + => RouteWebSocketAsync(null, url, null, handler); [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(Func url, Action handler) - => RouteWebSocketAsync(null, url, handler); + => RouteWebSocketAsync(null, null, url, handler); - private Task RouteWebSocketAsync(Regex urlRegex, Func urlFunc, Delegate handler) + private Task RouteWebSocketAsync(string globMatch, Regex reMatch, Func funcMatch, Delegate handler) { _webSocketRoutes.Insert(0, new WebSocketRouteHandler() { - Regex = urlRegex, - Function = urlFunc, + URL = new URLMatch() + { + BaseURL = Options.BaseURL, + globMatch = globMatch, + reMatch = reMatch, + funcMatch = funcMatch, + }, Handler = handler, }); return UpdateWebSocketInterceptionAsync(); @@ -937,7 +931,10 @@ private Task RouteWebSocketAsync(Regex urlRegex, Func urlFunc, Del private async Task UpdateWebSocketInterceptionAsync() { var patterns = WebSocketRouteHandler.PrepareInterceptionPatterns(_webSocketRoutes); - await SendMessageToServerAsync("setWebSocketInterceptionPatterns", patterns).ConfigureAwait(false); + await SendMessageToServerAsync("setWebSocketInterceptionPatterns", new Dictionary + { + ["patterns"] = patterns, + }).ConfigureAwait(false); } } diff --git a/src/Playwright/Core/Locator.cs b/src/Playwright/Core/Locator.cs index 8f2d149933..3483e3d534 100644 --- a/src/Playwright/Core/Locator.cs +++ b/src/Playwright/Core/Locator.cs @@ -593,6 +593,16 @@ private static string EscapeForTextSelector(string text, bool? exact) async Task> ILocator.AllAsync() => Enumerable.Range(0, await CountAsync().ConfigureAwait(false)).Select(Nth).ToArray(); + + public async Task AriaSnapshotAsync(LocatorAriaSnapshotOptions options = null) + { + var result = await _frame.SendMessageToServerAsync("ariaSnapshot", new Dictionary + { + ["selector"] = _selector, + ["timeout"] = options?.Timeout, + }).ConfigureAwait(false); + return result.Value.GetProperty("snapshot").GetString(); + } } internal class ByRoleOptions diff --git a/src/Playwright/Core/LocatorAssertions.cs b/src/Playwright/Core/LocatorAssertions.cs index 39a11ab509..5897bb9e3d 100644 --- a/src/Playwright/Core/LocatorAssertions.cs +++ b/src/Playwright/Core/LocatorAssertions.cs @@ -91,8 +91,7 @@ public Task ToBeVisibleAsync(LocatorAssertionsToBeVisibleOptions options = null) private Task ExpectTrueAsync(string expression, string message, FrameExpectOptions options) { - ExpectedTextValue[] expectedText = null; - return ExpectImplAsync(expression, expectedText, null, message, options); + return ExpectImplAsync(expression, null as ExpectedTextValue[], null, message, options); } public Task ToContainTextAsync(string expected, LocatorAssertionsToContainTextOptions options = null) => @@ -139,10 +138,9 @@ public Task ToHaveClassAsync(IEnumerable expected, LocatorAssertionsToHav public Task ToHaveCountAsync(int count, LocatorAssertionsToHaveCountOptions options = null) { - ExpectedTextValue[] expectedText = null; var commonOptions = ConvertToFrameExpectOptions(options); commonOptions.ExpectedNumber = count; - return ExpectImplAsync("to.have.count", expectedText, count, "Locator expected to have count", commonOptions); + return ExpectImplAsync("to.have.count", null as ExpectedTextValue[], count, "Locator expected to have count", commonOptions); } public Task ToHaveCSSAsync(string name, string value, LocatorAssertionsToHaveCSSOptions options = null) => @@ -174,8 +172,7 @@ public Task ToHaveJSPropertyAsync(string name, object value, LocatorAssertionsTo var commonOptions = ConvertToFrameExpectOptions(options); commonOptions.ExpressionArg = name; commonOptions.ExpectedValue = ScriptsHelper.SerializedArgument(value); - ExpectedTextValue[] expectedText = null; - return ExpectImplAsync("to.have.property", expectedText, value, $"Locator expected to have JavaScript property '{name}'", commonOptions); + return ExpectImplAsync("to.have.property", null as ExpectedTextValue[], value, $"Locator expected to have JavaScript property '{name}'", commonOptions); } public Task ToHaveTextAsync(string expected, LocatorAssertionsToHaveTextOptions options = null) => @@ -216,4 +213,11 @@ public Task ToHaveAccessibleNameAsync(Regex expected, LocatorAssertionsToHaveAcc public Task ToHaveRoleAsync(AriaRole role, LocatorAssertionsToHaveRoleOptions options = null) => ExpectImplAsync("to.have.role", new ExpectedTextValue() { String = role.ToString().ToLowerInvariant() }, role, "Locator expected to have role", ConvertToFrameExpectOptions(options)); + + public Task ToMatchAriaSnapshotAsync(string expected, LocatorAssertionsToMatchAriaSnapshotOptions options = null) + { + var commonOptions = ConvertToFrameExpectOptions(options); + commonOptions.ExpectedValue = ScriptsHelper.SerializedArgument(expected); + return ExpectImplAsync("to.match.aria", null as ExpectedTextValue[], expected, "Locator expected to match Aria snapshot", commonOptions); + } } diff --git a/src/Playwright/Core/Page.cs b/src/Playwright/Core/Page.cs index 3772c8138c..1c78f7bcee 100644 --- a/src/Playwright/Core/Page.cs +++ b/src/Playwright/Core/Page.cs @@ -1284,7 +1284,10 @@ private async Task UnrouteInternalAsync(List removed, List + { + ["patterns"] = patterns, + }).ConfigureAwait(false); } internal void OnClose() @@ -1352,7 +1355,7 @@ private async Task OnRouteAsync(Route route) private async Task OnWebSocketRouteAsync(WebSocketRoute webSocketRoute) { - var routeHandler = _webSocketRoutes.Find(r => r.Regex?.IsMatch(webSocketRoute.Url) == true || r.Function?.Invoke(webSocketRoute.Url) == true); + var routeHandler = _webSocketRoutes.Find(route => route.Matches(webSocketRoute.Url)); if (routeHandler != null) { await routeHandler.HandleAsync(webSocketRoute).ConfigureAwait(false); @@ -1585,22 +1588,27 @@ public async Task RemoveLocatorHandlerAsync(ILocator locator) [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(string url, Action handler) - => RouteWebSocketAsync(new Regex(Context.CombineUrlWithBase(url).GlobToRegex()), null, handler); + => RouteWebSocketAsync(url, null, null, handler); [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(Regex url, Action handler) - => RouteWebSocketAsync(url, null, handler); + => RouteWebSocketAsync(null, url, null, handler); [MethodImpl(MethodImplOptions.NoInlining)] public Task RouteWebSocketAsync(Func url, Action handler) - => RouteWebSocketAsync(null, url, handler); + => RouteWebSocketAsync(null, null, url, handler); - private Task RouteWebSocketAsync(Regex urlRegex, Func urlFunc, Delegate handler) + private Task RouteWebSocketAsync(string globMatch, Regex urlRegex, Func urlFunc, Delegate handler) { _webSocketRoutes.Insert(0, new WebSocketRouteHandler() { - Regex = urlRegex, - Function = urlFunc, + URL = new URLMatch() + { + BaseURL = Context.Options.BaseURL, + globMatch = globMatch, + reMatch = urlRegex, + funcMatch = urlFunc, + }, Handler = handler, }); return UpdateWebSocketInterceptionAsync(); @@ -1609,7 +1617,10 @@ private Task RouteWebSocketAsync(Regex urlRegex, Func urlFunc, Del private async Task UpdateWebSocketInterceptionAsync() { var patterns = WebSocketRouteHandler.PrepareInterceptionPatterns(_webSocketRoutes); - await SendMessageToServerAsync("setWebSocketInterceptionPatterns", patterns).ConfigureAwait(false); + await SendMessageToServerAsync("setWebSocketInterceptionPatterns", new Dictionary + { + ["patterns"] = patterns, + }).ConfigureAwait(false); } } diff --git a/src/Playwright/Core/RouteHandler.cs b/src/Playwright/Core/RouteHandler.cs index 132917562e..e66b148e69 100644 --- a/src/Playwright/Core/RouteHandler.cs +++ b/src/Playwright/Core/RouteHandler.cs @@ -47,7 +47,7 @@ internal class RouteHandler public int HandledCount { get; set; } - public static Dictionary PrepareInterceptionPatterns(List handlers) + public static List> PrepareInterceptionPatterns(List handlers) { bool all = false; var patterns = new List>(); @@ -70,19 +70,15 @@ public static Dictionary PrepareInterceptionPatterns(List - { - ["glob"] = "**/*", - }; - - patterns.Clear(); - patterns.Add(allPattern); + return [ + new Dictionary + { + ["glob"] = "**/*", + } + ]; } - return new Dictionary - { - ["patterns"] = patterns, - }; + return patterns; } public async Task HandleAsync(Route route) diff --git a/src/Playwright/Core/Tracing.cs b/src/Playwright/Core/Tracing.cs index 5d195a3aa9..5adcb890f9 100644 --- a/src/Playwright/Core/Tracing.cs +++ b/src/Playwright/Core/Tracing.cs @@ -168,4 +168,14 @@ internal void ResetStackCounter() _connection.SetIsTracing(false); } } + + public Task GroupAsync(string name, TracingGroupOptions options = null) + => SendMessageToServerAsync("tracingGroup", new Dictionary + { + ["name"] = name, + ["location"] = options?.Location, + }); + + public Task GroupEndAsync() + => SendMessageToServerAsync("tracingGroupEnd"); } diff --git a/src/Playwright/Core/WebSocketRouteHandler.cs b/src/Playwright/Core/WebSocketRouteHandler.cs index 3e8add3f1d..bbdb05422d 100644 --- a/src/Playwright/Core/WebSocketRouteHandler.cs +++ b/src/Playwright/Core/WebSocketRouteHandler.cs @@ -24,7 +24,6 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Playwright.Helpers; @@ -32,13 +31,11 @@ namespace Microsoft.Playwright.Core; internal class WebSocketRouteHandler { - public Regex Regex { get; set; } - - public Func Function { get; set; } + public URLMatch URL { get; set; } public Delegate Handler { get; set; } - public static Dictionary PrepareInterceptionPatterns(List handlers) + public static List> PrepareInterceptionPatterns(List handlers) { bool all = false; var patterns = new List>(); @@ -47,13 +44,16 @@ public static Dictionary PrepareInterceptionPatterns(List(); patterns.Add(pattern); - if (handler.Regex != null) + if (!string.IsNullOrEmpty(handler.URL.globMatch)) { - pattern["regexSource"] = handler.Regex.ToString(); - pattern["regexFlags"] = handler.Regex.Options.GetInlineFlags(); + pattern["glob"] = handler.URL.globMatch; } - - if (handler.Function != null) + else if (handler.URL.reMatch != null) + { + pattern["regexSource"] = handler.URL.reMatch.ToString(); + pattern["regexFlags"] = handler.URL.reMatch.Options.GetInlineFlags(); + } + else { all = true; } @@ -61,19 +61,15 @@ public static Dictionary PrepareInterceptionPatterns(List - { - ["glob"] = "**/*", - }; - - patterns.Clear(); - patterns.Add(allPattern); + return [ + new Dictionary + { + ["glob"] = "**/*", + } + ]; } - return new Dictionary - { - ["patterns"] = patterns, - }; + return patterns; } public async Task HandleAsync(WebSocketRoute route) @@ -85,4 +81,6 @@ public async Task HandleAsync(WebSocketRoute route) } await route.AfterHandleAsync().ConfigureAwait(false); } + + internal bool Matches(string normalisedUrl) => URL.Match(normalisedUrl); } diff --git a/src/Playwright/Helpers/URLMatch.cs b/src/Playwright/Helpers/URLMatch.cs new file mode 100644 index 0000000000..a5002c50c8 --- /dev/null +++ b/src/Playwright/Helpers/URLMatch.cs @@ -0,0 +1,82 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and / or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.Playwright.Helpers; + +public class URLMatch +{ + public Regex reMatch { get; set; } + + public Func funcMatch { get; set; } + + public string globMatch { get; set; } + + public string BaseURL { get; set; } + + public bool Match(string url) + { + if (reMatch != null) + { + return reMatch.IsMatch(url); + } + + if (funcMatch != null) + { + return funcMatch(url); + } + + if (globMatch != null) + { + var globWithBaseURL = JoinWithBaseURL(BaseURL, globMatch); + // Allow http(s) baseURL to match ws(s) urls. + if (new Regex("^https?://").IsMatch(globWithBaseURL) && new Regex("^wss?://").IsMatch(url)) + { + globWithBaseURL = new Regex("^http").Replace(globWithBaseURL, "ws"); + } + return new Regex(globWithBaseURL.GlobToRegex()).IsMatch(url); + } + return false; + } + + internal static string JoinWithBaseURL(string baseUrl, string url) + { + if (string.IsNullOrEmpty(baseUrl) + || (url?.StartsWith("*", StringComparison.InvariantCultureIgnoreCase) ?? false) + || !Uri.IsWellFormedUriString(url, UriKind.RelativeOrAbsolute)) + { + return url; + } + + var mUri = new Uri(url, UriKind.RelativeOrAbsolute); + if (!mUri.IsAbsoluteUri) + { + return new Uri(new Uri(baseUrl), mUri).ToString(); + } + + return url; + } +}