diff --git a/docs/architecture/selectors-and-assertions.md b/docs/architecture/selectors-and-assertions.md index ca2317b..a33aa03 100644 --- a/docs/architecture/selectors-and-assertions.md +++ b/docs/architecture/selectors-and-assertions.md @@ -213,7 +213,7 @@ All methods accept an optional `AssertionOptions? options` parameter and return | Method | Condition checked | |---|---| -| `ToHaveUrlAsync(string)` | Current page URL matches a literal string or glob pattern (via `Page.UrlMatchesStatic`) | +| `ToHaveUrlAsync(string)` | Current page URL equals the string, or matches it as a glob over the whole URL when it contains `*` (via `Page.UrlMatchesWhole`, the same matcher `WaitForURLAsync` uses). A string without a scheme is resolved against the context's `BaseURL` first when one is set. There is no substring match. | | `ToHaveTitleAsync(string)` | `document.title` equals expected (via `TitleAsync`) | | `ToPassAccessibilityAuditAsync(Action?, AssertionOptions?)` | No accessibility violations; see below | diff --git a/docs/getting-started.md b/docs/getting-started.md index 75a667f..475c445 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -301,6 +301,17 @@ await Page.ReloadAsync(); await Page.WaitForURLAsync("**/dashboard"); ``` +`WaitForURLAsync` returns at once when the URL already matches. A string with no `*` has to be the whole URL, so `WaitForURLAsync("https://example.com/")` waits for the site's root and is not satisfied by `https://example.com/account`. The browser's normal form counts as the same URL, so `https://example.com` without the trailing slash also matches the root. When the context has a `BaseURL`, a string without a scheme is resolved against it first, as `GotoAsync` does, so `WaitForURLAsync("/dashboard")` waits for that page under the base URL. A string containing `*` is a glob that has to cover the whole URL, where each `*` stands for any run of characters, path separators included. For anything a glob cannot say, pass a `Regex` or a predicate instead: + +```csharp +await Page.WaitForURLAsync(new Regex(@"/orders/\d+$")); +await Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/checkout"); +``` + +The wait sees navigations that keep the same document as well, such as a single-page application changing its route through `history.pushState`. `IFrame.WaitForURLAsync` takes the same arguments and waits on that frame's own URL. + +`ToHaveUrlAsync` matches a string the same way, so a wait and an assertion given the same string agree. + A tab the page opens for itself, through `window.open` or a link with `target="_blank"`, is a page like any other: it joins the context's pages and the page that opened it raises `Popup` with the new `IPage`. Subscribe before the click that opens it, since the tab can arrive before the click returns. The new tab is often still on `about:blank` when it is handed over, so wait on it rather than reading its URL straight away. ```csharp diff --git a/docs/guides/network-interception.md b/docs/guides/network-interception.md index 0b657cc..c14aace 100644 --- a/docs/guides/network-interception.md +++ b/docs/guides/network-interception.md @@ -53,6 +53,8 @@ A pattern is matched against the full request URL, query string and fragment inc Because a glob has to cover the whole URL, `**/api/products` does not match `https://example.com/api/products?page=2`. End the pattern with `**` when the request may carry a query string. +The substring form belongs to request matching: routes, `WaitForRequestAsync` and `WaitForResponseAsync`. `IPage.WaitForURLAsync`, `IFrame.WaitForURLAsync` and the `ToHaveUrlAsync` assertion use the same glob syntax but have no substring form, so a string with no `*` there has to equal the whole URL. + One handler takes a request, rather than each matching handler in turn. Page handlers are consulted before context handlers, and within each the most recently registered matching handler is the one that runs, so registering a second handler for a pattern shadows the first rather than queuing behind it. If the handler that runs returns without calling `FulfillAsync`, `ContinueAsync`, or `AbortAsync`, the request continues as it stands and no other handler is offered it. ## Fulfilling Requests diff --git a/docs/migration/from-playwright.md b/docs/migration/from-playwright.md index 0338b5b..a76ceea 100644 --- a/docs/migration/from-playwright.md +++ b/docs/migration/from-playwright.md @@ -108,9 +108,13 @@ See [Attaching to a Running Browser](../guides/attaching-to-a-running-browser.md | `page.GoForwardAsync(options)` | `page.GoForwardAsync(options)` | | `page.ReloadAsync(options)` | `page.ReloadAsync(options)` | | `page.WaitForURLAsync(pattern, options)` | `page.WaitForURLAsync(pattern, options)` | +| `page.WaitForURLAsync(regex, options)` | `page.WaitForURLAsync(regex, options)` | +| `page.WaitForURLAsync(predicate, options)` | `page.WaitForURLAsync(predicate, options)` | | `page.WaitForLoadStateAsync(state, timeout)` | `page.WaitForLoadStateAsync(state, timeout)` | | `page.Url` | `page.Url` | +A URL string is matched as Playwright matches it: a string with no wildcard has to equal the whole URL, and a glob has to cover the whole URL. The glob syntax is simpler, though. Each `*` matches any run of characters including `/`, so `*` and `**` are the same, and brace lists such as `{png,jpg}` are matched literally. A pattern written with `**` behaves the same in both; a single `*` that relied on stopping at a `/`, or a brace list, is better written as a `Regex`. + ### Page - locators | Playwright for .NET | Motus | diff --git a/src/Motus.Abstractions/IFrame.cs b/src/Motus.Abstractions/IFrame.cs index 15b597e..588ece4 100644 --- a/src/Motus.Abstractions/IFrame.cs +++ b/src/Motus.Abstractions/IFrame.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Motus.Abstractions; /// @@ -224,12 +226,61 @@ public interface IFrame Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = null); /// - /// Waits for a matching URL navigation. + /// Waits until this frame's URL matches , returning at once when it + /// already does. /// - /// The URL pattern to match. - /// Navigation options. + /// + /// The URL to wait for. A string containing * is a glob that has to cover the whole URL, + /// where each * stands for any run of characters, path separators included. Any other + /// string has to equal the URL exactly. A string without a scheme, such as /dashboard, is + /// resolved against first when one is set. + /// + /// The timeout in milliseconds. The other navigation options are unused. + /// + /// A URL that only starts with or contains the string is not a match: + /// https://example.com/ is not satisfied by https://example.com/account, and + /// https://example.com/orders?page=1 is not satisfied by + /// https://example.com/orders?page=10. To match part of a URL, use a glob such as + /// **/orders**, or one of the overloads taking a or a predicate. + /// A navigation that keeps the same document, through the history API or to a fragment, + /// changes the URL as well and is waited for the same way. + /// + /// The URL did not match within the timeout. Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null); + /// + /// Waits until this frame's URL matches a regular expression, returning at once when it already + /// does. + /// + /// + /// The expression the URL has to match. It is searched for anywhere in the URL, as + /// does, so anchor it with ^ and $ to match + /// the whole URL. + /// + /// The timeout in milliseconds. The other navigation options are unused. + /// + /// Navigations that keep the same document, through the history API or to a fragment, are + /// waited for as well. + /// + /// The URL did not match within the timeout. + Task WaitForURLAsync(Regex urlPattern, NavigationOptions? options = null); + + /// + /// Waits until this frame's URL satisfies a predicate, returning at once when it already does. + /// + /// + /// Called with the full URL, and returns for the URL being waited for. + /// It is called for the current URL and for each URL the frame navigates to, from one thread at a + /// time. An exception it throws ends the wait and propagates to the caller. + /// + /// The timeout in milliseconds. The other navigation options are unused. + /// + /// Navigations that keep the same document, through the history API or to a fragment, are + /// waited for as well. + /// + /// The URL did not match within the timeout. + Task WaitForURLAsync(Func predicate, NavigationOptions? options = null); + /// /// Returns the accessibility tree of this frame's document. /// diff --git a/src/Motus.Abstractions/IPage.cs b/src/Motus.Abstractions/IPage.cs index 03d40de..b41f4fc 100644 --- a/src/Motus.Abstractions/IPage.cs +++ b/src/Motus.Abstractions/IPage.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Motus.Abstractions; /// @@ -138,6 +140,10 @@ public interface IPage : IAsyncDisposable /// /// Raised when a frame in the page navigates. /// + /// + /// This includes a navigation that keeps the frame's document, through the history API or to a + /// fragment, in which case only the frame's URL has changed. + /// event EventHandler" + : $"

