Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/selectors-and-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccessibilityAssertionOptions>?, AssertionOptions?)` | No accessibility violations; see below |

Expand Down
11 changes: 11 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/network-interception.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/migration/from-playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
57 changes: 54 additions & 3 deletions src/Motus.Abstractions/IFrame.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.RegularExpressions;

namespace Motus.Abstractions;

/// <summary>
Expand Down Expand Up @@ -224,12 +226,61 @@ public interface IFrame
Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = null);

/// <summary>
/// Waits for a matching URL navigation.
/// Waits until this frame's URL matches <paramref name="urlPattern"/>, returning at once when it
/// already does.
/// </summary>
/// <param name="urlPattern">The URL pattern to match.</param>
/// <param name="options">Navigation options.</param>
/// <param name="urlPattern">
/// The URL to wait for. A string containing <c>*</c> is a glob that has to cover the whole URL,
/// where each <c>*</c> 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 <c>/dashboard</c>, is
/// resolved against <see cref="ContextOptions.BaseURL"/> first when one is set.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// A URL that only starts with or contains the string is not a match:
/// <c>https://example.com/</c> is not satisfied by <c>https://example.com/account</c>, and
/// <c>https://example.com/orders?page=1</c> is not satisfied by
/// <c>https://example.com/orders?page=10</c>. To match part of a URL, use a glob such as
/// <c>**/orders**</c>, or one of the overloads taking a <see cref="Regex"/> 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.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null);

/// <summary>
/// Waits until this frame's URL matches a regular expression, returning at once when it already
/// does.
/// </summary>
/// <param name="urlPattern">
/// The expression the URL has to match. It is searched for anywhere in the URL, as
/// <see cref="Regex.IsMatch(string)"/> does, so anchor it with <c>^</c> and <c>$</c> to match
/// the whole URL.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// Navigations that keep the same document, through the history API or to a fragment, are
/// waited for as well.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(Regex urlPattern, NavigationOptions? options = null);

/// <summary>
/// Waits until this frame's URL satisfies a predicate, returning at once when it already does.
/// </summary>
/// <param name="predicate">
/// Called with the full URL, and returns <see langword="true"/> 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.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// Navigations that keep the same document, through the history API or to a fragment, are
/// waited for as well.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(Func<string, bool> predicate, NavigationOptions? options = null);

/// <summary>
/// Returns the accessibility tree of this frame's document.
/// </summary>
Expand Down
61 changes: 58 additions & 3 deletions src/Motus.Abstractions/IPage.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.RegularExpressions;

namespace Motus.Abstractions;

/// <summary>
Expand Down Expand Up @@ -138,6 +140,10 @@ public interface IPage : IAsyncDisposable
/// <summary>
/// Raised when a frame in the page navigates.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
event EventHandler<IFrame>? FrameNavigated;

/// <summary>
Expand Down Expand Up @@ -424,12 +430,61 @@ public interface IPage : IAsyncDisposable
Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = null);

/// <summary>
/// Waits for a matching URL navigation.
/// Waits until the page's URL matches <paramref name="urlPattern"/>, returning at once when it
/// already does.
/// </summary>
/// <param name="urlPattern">The URL pattern to match.</param>
/// <param name="options">Navigation options.</param>
/// <param name="urlPattern">
/// The URL to wait for. A string containing <c>*</c> is a glob that has to cover the whole URL,
/// where each <c>*</c> 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 <c>/dashboard</c>, is
/// resolved against <see cref="ContextOptions.BaseURL"/> first when one is set.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// A URL that only starts with or contains the string is not a match:
/// <c>https://example.com/</c> is not satisfied by <c>https://example.com/account</c>, and
/// <c>https://example.com/orders?page=1</c> is not satisfied by
/// <c>https://example.com/orders?page=10</c>. To match part of a URL, use a glob such as
/// <c>**/orders**</c>, or one of the overloads taking a <see cref="Regex"/> 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.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null);

/// <summary>
/// Waits until the page's URL matches a regular expression, returning at once when it already
/// does.
/// </summary>
/// <param name="urlPattern">
/// The expression the URL has to match. It is searched for anywhere in the URL, as
/// <see cref="Regex.IsMatch(string)"/> does, so anchor it with <c>^</c> and <c>$</c> to match
/// the whole URL.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// Navigations that keep the same document, through the history API or to a fragment, are
/// waited for as well.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(Regex urlPattern, NavigationOptions? options = null);

/// <summary>
/// Waits until the page's URL satisfies a predicate, returning at once when it already does.
/// </summary>
/// <param name="predicate">
/// Called with the full URL, and returns <see langword="true"/> for the URL being waited for.
/// It is called for the current URL and for each URL the page navigates to, from one thread at a
/// time. An exception it throws ends the wait and propagates to the caller.
/// </param>
/// <param name="options">The timeout in milliseconds. The other navigation options are unused.</param>
/// <remarks>
/// Navigations that keep the same document, through the history API or to a fragment, are
/// waited for as well.
/// </remarks>
/// <exception cref="WaitTimeoutException">The URL did not match within the timeout.</exception>
Task WaitForURLAsync(Func<string, bool> predicate, NavigationOptions? options = null);

/// <summary>
/// Waits for a network request matching the URL pattern.
/// </summary>
Expand Down
25 changes: 16 additions & 9 deletions src/Motus/Assertions/PageAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,26 @@ private Task RetryAsync(
/// </param>
/// <param name="options">Timeout and failure message overrides.</param>
/// <remarks>
/// Three forms are accepted, and which one applies depends on the pattern. A pattern equal to
/// the URL matches it. A pattern containing <c>*</c> is matched against the whole URL, so
/// <c>https://example.com/orders/*</c> matches any order page but not the site root. A pattern
/// containing no <c>*</c> at all matches when the URL merely contains it, which is worth
/// knowing: <c>/orders</c> also matches <c>https://example.com/orders/17/edit</c>.
/// A pattern containing <c>*</c> is a glob matched against the whole URL, where each <c>*</c>
/// stands for any run of characters, path separators included, so
/// <c>https://example.com/orders/*</c> matches any order page but not the site root. Any other
/// pattern has to equal the URL exactly: <c>https://example.com/orders</c> does not match
/// <c>https://example.com/orders/17/edit</c>. To match part of a URL, use a glob such as
/// <c>**/orders**</c>. A URL without a scheme, such as <c>/orders/17</c>, is resolved against
/// the context's <see cref="ContextOptions.BaseURL"/> when one is set. This is the same
/// matching <see cref="IPage.WaitForURLAsync(string, NavigationOptions?)"/> uses, so a wait
/// and an assertion given the same string agree.
/// </remarks>
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.UrlMatchesStatic(url, urlOrGlob);
var matches = Page.UrlMatchesWhole(url, expected);
return (matches, url);
}, "ToHaveUrl", urlOrGlob, options);
}, "ToHaveUrl", expected, options);
}

/// <summary>Asserts that the document title equals the expected string.</summary>
/// <param name="expected">The title the document must carry.</param>
Expand Down
21 changes: 19 additions & 2 deletions src/Motus/Page/Frame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,25 @@ public async Task<string> TitleAsync() =>
public Task WaitForLoadStateAsync(LoadState? state = null, double? timeout = null) =>
_page.WaitForLoadStateAsync(state, timeout);

public Task WaitForURLAsync(string urlPattern, NavigationOptions? options = null) =>
_page.WaitForURLAsync(urlPattern, options);
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, expected), $"'{expected}'", options);
}

public Task WaitForURLAsync(System.Text.RegularExpressions.Regex urlPattern, NavigationOptions? options = null)
{
ArgumentNullException.ThrowIfNull(urlPattern);
return _page.WaitForFrameUrlAsync(this, urlPattern.IsMatch, $"/{urlPattern}/", options);
}

public Task WaitForURLAsync(Func<string, bool> predicate, NavigationOptions? options = null)
{
ArgumentNullException.ThrowIfNull(predicate);
return _page.WaitForFrameUrlAsync(this, predicate, "a predicate", options);
}

// --- Locator methods ---

Expand Down
31 changes: 30 additions & 1 deletion src/Motus/Page/Page.Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ internal void StartFrameStructureEventPump(IMotusSession session, CancellationTo

private async Task PumpFrameTreeEventsAsync(IMotusSession session, bool isPageSession, CancellationToken ct)
{
string[] events = ["Page.frameNavigated", "Page.frameAttached", "Page.frameDetached"];
string[] events =
[
"Page.frameNavigated", "Page.frameAttached", "Page.frameDetached",
"Page.navigatedWithinDocument"
];

try
{
Expand All @@ -148,6 +152,10 @@ private async Task PumpFrameTreeEventsAsync(IMotusSession session, bool isPageSe
if (JsonSerializer.Deserialize(raw.Params, CdpJsonContext.Default.PageFrameDetachedEvent) is { } detached)
OnFrameDetached(detached, session);
break;
case "Page.navigatedWithinDocument":
if (JsonSerializer.Deserialize(raw.Params, CdpJsonContext.Default.PageNavigatedWithinDocumentEvent) is { } within)
OnNavigatedWithinDocument(within);
break;
}
}
catch
Expand Down Expand Up @@ -222,6 +230,27 @@ private void OnFrameNavigated(PageFrameNavigatedEvent evt, IMotusSession source,
MainFrameNavigated?.Invoke(info.Url);
}

/// <summary>
/// Records a URL change that kept the frame's document: a history API call or a fragment
/// navigation.
/// </summary>
/// <remarks>
/// The browser does not report these as <c>Page.frameNavigated</c>, so without this a frame's
/// URL stayed at whatever its last document loaded with, and a single-page application that
/// routes through <c>history.pushState</c> never appeared to move. The document, its child
/// frames and its execution contexts are all unchanged, so none of the cleanup a full
/// navigation does applies here; only the URL is updated, and
/// <see cref="FrameNavigated"/> is raised so a wait on the URL can see the change.
/// </remarks>
private void OnNavigatedWithinDocument(PageNavigatedWithinDocumentEvent evt)
{
if (!_frames.TryGetValue(evt.FrameId, out var frame))
return;

frame.Url = evt.Url;
FrameNavigated?.Invoke(this, frame);
}

private void OnFrameAttached(PageFrameAttachedEvent evt, IMotusSession source)
{
var frame = EnsureFrame(evt.FrameId, evt.ParentFrameId);
Expand Down
Loading
Loading