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
7 changes: 4 additions & 3 deletions COMPATIBILITY.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ dotnet add package E2E.NUnit # or E2E.XUnit.V3 for xUnit v3
- [Compatibility](../COMPATIBILITY.md): how this port differs from the TypeScript [e2e](https://github.com/tester-army/e2e).
- [Test filters](../COMPATIBILITY.md#test-filters): `ITestFilter`, `TestFilterContext`, and `TestFilterResult` leave out the tests a change cannot affect. The seam, the change detection (`INVALID_CHANGED_SINCE`), and the `run.changes` and `run.filters` report fields are ported; no `dotnet test` host calls a filter yet.

## Scrolling to a node

`Screen.ScrollUntilVisibleAsync(target, options)` scrolls the viewport until `target` resolves visibly. `Locator.ScrollUntilVisibleAsync(target, options)` swipes that node instead, so a feed or table that is its own scroll container pages without the pointer hovering it first.

```csharp
await Screen.ScrollUntilVisibleAsync(Screen.GetByRole("button", "Accept"));
await Screen.GetByTestId("feed").ScrollUntilVisibleAsync(row, new ScrollUntilVisibleOptions { Momentum = Momentum.Fast });
```

| Option | Type | Default |
| --- | --- | --- |
| `Direction` | `ScrollDirection` | `Down` |
| `Momentum` | `Momentum` (`None`, `Slow`, `Fast`) | `Slow` |
| `Timeout` | `TimeSpan` | 30 seconds |

`Timeout` is its own default, not `actionTimeout`, and bounds the whole scroll, each swipe included. `LOCATOR_NOT_FOUND` means the deadline expired first, a swipe it cut short included. `INVALID_LOCATOR` means `target` was made by another target's screen.

## Errors

A read or navigation that times out fails with `OPERATION_TIMEOUT`. It is not retried, even when the call log mentions a detached element or a navigation race. On a device, an input (a tap, key, swipe, or `Back`) that the device does not answer within `actionTimeout` fails with `ACTION_MAY_HAVE_COMMITTED` and is never repeated, because it may have landed; an input the timeout ran out before is not sent (`OPERATION_TIMEOUT`), and a long press that holds at least as long as the time left is refused (`NOT_ACTIONABLE`). See `EngineErrorCodes` in the [API](api/index.md).
11 changes: 11 additions & 0 deletions skills/e2e-dotnet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@ lines you changed:
the current value; `CountAsync` and `IsVisibleAsync` answer from the current
screen. Nothing waits for a value to change, so use a matcher when a value
has to settle.
- To reach a node below the fold, `Screen.ScrollUntilVisibleAsync(locator,
new ScrollUntilVisibleOptions { Direction, Momentum, Timeout })` scrolls the viewport
(`ScrollDirection.Down`, `Momentum.Slow` by default) until the locator resolves
visibly. On a locator (`list.ScrollUntilVisibleAsync(row)`) each step swipes that node,
so a feed or table that is its own scroll container pages. `Timeout` is its own
default of 30 s, not `actionTimeout`, and bounds the whole scroll, each swipe
included. `Momentum` is the stride of each step: a longer one must not carry the
target past the surface unseen, as a virtualized list does. It fails with
`LOCATOR_NOT_FOUND` when the deadline expires first (a swipe it cut short
included) and `INVALID_LOCATOR` for a target made by another screen (topic
`writing-tests`).
- A read or navigation that times out fails with `OPERATION_TIMEOUT` and is not
retried, even when the call log mentions a detached element or a navigation
race. On a device, an input (tap, fill, key, swipe, `Back`) the device does not
Expand Down
10 changes: 10 additions & 0 deletions skills/e2e-dotnet/references/writing-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ it to the test's output. Use them to write a locator. Both read once without
waiting, and take an `ActionOptions` with a `Timeout`. A locator does not have
them.

`Screen.ScrollUntilVisibleAsync(locator, new ScrollUntilVisibleOptions { Direction, Momentum, Timeout })`
scrolls the viewport (`ScrollDirection.Down`, `Momentum.Slow` by default) until the
locator resolves visibly, else `LOCATOR_NOT_FOUND`. On a locator
(`list.ScrollUntilVisibleAsync(row)`) it swipes that node, so a feed or a table that is
its own scroll container pages without the pointer hovering it first. `Timeout` is its own
default of 30 seconds, not the action timeout, and bounds the whole scroll, each swipe
included. `Momentum` is the stride of each step; a longer one must not carry the target
past the surface unseen, as a virtualized list does. The target must come from the same
screen, else `INVALID_LOCATOR`.

## Expect

`Expect.That(locator)` polls until the assertion timeout:
Expand Down
2 changes: 1 addition & 1 deletion src/E2E.Mobile/DeviceActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ private static async Task RunAsync(IDeviceClient client, NodeBinding target, Loc
await PressKeyAsync(client, target, press.Key, settle, input, cancellationToken).ConfigureAwait(false);
break;
case LocatorAction.Swipe swipe:
await SwipeNodeAsync(client, target, swipe.Direction, momentum: null, input, cancellationToken).ConfigureAwait(false);
await SwipeNodeAsync(client, target, swipe.Direction, swipe.Momentum, input, cancellationToken).ConfigureAwait(false);
break;
default:
// Not in NodeActionKinds, so the harness never sends them. A device has no touch equivalent.
Expand Down
10 changes: 0 additions & 10 deletions src/E2E.Mobile/DeviceSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,6 @@ namespace E2E.Mobile;
/// <summary>The logical size of the screen, in points.</summary>
public readonly record struct ViewportSize(double Width, double Height);

/// <summary>How far a scroll gesture travels inside its rect. Unset is the default.</summary>
public enum Momentum
{
/// <summary>A quarter of the box: shorter than any row, so every row lands fully on screen at some stop.</summary>
Slow,

/// <summary>Four fifths of the box.</summary>
Fast,
}

/// <summary>The screenshot fields a viewport probe reads off the device client's response.</summary>
public sealed record RawScreenshotResult
{
Expand Down
6 changes: 6 additions & 0 deletions src/E2E/Engine/IEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ Task<IReadOnlyList<SemanticNode>> LocateAsync(LocatorExpression expression, Time
/// </summary>
Task SwipeAsync(ScrollDirection direction, CancellationToken cancellationToken) => Unsupported("scroll");

/// <summary>
/// <see cref="SwipeAsync(ScrollDirection, CancellationToken)"/> with the stride of the gesture: upstream's <c>swipe(direction,
/// momentum)</c>. An engine that takes a momentum overrides it; the default drops it, so an engine with one stride swipes by that.
/// </summary>
Task SwipeAsync(ScrollDirection direction, Momentum? momentum, CancellationToken cancellationToken) => SwipeAsync(direction, cancellationToken);

/// <summary>
/// Goes back one entry in the session history. With no earlier entry it does nothing.
/// Engines that declare <see cref="EngineCapabilities.History"/> implement it.
Expand Down
17 changes: 15 additions & 2 deletions src/E2E/Engine/LocatorAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ public sealed record Focus : LocatorAction;
/// <summary>Scrolls the node's nearest scrollable container until the node is in the viewport.</summary>
public sealed record ScrollIntoView : LocatorAction;

/// <summary>Scrolls a scrollable node about three quarters of its height or width. The viewport swipe is <see cref="IEngineSession.SwipeAsync"/>.</summary>
public sealed record Swipe(ScrollDirection Direction) : LocatorAction;
/// <summary>Scrolls a scrollable node about three quarters of its height or width. The viewport swipe is <see cref="IEngineSession.SwipeAsync(ScrollDirection, CancellationToken)"/>.</summary>
public sealed record Swipe(ScrollDirection Direction, Momentum? Momentum = null) : LocatorAction;
}

/// <summary>Which way a scroll moves the content into view. <see cref="Down"/> reveals what is below.</summary>
Expand All @@ -49,3 +49,16 @@ public enum ScrollDirection
Left,
Right,
}

/// <summary>Fling strength of a swipe: how far the gesture travels. Upstream's <c>Momentum</c> (<c>none</c>, <c>slow</c>, <c>fast</c>).</summary>
public enum Momentum
{
/// <summary>No fling: half an extent on the web, half a node on a device.</summary>
None,

/// <summary>A short stride. The engine decides how far: a node swipe on a device is a quarter of the node, on the web three quarters of its extent.</summary>
Slow,

/// <summary>A long stride: four fifths of a node on a device, one and a half extents on the web.</summary>
Fast,
}
12 changes: 6 additions & 6 deletions src/E2E/Engine/PageScript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,18 +1049,18 @@ internal static class PageScript

// Moves about three quarters of a screen, so every row shows at least once.
public const string ScrollViewport = """
(direction) => {
const x = Math.round(innerWidth * 0.75);
const y = Math.round(innerHeight * 0.75);
([direction, stride]) => {
const x = Math.round(innerWidth * stride);
const y = Math.round(innerHeight * stride);
const by = { up: [0, -y], down: [0, y], left: [-x, 0], right: [x, 0] }[direction];
window.scrollBy({ left: by[0], top: by[1], behavior: "instant" });
}
""";

public const string ScrollElement = """
(el, direction) => {
const x = Math.round(el.clientWidth * 0.75);
const y = Math.round(el.clientHeight * 0.75);
(el, [direction, stride]) => {
const x = Math.round(el.clientWidth * stride);
const y = Math.round(el.clientHeight * stride);
const by = { up: [0, -y], down: [0, y], left: [-x, 0], right: [x, 0] }[direction];
el.scrollBy({ left: by[0], top: by[1], behavior: "instant" });
}
Expand Down
19 changes: 15 additions & 4 deletions src/E2E/Engine/WebEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1485,13 +1485,15 @@ public async Task NewContextAsync()
_context = context;
}

public Task SwipeAsync(ScrollDirection direction, CancellationToken cancellationToken) => ActedAsync(cancellationToken, () => RunAsync("scroll", cancellationToken, async () =>
public Task SwipeAsync(ScrollDirection direction, CancellationToken cancellationToken) => SwipeAsync(direction, null, cancellationToken);

public Task SwipeAsync(ScrollDirection direction, Momentum? momentum, CancellationToken cancellationToken) => ActedAsync(cancellationToken, () => RunAsync("scroll", cancellationToken, async () =>
{
RequireObservation();
var budget = Budget("scroll", cancellationToken);
try
{
await budget.WithinAsync(Page.EvaluateAsync(PageScript.ScrollViewport, Direction(direction))).ConfigureAwait(false);
await budget.WithinAsync(Page.EvaluateAsync(PageScript.ScrollViewport, new object[] { Direction(direction), Stride(momentum) })).ConfigureAwait(false);
}
catch (Exception ex) when (WebErrors.IsPlaywright(ex))
{
Expand All @@ -1500,7 +1502,7 @@ public Task SwipeAsync(ScrollDirection direction, CancellationToken cancellation
catch (EngineException ex) when (ex.Code == EngineErrorCodes.OperationTimeout)
{
// As upstream's viewport swipe, a scroll the deadline cut off may have moved the page.
throw WebErrors.ClassifyAction(ex, new LocatorAction.Swipe(direction));
throw WebErrors.ClassifyAction(ex, new LocatorAction.Swipe(direction, momentum));
}
}));

Expand Down Expand Up @@ -1772,7 +1774,7 @@ private async Task DispatchAsync(IElementHandle element, LocatorAction action, O
await element.ScrollIntoViewIfNeededAsync(new ElementHandleScrollIntoViewIfNeededOptions { Timeout = budget.PlaywrightTimeout }).ConfigureAwait(false);
break;
case LocatorAction.Swipe swipe:
await element.EvaluateAsync(PageScript.ScrollElement, Direction(swipe.Direction)).ConfigureAwait(false);
await element.EvaluateAsync(PageScript.ScrollElement, new object[] { Direction(swipe.Direction), Stride(swipe.Momentum) }).ConfigureAwait(false);
break;
default:
throw new EngineException(EngineErrorCodes.UnsupportedCapability, "Web engine cannot perform " + action.GetType().Name + ".");
Expand Down Expand Up @@ -2476,6 +2478,15 @@ EngineException Unchanged(string detail = "") =>
_ => KeyboardModifier.ControlOrMeta,
});

// The share of the extent a swipe moves, as upstream's swipeDistance ratios: none half, slow three quarters, fast one and a half.
// A swipe naming no momentum keeps the port's three quarters.
private static double Stride(Momentum? momentum) => momentum switch
{
Momentum.None => 0.5,
Momentum.Fast => 1.5,
_ => 0.75,
};

private static string Direction(ScrollDirection direction)
{
return direction switch
Expand Down
Loading
Loading