{WebUtility.HtmlEncode(path)}

"; + + context.Response.StatusCode = 200; + context.Response.ContentType = "text/html; charset=utf-8"; + + var bytes = Encoding.UTF8.GetBytes(body); + context.Response.ContentLength64 = bytes.Length; + + try + { + await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + context.Response.Close(); + } + catch (Exception) + { + // The browser gave up on the request. + } + } + } + + private static int AllocateFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + _cts.Cancel(); + try { _listener.Stop(); } catch { /* already stopped */ } + _listener.Close(); + } +} diff --git a/tests/Motus.Tests/Runner/TimelineRecorderHookTests.cs b/tests/Motus.Tests/Runner/TimelineRecorderHookTests.cs index 6cbe0a8..6235b46 100644 --- a/tests/Motus.Tests/Runner/TimelineRecorderHookTests.cs +++ b/tests/Motus.Tests/Runner/TimelineRecorderHookTests.cs @@ -100,6 +100,8 @@ public Task ScreenshotAsync(ScreenshotOptions? options = null) public Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = null) => throw new NotImplementedException(); public Task WaitForNavigationAsync(NavigationOptions? options = null) => throw new NotImplementedException(); public Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null) => throw new NotImplementedException(); + public Task WaitForURLAsync(System.Text.RegularExpressions.Regex urlPattern, NavigationOptions? options = null) => throw new NotImplementedException(); + public Task WaitForURLAsync(Func predicate, NavigationOptions? options = null) => throw new NotImplementedException(); public ILocator Locator(string selector, LocatorOptions? options = null) => throw new NotImplementedException(); public ILocator LocatorByBackendNodeId(long backendNodeId) => throw new NotImplementedException(); public Task AccessibilitySnapshotAsync(CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/tests/Motus.Tests/Transport/BiDi/BiDiEventMapTests.cs b/tests/Motus.Tests/Transport/BiDi/BiDiEventMapTests.cs index 00b33c6..720a700 100644 --- a/tests/Motus.Tests/Transport/BiDi/BiDiEventMapTests.cs +++ b/tests/Motus.Tests/Transport/BiDi/BiDiEventMapTests.cs @@ -60,6 +60,39 @@ public void FrameNavigated_Builds_Frame_Object() Assert.AreEqual("https://example.com", frame.GetProperty("url").GetString()); } + /// + /// BiDi reports a fragment navigation and a history API call as two events where CDP has one, + /// so the CDP name carries a translation for each. + /// + [TestMethod] + public void NavigatedWithinDocument_Is_Translated_From_Both_BiDi_Events() + { + var translations = BiDiEventMap.GetEventTranslations("Page.navigatedWithinDocument"); + + CollectionAssert.AreEquivalent( + new[] { "browsingContext.fragmentNavigated", "browsingContext.historyUpdated" }, + translations.Select(t => t.BiDiEventName).ToArray()); + } + + [TestMethod] + [DataRow("browsingContext.fragmentNavigated", "fragment")] + [DataRow("browsingContext.historyUpdated", "historyApi")] + public void NavigatedWithinDocument_Builds_FrameId_And_Url(string biDiEvent, string navigationType) + { + var translation = BiDiEventMap.GetEventTranslations("Page.navigatedWithinDocument") + .Single(t => t.BiDiEventName == biDiEvent); + var bidiParams = Parse("""{"context":"ctx-1","navigation":"nav-1","url":"https://example.com/a#b","timestamp":1}"""); + var cdpParams = translation.TranslateEvent(bidiParams); + + Assert.AreEqual("ctx-1", cdpParams.GetProperty("frameId").GetString()); + Assert.AreEqual("https://example.com/a#b", cdpParams.GetProperty("url").GetString()); + Assert.AreEqual(navigationType, cdpParams.GetProperty("navigationType").GetString()); + } + + [TestMethod] + public void SingleSource_Event_Has_One_Translation() => + Assert.AreEqual(1, BiDiEventMap.GetEventTranslations("Page.frameNavigated").Count); + [TestMethod] public void DialogOpening_Maps_Type_And_Message() { diff --git a/tests/Motus.Tests/Transport/BiDi/BiDiSessionTests.cs b/tests/Motus.Tests/Transport/BiDi/BiDiSessionTests.cs index a86c4f4..2116605 100644 --- a/tests/Motus.Tests/Transport/BiDi/BiDiSessionTests.cs +++ b/tests/Motus.Tests/Transport/BiDi/BiDiSessionTests.cs @@ -152,6 +152,90 @@ public async Task SubscribeAsync_Sends_SessionSubscribe_On_First_Call() await enumerator.DisposeAsync(); } + /// + /// A CDP event BiDi reports as two events is subscribed as both, and a subscriber to the CDP + /// name receives either one translated. + /// + [TestMethod] + public async Task SubscribeAsync_EventWithTwoBiDiSources_DeliversBoth() + { + var socket = new FakeCdpSocket(); + var transport = new BiDiTransport(socket); + await transport.ConnectAsync(new Uri("ws://localhost:1234"), CancellationToken.None); + var session = new BiDiSession(transport, "ctx-1"); + + socket.QueueResponse("""{"type":"success","id":1,"result":{}}"""); + socket.QueueResponse("""{"type":"success","id":2,"result":{}}"""); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var enumerator = session.SubscribeAsync( + "Page.navigatedWithinDocument", + BiDiJsonContext.Default.JsonElement, + cts.Token).GetAsyncEnumerator(cts.Token); + + var first = enumerator.MoveNextAsync(); + await Task.Delay(100); + + socket.Enqueue("""{"type":"event","method":"browsingContext.historyUpdated","params":{"context":"ctx-1","url":"https://example.com/a","timestamp":1}}"""); + Assert.IsTrue(await first); + Assert.AreEqual("https://example.com/a", enumerator.Current.GetProperty("url").GetString()); + + socket.Enqueue("""{"type":"event","method":"browsingContext.fragmentNavigated","params":{"context":"ctx-1","navigation":"n","url":"https://example.com/a#b","timestamp":2}}"""); + Assert.IsTrue(await enumerator.MoveNextAsync()); + Assert.AreEqual("https://example.com/a#b", enumerator.Current.GetProperty("url").GetString()); + + var subscribes = Enumerable.Range(0, socket.SentMessages.Count) + .Select(i => JsonDocument.Parse(socket.GetSentJson(i)).RootElement) + .Where(m => m.GetProperty("method").GetString() == "session.subscribe") + .ToDictionary( + m => m.GetProperty("params").GetProperty("events")[0].GetString()!, + m => m.GetProperty("params")); + CollectionAssert.AreEquivalent( + new[] { "browsingContext.fragmentNavigated", "browsingContext.historyUpdated" }, + subscribes.Keys.ToArray()); + + // Firefox never delivers a fragment navigation to a subscription scoped to one context, so + // that one is subscribed for all of them; the history update stays scoped. + Assert.IsFalse(HasContexts(subscribes["browsingContext.fragmentNavigated"])); + Assert.IsTrue(HasContexts(subscribes["browsingContext.historyUpdated"])); + + static bool HasContexts(JsonElement p) => + p.TryGetProperty("contexts", out var c) && c.ValueKind == JsonValueKind.Array; + + await enumerator.DisposeAsync(); + } + + /// + /// A browser that does not know one of the two events refuses that subscription, and the + /// other is still delivered. + /// + [TestMethod] + public async Task SubscribeAsync_EventWithTwoBiDiSources_SurvivesOneBeingRefused() + { + var socket = new FakeCdpSocket(); + var transport = new BiDiTransport(socket); + await transport.ConnectAsync(new Uri("ws://localhost:1234"), CancellationToken.None); + var session = new BiDiSession(transport, "ctx-1"); + + socket.QueueResponse("""{"type":"error","id":1,"error":"invalid argument","message":"Unknown event"}"""); + socket.QueueResponse("""{"type":"success","id":2,"result":{}}"""); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var enumerator = session.SubscribeAsync( + "Page.navigatedWithinDocument", + BiDiJsonContext.Default.JsonElement, + cts.Token).GetAsyncEnumerator(cts.Token); + + var next = enumerator.MoveNextAsync(); + await Task.Delay(100); + + socket.Enqueue("""{"type":"event","method":"browsingContext.historyUpdated","params":{"context":"ctx-1","url":"https://example.com/a","timestamp":1}}"""); + Assert.IsTrue(await next); + Assert.AreEqual("https://example.com/a", enumerator.Current.GetProperty("url").GetString()); + + await enumerator.DisposeAsync(); + } + [TestMethod] public void CleanupChannels_Removes_Context_Channels() {