From de494ddc408c7e2b57d64697bd7d2404bc9ed511 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Fri, 23 Oct 2026 11:02:00 -0400 Subject: [PATCH 1/3] Match WaitForURLAsync strings against the whole URL A plain string passed to WaitForURLAsync matched any URL that contained it, so waiting for a site's root returned at once from every page on the site, and "?page=1" was satisfied by "?page=10". The wait passed silently and the test went on against a page that had not loaded. A string with no wildcard now has to equal the URL, and a string with a wildcard is a glob over the whole URL, using the same glob syntax routes already use. WaitForURLAsync gains Regex and predicate overloads on IPage and IFrame for anything a glob cannot express. ToHaveUrlAsync uses the same matcher, so a wait and an assertion given the same string agree. Routes and the request and response waits keep their documented substring form. Three gaps surfaced while testing the wait and are fixed with it. A frame's wait checked the page's URL instead of the frame's. URL changes that keep the document (history.pushState, fragments) never updated the frame URL, so a single-page application never appeared to move; CDP's Page.navigatedWithinDocument is now handled, and on BiDi it is fed by both browsingContext.fragmentNavigated and browsingContext.historyUpdated. And a timeout surfaced as TaskCanceledException rather than the documented WaitTimeoutException. The wait now listens for navigation events as well as polling, so a URL the page only passes through is not missed. Signed-off-by: Torrey Betts --- docs/architecture/selectors-and-assertions.md | 2 +- docs/getting-started.md | 11 + docs/guides/network-interception.md | 2 + docs/migration/from-playwright.md | 4 + src/Motus.Abstractions/IFrame.cs | 56 ++- src/Motus.Abstractions/IPage.cs | 60 ++- src/Motus/Assertions/PageAssertions.cs | 14 +- src/Motus/Page/Frame.cs | 20 +- src/Motus/Page/Page.Events.cs | 31 +- src/Motus/Page/Page.Navigation.cs | 132 +++++- src/Motus/Protocol/CdpJsonContext.cs | 9 + src/Motus/Transport/BiDi/BiDiEventMap.cs | 80 +++- src/Motus/Transport/BiDi/BiDiSession.cs | 79 +++- .../Snapshot/PageSnapshotServiceTests.cs | 2 + tests/Motus.Mcp.Tests/Tools/CoreToolsFakes.cs | 4 + .../Assertions/PageAssertionsTests.cs | 16 + tests/Motus.Tests/Page/PageWaitForUrlTests.cs | 300 ++++++++++++++ tests/Motus.Tests/Page/UrlMatchingTests.cs | 93 +++++ .../Page/WaitForUrlIntegrationTests.cs | 376 ++++++++++++++++++ .../Runner/TimelineRecorderHookTests.cs | 2 + .../Transport/BiDi/BiDiEventMapTests.cs | 33 ++ .../Transport/BiDi/BiDiSessionTests.cs | 84 ++++ 22 files changed, 1375 insertions(+), 35 deletions(-) create mode 100644 tests/Motus.Tests/Page/PageWaitForUrlTests.cs create mode 100644 tests/Motus.Tests/Page/UrlMatchingTests.cs create mode 100644 tests/Motus.Tests/Page/WaitForUrlIntegrationTests.cs diff --git a/docs/architecture/selectors-and-assertions.md b/docs/architecture/selectors-and-assertions.md index 4138545..570b669 100644 --- a/docs/architecture/selectors-and-assertions.md +++ b/docs/architecture/selectors-and-assertions.md @@ -166,7 +166,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). 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 ade362c..95c0709 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -287,6 +287,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`. 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 4a8ce9e..39b126d 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 ae928b0..2a6c33b 100644 --- a/src/Motus.Abstractions/IFrame.cs +++ b/src/Motus.Abstractions/IFrame.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Motus.Abstractions; /// @@ -184,12 +186,60 @@ 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. + /// + /// 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 6c2cece..ccbffb3 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() { From b63d4beab670f5b408a637ccae3243e351cf45b8 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Fri, 23 Oct 2026 11:29:17 -0400 Subject: [PATCH 2/3] Match a URL written without its trailing slash against the browser's form WaitForURLAsync and ToHaveUrlAsync now require a plain string to equal the URL, but the browser reports a URL in its normal form: a bare origin gains a trailing slash and the host is lower case. A base URL as it is usually written, such as http://localhost:5000, therefore never matched the page it names and the wait ran out its timeout. An http or https pattern that doesn't equal the URL as given is now also compared in its normalized form. The match never widens: a path, query or fragment still has to be the same. Signed-off-by: Torrey Betts --- docs/getting-started.md | 2 +- src/Motus/Page/Page.Navigation.cs | 23 +++++++++++++++---- tests/Motus.Tests/Page/UrlMatchingTests.cs | 17 ++++++++++++++ .../Page/WaitForUrlIntegrationTests.cs | 14 +++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 95c0709..1866b74 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -287,7 +287,7 @@ 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`. 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: +`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. 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+$")); diff --git a/src/Motus/Page/Page.Navigation.cs b/src/Motus/Page/Page.Navigation.cs index 5c80b3d..cc7d83b 100644 --- a/src/Motus/Page/Page.Navigation.cs +++ b/src/Motus/Page/Page.Navigation.cs @@ -404,11 +404,26 @@ private static bool UrlMatches(string url, string pattern) /// Unlike there is no substring fallback. A site's root is a /// prefix of every URL on the site, and ?page=1 is a prefix of ?page=10, so a /// containment check answered "yes" for pages the caller was not waiting for. + /// + /// A browser reports a URL in its normal form, so https://example.com comes back as + /// https://example.com/ and a host in lower case. An http or https pattern written the + /// short way names the same address, so it is also compared in that form; without that, a + /// wait for a site's base URL as it is usually written would never match. + /// /// - internal static bool UrlMatchesWhole(string url, string pattern) => - pattern.Contains('*') - ? GlobMatches(url, pattern) - : string.Equals(url, pattern, StringComparison.Ordinal); + internal static bool UrlMatchesWhole(string url, string pattern) + { + if (pattern.Contains('*')) + return GlobMatches(url, pattern); + + if (string.Equals(url, pattern, StringComparison.Ordinal)) + return true; + + return pattern.Contains("://", StringComparison.Ordinal) + && Uri.TryCreate(pattern, UriKind.Absolute, out var parsed) + && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps) + && string.Equals(url, parsed.AbsoluteUri, StringComparison.Ordinal); + } // Each * stands for any run of characters, path separators included, so * and ** are the same. // Everything else in the pattern is literal, and the glob has to cover the whole URL. diff --git a/tests/Motus.Tests/Page/UrlMatchingTests.cs b/tests/Motus.Tests/Page/UrlMatchingTests.cs index ce5c3b4..f791826 100644 --- a/tests/Motus.Tests/Page/UrlMatchingTests.cs +++ b/tests/Motus.Tests/Page/UrlMatchingTests.cs @@ -25,6 +25,23 @@ public void Whole_ExactUrl_Matches() => public void Whole_PlainString_DoesNotMatchAUrlThatOnlyContainsIt(string url, string pattern) => Assert.IsFalse(Motus.Page.UrlMatchesWhole(url, pattern)); + /// + /// The browser reports a bare origin with a trailing slash and a host in lower case, so a + /// base URL written without the slash still names the page. + /// + [TestMethod] + [DataRow("http://localhost:5000/", "http://localhost:5000")] + [DataRow("https://example.test/", "https://EXAMPLE.test")] + [DataRow("https://example.test/a%20b", "https://example.test/a b")] + public void Whole_PlainString_MatchesTheBrowsersFormOfTheSameUrl(string url, string pattern) => + Assert.IsTrue(Motus.Page.UrlMatchesWhole(url, pattern)); + + [TestMethod] + [DataRow("https://example.test/account", "https://example.test")] + [DataRow("file:///orders", "/orders")] + public void Whole_Normalizing_DoesNotWidenTheMatch(string url, string pattern) => + Assert.IsFalse(Motus.Page.UrlMatchesWhole(url, pattern)); + [TestMethod] public void Whole_QueryPrefix_DoesNotMatchALongerValue() { diff --git a/tests/Motus.Tests/Page/WaitForUrlIntegrationTests.cs b/tests/Motus.Tests/Page/WaitForUrlIntegrationTests.cs index 608ace6..1ece4ef 100644 --- a/tests/Motus.Tests/Page/WaitForUrlIntegrationTests.cs +++ b/tests/Motus.Tests/Page/WaitForUrlIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.RegularExpressions; using Motus.Abstractions; +using Motus.Assertions; namespace Motus.Tests.Page; @@ -81,6 +82,19 @@ public async Task Page_PlainString_ReturnsOnceTheExactUrlLoads() Assert.AreEqual(_server.Origin + "/", _page.Url); } + /// + /// A base URL is usually written without a trailing slash, while the browser reports the + /// site's root with one. + /// + [TestMethod] + public async Task Page_OriginWithoutATrailingSlash_MatchesTheSitesRoot() + { + await _page!.GotoAsync(_server.Origin + "/"); + + await _page.WaitForURLAsync(_server.Origin, Within(1_000)); + await Expect.That(_page).ToHaveUrlAsync(_server.Origin, new AssertionOptions { Timeout = 1_000 }); + } + [TestMethod] public async Task Page_UrlAlreadyMatching_ReturnsWithoutANavigation() { From 9b2221f9a4a1edb3e0c463b336cf74bb0235ed77 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 13:09:25 -0400 Subject: [PATCH 3/3] Resolve a relative URL wait against the context's base URL With the substring match gone, a string without a scheme such as "/dashboard" could no longer match anything, because the browser always reports an absolute URL. A context with a BaseURL navigates with GotoAsync("/dashboard"), and waiting or asserting with the same relative string used to work through the substring match; after the switch to whole-URL matching it timed out every time. WaitForURLAsync on a page or frame and ToHaveUrlAsync now resolve such a string against the base URL first, the way GotoAsync resolves the address it loads, and then compare the whole URL. Globs, absolute URLs, and any string when no base URL is set are compared as written. Signed-off-by: Torrey Betts --- docs/architecture/selectors-and-assertions.md | 2 +- docs/getting-started.md | 2 +- src/Motus.Abstractions/IFrame.cs | 3 +- src/Motus.Abstractions/IPage.cs | 3 +- src/Motus/Assertions/PageAssertions.cs | 17 +++++--- src/Motus/Page/Frame.cs | 3 +- src/Motus/Page/Page.Navigation.cs | 25 ++++++++++- .../Assertions/PageAssertionsTests.cs | 15 ++++++- tests/Motus.Tests/Page/PageWaitForUrlTests.cs | 42 ++++++++++++++++++- 9 files changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/architecture/selectors-and-assertions.md b/docs/architecture/selectors-and-assertions.md index 570b669..77c3ed1 100644 --- a/docs/architecture/selectors-and-assertions.md +++ b/docs/architecture/selectors-and-assertions.md @@ -166,7 +166,7 @@ All methods accept an optional `AssertionOptions? options` parameter and return | Method | Condition checked | |---|---| -| `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). There is no substring match. | +| `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 1866b74..c630b59 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -287,7 +287,7 @@ 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. 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: +`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+$")); diff --git a/src/Motus.Abstractions/IFrame.cs b/src/Motus.Abstractions/IFrame.cs index 2a6c33b..39992bc 100644 --- a/src/Motus.Abstractions/IFrame.cs +++ b/src/Motus.Abstractions/IFrame.cs @@ -192,7 +192,8 @@ public interface IFrame /// /// 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. + /// 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. /// diff --git a/src/Motus.Abstractions/IPage.cs b/src/Motus.Abstractions/IPage.cs index ccbffb3..6030b76 100644 --- a/src/Motus.Abstractions/IPage.cs +++ b/src/Motus.Abstractions/IPage.cs @@ -401,7 +401,8 @@ public interface IPage : IAsyncDisposable /// /// 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. + /// 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. /// diff --git a/src/Motus/Assertions/PageAssertions.cs b/src/Motus/Assertions/PageAssertions.cs index 8863140..79c9b51 100644 --- a/src/Motus/Assertions/PageAssertions.cs +++ b/src/Motus/Assertions/PageAssertions.cs @@ -46,16 +46,21 @@ private Task RetryAsync( /// https://example.com/orders/* matches any order page but not the site root. Any other /// pattern has to equal the URL exactly: https://example.com/orders does not match /// https://example.com/orders/17/edit. To match part of a URL, use a glob such as - /// **/orders**. This is the same matching - /// uses, so a wait and an assertion given the same string agree. + /// **/orders**. A URL without a scheme, such as /orders/17, is resolved against + /// the context's when one is set. This is the same + /// matching uses, so a wait + /// and an assertion given the same string agree. /// - public Task ToHaveUrlAsync(string urlOrGlob, AssertionOptions? options = null) => - RetryAsync(async ct => + public Task ToHaveUrlAsync(string urlOrGlob, AssertionOptions? options = null) + { + var expected = _page.ResolveAgainstBaseUrl(urlOrGlob); + return RetryAsync(async ct => { var url = _page.Url; - var matches = Page.UrlMatchesWhole(url, urlOrGlob); + var matches = Page.UrlMatchesWhole(url, expected); return (matches, url); - }, "ToHaveUrl", urlOrGlob, options); + }, "ToHaveUrl", expected, options); + } /// Asserts that the document title equals the expected string. /// The title the document must carry. diff --git a/src/Motus/Page/Frame.cs b/src/Motus/Page/Frame.cs index 3dc1476..28fb6c3 100644 --- a/src/Motus/Page/Frame.cs +++ b/src/Motus/Page/Frame.cs @@ -80,8 +80,9 @@ public Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = nul public Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null) { ArgumentNullException.ThrowIfNull(urlPattern); + var expected = _page.ResolveAgainstBaseUrl(urlPattern); return _page.WaitForFrameUrlAsync( - this, url => Motus.Page.UrlMatchesWhole(url, urlPattern), $"'{urlPattern}'", options); + this, url => Motus.Page.UrlMatchesWhole(url, expected), $"'{expected}'", options); } public Task WaitForURLAsync(System.Text.RegularExpressions.Regex urlPattern, NavigationOptions? options = null) diff --git a/src/Motus/Page/Page.Navigation.cs b/src/Motus/Page/Page.Navigation.cs index cc7d83b..a1279c7 100644 --- a/src/Motus/Page/Page.Navigation.cs +++ b/src/Motus/Page/Page.Navigation.cs @@ -152,8 +152,9 @@ await _networkManager.WaitForNetworkIdleAsync( public Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null) { ArgumentNullException.ThrowIfNull(urlPattern); + var expected = ResolveAgainstBaseUrl(urlPattern); return WaitForFrameUrlAsync( - frame: null, url => UrlMatchesWhole(url, urlPattern), $"'{urlPattern}'", options); + frame: null, url => UrlMatchesWhole(url, expected), $"'{expected}'", options); } public Task WaitForURLAsync(Regex urlPattern, NavigationOptions? options = null) @@ -377,6 +378,28 @@ void Handler(string stoppedFrameId) return tcs.Task; } + /// + /// Resolves a URL written relative to the context's base URL into the address it names, the + /// way resolves one it is asked to load. + /// + /// + /// A URL wait or assertion compares the whole URL, so a relative string such as + /// /dashboard could otherwise never match the absolute URL the browser reports, even + /// after GotoAsync("/dashboard") had loaded exactly that page. A glob, an absolute URL, + /// or any string when no base URL is set is returned unchanged. + /// + internal string ResolveAgainstBaseUrl(string pattern) + { + if (_context.BaseURL is null || pattern.Contains('*') + || Uri.IsWellFormedUriString(pattern, UriKind.Absolute)) + return pattern; + + return Uri.TryCreate(_context.BaseURL, UriKind.Absolute, out var baseUri) + && Uri.TryCreate(baseUri, pattern, out var resolved) + ? resolved.AbsoluteUri + : pattern; + } + /// /// Matches a request URL the way routes and request or response waits do: an exact URL, a /// glob over the whole URL when the pattern contains *, and otherwise any URL that diff --git a/tests/Motus.Tests/Assertions/PageAssertionsTests.cs b/tests/Motus.Tests/Assertions/PageAssertionsTests.cs index 0e528f8..10ad2e0 100644 --- a/tests/Motus.Tests/Assertions/PageAssertionsTests.cs +++ b/tests/Motus.Tests/Assertions/PageAssertionsTests.cs @@ -29,7 +29,7 @@ public async Task Setup() [TestCleanup] public async Task Cleanup() => await _transport.DisposeAsync(); - private async Task CreatePageWithFrameAsync(string url = "about:blank") + private async Task CreatePageWithFrameAsync(string url = "about:blank", ContextOptions? options = null) { _socket.QueueResponse("""{"id": 2, "result": {"browserContextId": "ctx-1"}}"""); _socket.QueueResponse("""{"id": 3, "result": {"targetId": "target-1"}}"""); @@ -39,7 +39,7 @@ private async Task CreatePageWithFrameAsync(string url = "about:blank") _socket.QueueResponse("""{"id": 7, "sessionId": "session-1", "result": {}}"""); _socket.QueueResponse("""{"id": 8, "sessionId": "session-1", "result": {}}"""); _socket.QueueResponse("""{"id": 9, "sessionId": "session-1", "result": {}}"""); - var page = await _browser.NewPageAsync(); + var page = await _browser.NewPageAsync(options); // Inject frame navigated + execution context so evaluation works _socket.Enqueue($$""" @@ -164,6 +164,17 @@ await Assert.ThrowsExceptionAsync( await Expect.That(page).ToHaveUrlAsync("**/orders**", new() { Timeout = 200 }); } + [TestMethod] + public async Task ToHaveUrlAsync_ResolvesARelativeUrlAgainstTheBaseUrl() + { + var page = await CreatePageWithFrameAsync( + "http://localhost:5000/orders/17", new ContextOptions { BaseURL = "http://localhost:5000" }); + + await Expect.That(page).ToHaveUrlAsync("/orders/17", new() { Timeout = 200 }); + await Assert.ThrowsExceptionAsync( + () => Expect.That(page).ToHaveUrlAsync("/orders/1", new() { Timeout = 200 })); + } + [TestMethod] public async Task Not_ToHaveUrlAsync_Passes_WhenNoMatch() { diff --git a/tests/Motus.Tests/Page/PageWaitForUrlTests.cs b/tests/Motus.Tests/Page/PageWaitForUrlTests.cs index a1b4953..8b6b9dc 100644 --- a/tests/Motus.Tests/Page/PageWaitForUrlTests.cs +++ b/tests/Motus.Tests/Page/PageWaitForUrlTests.cs @@ -38,7 +38,7 @@ public async Task Setup() private static NavigationOptions Within(int ms) => new() { Timeout = ms }; - private async Task CreatePageAsync(string url) + private async Task CreatePageAsync(string url, ContextOptions? options = null) { _socket.QueueResponse("""{"id": 2, "result": {"browserContextId": "ctx-1"}}"""); _socket.QueueResponse("""{"id": 3, "result": {"targetId": "target-1"}}"""); @@ -48,7 +48,7 @@ private async Task CreatePageAsync(string url) _socket.QueueResponse("""{"id": 7, "sessionId": "session-1", "result": {}}"""); _socket.QueueResponse("""{"id": 8, "sessionId": "session-1", "result": {}}"""); _socket.QueueResponse("""{"id": 9, "sessionId": "session-1", "result": {}}"""); - var page = await _browser.NewPageAsync(); + var page = await _browser.NewPageAsync(options); Navigate("frame-main", url); await WaitUntilAsync(() => page.Url == url); @@ -135,6 +135,44 @@ public async Task String_ReturnsWhenTheExactUrlArrives() await wait; } + [TestMethod] + public async Task String_RelativeToTheBaseUrl_WaitsForThatAddress() + { + var page = await CreatePageAsync( + "http://localhost:5000/login", new ContextOptions { BaseURL = "http://localhost:5000" }); + + var wait = page.WaitForURLAsync("/dashboard", Within(5_000)); + Navigate("frame-main", "http://localhost:5000/dashboard/settings"); + Navigate("frame-main", "http://localhost:5000/dashboard"); + + await wait; + Assert.AreEqual("http://localhost:5000/dashboard", page.Url); + } + + [TestMethod] + public async Task String_RelativeWithoutABaseUrl_IsComparedAsWritten() + { + var page = await CreatePageAsync("http://localhost:5000/dashboard"); + + await Assert.ThrowsExceptionAsync( + () => page.WaitForURLAsync("/dashboard", Within(300))); + } + + [TestMethod] + public async Task Frame_StringRelativeToTheBaseUrl_WaitsForThatAddress() + { + var page = await CreatePageAsync( + "http://localhost:5000/", new ContextOptions { BaseURL = "http://localhost:5000/app/" }); + Navigate("frame-child", "http://localhost:5000/app/widget?step=1", parentId: "frame-main"); + await WaitUntilAsync(() => page.Frames.Any(f => f.Url == "http://localhost:5000/app/widget?step=1")); + var child = page.Frames.Single(f => f.Url == "http://localhost:5000/app/widget?step=1"); + + var wait = child.WaitForURLAsync("widget?step=2", Within(5_000)); + NavigateWithinDocument("frame-child", "http://localhost:5000/app/widget?step=2"); + + await wait; + } + [TestMethod] public async Task Glob_ReturnsWhenAMatchingUrlArrives() {