From 0b4fffbfd35c4f0dc72d7f60da9ee50c413f0113 Mon Sep 17 00:00:00 2001 From: Dario Kondratiuk Date: Sun, 25 Oct 2026 13:23:17 -0300 Subject: [PATCH 1/2] Port screen.scrollUntilVisible and locator.scrollUntilVisible (upstream locator/screen.ts) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DH1b4gwq2kmMK94pdZRQZm --- COMPATIBILITY.md | 7 +- skills/e2e-dotnet/references/writing-tests.md | 10 + src/E2E.Mobile/DeviceActions.cs | 2 +- src/E2E.Mobile/DeviceSupport.cs | 10 - src/E2E/Engine/IEngine.cs | 6 + src/E2E/Engine/LocatorAction.cs | 17 +- src/E2E/Engine/PageScript.cs | 12 +- src/E2E/Engine/WebEngine.cs | 19 +- src/E2E/Locator/Screen.cs | 152 +++++++++++++- src/E2E/Run/E2ESession.cs | 3 +- ...ALocatorMadeByAnotherTargetsScreenTests.cs | 38 ++++ .../ScreenScrollUntilVisibleTests.cs | 197 ++++++++++++++++++ .../ScrollUntilVisible/ScrollScreen.cs | 117 +++++++++++ 13 files changed, 561 insertions(+), 29 deletions(-) create mode 100644 tests/E2E.Tests/ScrollUntilVisible/ALocatorMadeByAnotherTargetsScreenTests.cs create mode 100644 tests/E2E.Tests/ScrollUntilVisible/ScreenScrollUntilVisibleTests.cs create mode 100644 tests/E2E.Tests/ScrollUntilVisible/ScrollScreen.cs diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 89860668..5a6d8797 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -35,6 +35,7 @@ Names are C# versions of the JavaScript API: `agent.act` is `ActAsync`, `screen. - Actions: tap, click (an alias of tap), doubleTap (`DblClickAsync` is an alias), fill (a string or a `Secret`), pressSequentially, press, check, uncheck, clear, selectOption, focus, scrollIntoView. They wait up to the action timeout for exactly one enabled, visible match. `selectOption` takes a string that matches an option value or label. `ActionOptions.Timeout` overrides the wait per action (`PressSequentiallyOptions` adds `Delay`). The web engine's own Playwright wait still uses the configured action timeout - Locator reads: `textContent`, `inputValue`, `getAttribute`, `isVisible`, `isHidden`, `isEnabled`, `isDisabled`, `isChecked`, `boundingBox`, `count`, `all`, `allTextContents`. A read of one node (`textContent`, `inputValue`, `getAttribute`, `isEnabled`, `isDisabled`, `isChecked`, `boundingBox`) waits for its one match until the action timeout, then reads once with no retry for a value; `isVisible`, `isHidden`, `count`, `all`, and `allTextContents` answer from the current screen at once. No read verifies an earlier `act`. `textContent` and `allTextContents` collapse each whitespace run to one space and trim, as upstream. A single-node read fails with `NOT_FOUND` when nothing matches before the action timeout ends - `screen.tree()` and `screen.debug()` (commit `d7ec0ec`) are `Screen.TreeAsync` and `Screen.DebugAsync`. One observation of the whole screen, rendered by `FailureEvidence.Listing` (the trace's screen at failure, upstream's `prepareObservation`): one node per line, children indented, secrets redacted, cut at the observation byte limit (262144). A node the page does not show is left out. A step of kind `screen`. `DebugAsync` writes the text to `Console.Out`, where a test framework picks it up. Differences: `Timeout` bounds the read with a linked token and fails with `OPERATION_TIMEOUT`, where upstream passes it to the engine operation. A `Locator` has no `TreeAsync`, so upstream's refusal on a locator is a compile error, and the port has no frames. The same commit's change to print test output without the `list` reporter, and to keep stdout for `--reporter json`, is not ported: the port has no runner. `screen-tree.test.ts` is `ScreenTree/ScreenTreeTests` and `ScreenDebugTests` (not ported: the refusal on a locator, the unknown option, and `sdk-types.ts`'s checks, which a typed API makes compile errors); `screen-tree-secrets.test.ts` is `ScreenTreeSecrets/ScreenTreeTests` (through a session over a fake engine). `run-user-output.test.ts`'s two new cases are not ported with the runner change. +- `screen.scrollUntilVisible` and `locator.scrollUntilVisible` (`locator/screen.ts`) are `Screen.ScrollUntilVisibleAsync` and `Locator.ScrollUntilVisibleAsync`, with `ScrollUntilVisibleOptions { Direction, Momentum, Timeout }` (down, slow, 30 s by default). `Momentum` (`None`, `Slow`, `Fast`; upstream's `Momentum`) moved from `E2E.Mobile` to `E2E.Engine` beside `ScrollDirection`, and `LocatorAction.Swipe` carries it. `IEngineSession.SwipeAsync(direction, momentum, ct)` is the viewport swipe with a stride; its default drops the momentum. `WebEngine` strides as upstream's `swipeDistance` (none half, slow three quarters, fast one and a half of the extent; a swipe naming none keeps three quarters), `MobileEngine` strides a node swipe by it and keeps the device client's default for the whole-screen swipe, and `DocumentEngine` and `WinAppEngine` have one stride. The port has no operation context, so a swipe's deadline is the cancellation of the token it runs under, and a swipe the deadline ends fails with an `OPERATION_TIMEOUT` cause. A locator swipe's engine error is not wrapped in `ACTION_FAILED`, since the port has no `translateLocatorError`. `filter({ has })` now refuses a locator of another screen with `INVALID_LOCATOR`, as upstream's `ownLocator` does - `locator.waitFor` with `attached`, `detached`, `visible` (default), and `hidden`, as `WaitForAsync(new LocatorWaitForOptions { State, Timeout })`. The timeout defaults to the action timeout. `LocatorWaitForOptions` is named apart from `agent.waitFor`'s `WaitForOptions`. Differences: a timeout fails with `TIMEOUT` (upstream `LOCATOR_NOT_FOUND`, `locator did not become `), and a passing `waitFor` does not verify an earlier `act` (upstream it does). The timeout carries `Details` as upstream's does (the locator, what it asked for, and how long it waited; see [Trace page](#trace-page)) - Locator expectations: visible, hidden, attached, enabled, disabled, checked, selected, expanded, focused, text, contained text, value, attribute, accessible name, and count. They poll until the assertion timeout, or the matcher's own `timeout`. `Not` inverts a matcher, which then passes after 1000 ms of continuous truth (or the whole budget when it is shorter). Text matchers take the same `TextMatch` as queries, `ignoreCase`, and a list form. The boolean flags are `visible`, `attached`, `enabled`, and `isChecked` (`checked` is a C# keyword) - `toHaveScreenshot` as `Expect.That(Screen).ToHaveScreenshotAsync(name, options)` and the same on a locator, with `Not` and `Expect.Soft(locator)`; see [Visual comparison](#visual-comparison) @@ -124,7 +125,7 @@ Names are C# versions of the JavaScript API: `agent.act` is `ActAsync`, `screen. - Vision on `act` (the screenshot and point tools). `app.screenshot()` is not ported either. Video is ported on the web engine and on `MobileEngine` with agent-device's own recording: see [Video](#video). Vision on judgments is ported: see [Agents](#agents). `ToHaveScreenshotAsync` is: see [Visual comparison](#visual-comparison) - Parallel workers and serial suites - The full locator action set (`dragTo`, `swipe`, `hover`, `setInputFiles`, `secondaryTap`, `longPress`, pointer points). `secondaryTap` and `longPress` have no counterpart in the document engine -- The screen-level `screen.tapAt`, `screen.swipe`, and `screen.scrollUntilVisible` +- The screen-level `screen.tapAt` and `screen.swipe` - Per-action options other than `timeout` (and `delay` for `pressSequentially`): click `modifiers` on a locator (the agent's `tap` and `double_tap` hold them, above), tap `position`, and the `{ label, value, index }` form of `selectOption` - Diff-only observations. Each model turn receives a full text snapshot - Telemetry @@ -154,7 +155,7 @@ Not ported, because the feature is not in the port: - No `ListReporter`, run header, or live window; no JUnit or Markdown report renderer; no serial groups, workers, shards, runner, `e2e run`, `e2e init`, or `e2e list`; no JavaScript config loader; no `test.extend`; no managed process; no telemetry; no wordmark; no `projectId`; no `runWithRetries`: the `report-list`, `report-junit`, `report-markdown`, `cli-run`, `cli-boot`, `config-load`, `parallel-workers`, `managed-process`, `extended-fixtures`, `telemetry-*`, `wordmark`, `target-identity`, and `retry` tests, and the three `report-status` tests about the last attempt of a serial group - No value expectations (`expect-values` `truthy/falsy/null/undefined fail on the wrong value, and pass negated`), no `toHaveClass`, `frameLocator`, `*css=` captures, or download and click-modifier tests; the `waitForResponse`, `route`, and `unroute` predicate tests and the `an option ... does not take is refused` tests cannot happen, because C# overloads are typed -- No run-wide usage accumulator (the port has `ModelUsage`, but no overflow-capping total or usage events), `scrollUntilVisible`, `withStallGuard`, `SessionStore`, `realmSlot`, `traceCacheKeyHash`, or `verifyEndState` with `waitMs`: the `model-usage`, `scroll-until-visible`, `tool-loop-stall`, `session-envelope`, `realm-slot`, `trace-identity`, and `trace-replay` end-wait tests +- No run-wide usage accumulator (the port has `ModelUsage`, but no overflow-capping total or usage events), `withStallGuard`, `SessionStore`, `realmSlot`, `traceCacheKeyHash`, or `verifyEndState` with `waitMs`: the `model-usage`, `tool-loop-stall`, `session-envelope`, `realm-slot`, `trace-identity`, and `trace-replay` end-wait tests - No `pixelFallback` observe option, `tap_at` or `select_at`, upload tool, or `schema` and `vision` options on `act`: the `agent-fallback-handoff`, `agent-act-pixel-tools`, `agent-act-verbs` hidden-file, `act-validation`, and `agent-screen-update` tests - No removed-key hints in `agent-config`, no `artifacts` config key (`refuses the removed kinds list`), no module-error explainer (`diagnose`), no `defineEngine` (`binds validateApp, prepare, finish, and fixture factories to the spec`) - No `waitForDownload`, `sanitizeFilename`, or `artifactPath` in the web engine, and no stalled-reader pixel fallback in `semantic-fallback` @@ -565,7 +566,7 @@ Upstream's `--trace` writes `/results//trace.md`, a markdown pag - What the page tells: the title, the file, the status and duration, what the attempt ran on, the error (a fence in it cannot close the block), `Look at:`, whether the attempts failed alike, every step (its label, duration, model calls, the video offset, the hook, the explanation of a failed one, the cache's decision, the steps' screenshots, and how the screen changed), the app log, the agent's last turns, the screen at failure, other errors, and the evidence. Every piece of text goes through `MarkdownText` (a mention or a URL becomes code, markup is escaped, a loopback origin is dropped), and the whole page goes through the attempt's redactor again before it is written. - Not filled by the port yet, each with its issue. The renderer shows them when a record carries them, and the tests build such records by hand. A serial group's members: the port has no serial groups. Filled since: the agent's turns of a step (#481), the step events, the hook, and the app log (see [Trace feed](#trace-feed)), and what became of a step's recording with the model that recorded it (see [Trace page](#trace-page)). - Recording outcome. `E2ESession.Flush` is upstream's `settleStagedTraces`: it returns a `CacheWrite` per step index, and the session sets `StepRecord.CacheWrite` on each step that carries cache detail. A step decides two itself when it concludes: `Evicted` (a passed step an `end-mismatch` hand-off had to repair) and `NoChange` (the step changed nothing a replay could check and read no entry; one that read an entry is evicted at the settlement instead). The settlement reports the rest: `Saved`, `Kept` (the replayed entry, or one holding the same flow, which ignores the recording model), `Unconfirmed`, and `Evicted` (an entry the step read that the settlement deleted: a whole replay no check confirmed, a replay that ended in a failure, or an entry the step read and replaced with nothing). A flow the step recorded anew is `Unconfirmed` when no check confirmed it, whether or not an older entry was deleted, as upstream's `settleStagedTraces` reports it (only an entry replayed whole is `evicted`). Both report `Evicted` only after `IStepCache.Delete` returned, as upstream's `evict` and `settleStagedTraces` do since `689620d` (#1072). `IStepCache.Delete` returns when the entry is absent and throws when deletion fails, as upstream's `CacheStore.delete` does, and `FileStepCache.Delete` ignores only a missing file or directory, as upstream's `FileCacheStore` ignores only `ENOENT`. A `Delete` that throws leaves the entry in place: `E2ESession.Flush` reports `Unconfirmed` for a whole replay or a new recording and nothing for an act that recorded nothing (upstream's `evict` stages nothing then), and `StepTraceSession.ConcludeAsync` (upstream's `evict` for a repaired `end-mismatch`) stages no decision. Upstream also evicts a failed step's consumed replay in `conclude`; the port does both at the settlement, so a delete `ConcludeAsync` could not make is tried once more there. Tests: `StepCache/StepTraceSessionTests` "does not stage an eviction outcome when deleting a consumed entry rejects" and the three `it` of `trace-cache-config.test.ts` in `TraceCacheConfig/FlushStagedTracesTests`, the default-store one a no-op as root and on Windows, as upstream. `NotWritten` is a store whose `Write` throws; the port's `FileStepCache` has no size limit on a write. Unlike upstream, a cancelled attempt reports none, since it never settles. The page tells the model after the cache decision: ``recorded with `fake-loop/model-A` ``. `CacheEntry.RecordedBy` and `CacheInfo.RecordedBy` are upstream's `recordedBy`, but one string, `IAgentModel.Name`, which spells provider and model id together (upstream's is a `provider` and a `model`). The recorder redacts it and drops one that is blank or past 300 characters, and a read does the same, so a malformed one never makes an entry a miss. It is the agent's configured model, which an executor's `Model` becomes when the agent sets none; an agent with none records none. It never keys the entry. The port has no list reporter, so the `recorded with` note on a list line has no port; `cache.recordedBy` and `cache.write` are in `report.json` (see [Report document](#report-document)). Tests: `CacheModelProvenance/` holds both `it` of `cache-model-provenance.test.ts`, each partial: they read the step record, the entry file, and `report.json` (checked against the schema), not the list line or `trace: 'on'`, and the malformed cases are one string (the nine cases of the first are seven). The second is also partial where an executor reports no model: upstream's report names the executor, the port's says `unknown`. `StepCache/CacheWriteTests` (.NET-only) covers each outcome. `TraceRecorderTests` has the two oversized cases as one theory, the blank cases as one theory, and the redaction in "records a secret fill by name only". The two `it` of `trace-cache.test.ts` that read a bounded or blank `recordedBy` are covered by the `oversized-model`, `blank-model`, and `invalid-model` cases of the first test. -- Error details. `ErrorDetails` (`E2EException.Details`, public) is upstream's `ErrorDetails` of `internal/errors.ts`, with the same fields in C# casing: `Locator`, `Expected`, `Observed`, `Matches`, `Role`, `Name`, `TestId`, and `WaitedMs`. A locator that matched nothing (`NOT_FOUND`) carries the locator as written, what it asked for, and how long it waited. One that matched too much (`STRICT_MODE`) carries the same without the wait and with `Matches`. A `waitFor` that timed out carries the wait. A locator matcher that failed carries `Locator`, `Expected` (with `not ` when negated), `Observed`, and `Matches`, and `ToHaveScreenshot` carries the stored screenshot's path as `Expected` and what it observed. `LocatorResolver.LocatorDetails` and `LocatorExpression.ExpressionHints` are upstream's `locatorDetails` and `expressionHints`. `ErrorSerializer.Serialize` keeps the details as upstream's `serializeError` does: text redacted, stripped of control characters, cut at 2 KiB, empty text dropped. The port has no report boundary, so `ErrorRedaction.Failure` also redacts the details text when it builds the error. Upstream sets details in two more places the port has no feature for: `screen.scrollUntilVisible` and a tap at a point. Not ported: the details of an error classified from another module copy (there is one copy of an assembly). +- Error details. `ErrorDetails` (`E2EException.Details`, public) is upstream's `ErrorDetails` of `internal/errors.ts`, with the same fields in C# casing: `Locator`, `Expected`, `Observed`, `Matches`, `Role`, `Name`, `TestId`, and `WaitedMs`. A locator that matched nothing (`NOT_FOUND`) carries the locator as written, what it asked for, and how long it waited. One that matched too much (`STRICT_MODE`) carries the same without the wait and with `Matches`. A `waitFor` that timed out carries the wait. A locator matcher that failed carries `Locator`, `Expected` (with `not ` when negated), `Observed`, and `Matches`, and `ToHaveScreenshot` carries the stored screenshot's path as `Expected` and what it observed. `LocatorResolver.LocatorDetails` and `LocatorExpression.ExpressionHints` are upstream's `locatorDetails` and `expressionHints`. `ErrorSerializer.Serialize` keeps the details as upstream's `serializeError` does: text redacted, stripped of control characters, cut at 2 KiB, empty text dropped. The port has no report boundary, so `ErrorRedaction.Failure` also redacts the details text when it builds the error. `Screen.ScrollUntilVisibleAsync` sets them too, on its `LOCATOR_NOT_FOUND`. Upstream sets details in one more place the port has no feature for: a tap at a point. Not ported: the details of an error classified from another module copy (there is one copy of an assembly). - Failure evidence. `FailureEvidence.CaptureAsync` is upstream's `captureFailureEvidence` (`run/failure-evidence.ts`), with its options, its 5 s budget for the observation and the screenshot together, its limits, and its scoring of the nodes closest to what a locator asked for. A failed attempt that keeps a trace page looks at the app once more and keeps the screen as `attempt-/screen-at-failure.txt`, one `#id role "name" text="…" [states]` line for each node (indented by depth, hidden nodes left out as the lines the model reads leave them out, a node with no role read as `text`), a masked screenshot as `attempt-/screenshots/failure.png`, the location, and for a locator that matched nothing or too much, up to five candidates. The page shows them under `## Screen at failure`. The look is taken once per attempt, the moment the test body fails, ahead of the test's own teardown (upstream's `captureEvidence`, called after the body, the late soft failures, and the settled callbacks, and again after the teardown for a failure that first landed there). `E2ESession.CaptureFailureEvidenceAsync` is that look, and an adapter calls it: the NUnit adapter wraps the test method (`IWrapTestMethod`, since `ITestAction` runs after `[TearDown]`) and looks when the body throws or an `Expect.Soft` failed; the xUnit adapter looks from a `BeforeAfterTestAttribute` after the test method, when the result is `Failed`. A failure that first lands in the teardown is looked at when the session is released, as upstream does. Differences: an xUnit `Expect.Soft` failure is kept on the session until the test is disposed, so it is looked at then, after the test's own `DisposeAsync` (closing the soft failures early would move them out of the test's result). An attempt whose trace page is not kept looks at nothing. The screenshot is dropped when the attempt knows a secret (as every pixel the port keeps; upstream keeps its masked failure screenshot), when the look at the screen failed, and when the engine does not report that it covered a region for each secure node. The viewport is the screenshot's size in viewport pixels, since the port's observation has none, so it is absent without a screenshot. When the look fails (a crashed page, a closed app) there is no evidence, not the last screen a step saw. The candidates are listed for the codes `NOT_FOUND`, `STRICT_MODE`, and `TIMEOUT` (upstream's `LOCATOR_NOT_FOUND` and `LOCATOR_AMBIGUOUS` too, which an agent action raises). - Differences: the page has no `sourceUrl` or `artifactsUrl` link and no `Standalone` mode, and its footer does not point at `report.json`, which the port writes beside the results (see [Report document](#report-document)). A skipped attempt's reason (`Also skipped:`) has no port. `lastTurnLines` and `screenLines` belong to the run page and are not ported. Not ported: `writeHostTrace` (`run/host-trace.ts`) and the hosted session's page, which need the hosted session (#403). - .NET-only names: `E2ESession.Evidence` (what the session captured; upstream reads it from the report), `E2ESession.CaptureFailureEvidenceAsync` and `E2EFixture.CaptureFailureEvidenceAsync` (upstream's local `captureEvidence` closure; the fixture's is for the adapters), `LooksBeforeTeardownAttribute` (NUnit and xUnit; internal), NUnit `E2ETest.CaptureEvidenceOf` (internal; reaches the protected fixture method), xUnit `E2ETest.LookAtFailure` and its `Running` map (the attribute is handed the test, not the instance), `FailureEvidence.ScreenFile`, `ScreenshotFile`, `ScreenArtifactId`, and `ScreenshotArtifactId` (upstream registers the files with its artifact sink), `FailureEvidenceOptions.Observe` and `Capture` (upstream hands the open session), `AttemptReport` (and its `ResultId` and `ResultSegmentOf`, which give the result id and directory name; a .NET test has no one source file, so the test class stands for upstream's `file`: the fixture passes its full name as `E2ESessionOptions.TestFile`, it joins the title in the id hash as upstream's `testId` does, and its simple name leads the segment; a session started with no `TestFile`, such as `explore` or a direct `E2ESession.StartAsync`, hashes the title alone), `AttemptReport.TestId` and `EncodeTitle` (internal; upstream's `testId` and `encodeTitle` in `internal/ids.ts`, with a title path of one title; the title is percent-encoded so a `::` in it cannot pass for the separator; the hash input carries a NUL mark so a title-only session cannot share an id with a test id), `E2ESessionOptions.TestFile` (public, optional; the one new member of the public API, set by `E2EFixture`, so the NUnit and xUnit adapters change nothing), `TraceOutput`, `WriteTracePagesOptions`, `ReportResult.Directory` (an attempt writes under `ArtifactPaths.ResultSegment` of the result's id and title, so two titles that slug alike keep apart; the segment leads with the test class's name when the fixture started the session, and the page goes in that directory), `StepRecorder.AddModelCall` (upstream's `usage.record` in `StepAccounting`), `FileStepCache.CacheDirectory` (internal), and `CacheInfo.Entry` (upstream's `entry` of `StepCacheInfo`), `OptionalTextConverter` (upstream's `readRecordedModel` tolerates a malformed `recordedBy`; the port's is a string, which a converter reads leniently), `PendingAct.Decided` (upstream's `decided` staged entry), and `CacheInfo.WithRecordedBy` (upstream spreads `recordedBy` into the info). diff --git a/skills/e2e-dotnet/references/writing-tests.md b/skills/e2e-dotnet/references/writing-tests.md index 28297c82..f5e9b433 100644 --- a/skills/e2e-dotnet/references/writing-tests.md +++ b/skills/e2e-dotnet/references/writing-tests.md @@ -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: diff --git a/src/E2E.Mobile/DeviceActions.cs b/src/E2E.Mobile/DeviceActions.cs index 539b77fd..a988db3e 100644 --- a/src/E2E.Mobile/DeviceActions.cs +++ b/src/E2E.Mobile/DeviceActions.cs @@ -156,7 +156,7 @@ private static async Task RunAsync(DeviceSession session, IDeviceClient client, await PressKeyAsync(client, target, press.Key, settle, cancellationToken).ConfigureAwait(false); break; case LocatorAction.Swipe swipe: - await SwipeNodeAsync(client, target, swipe.Direction, momentum: null, cancellationToken).ConfigureAwait(false); + await SwipeNodeAsync(client, target, swipe.Direction, swipe.Momentum, cancellationToken).ConfigureAwait(false); break; default: // Not in NodeActionKinds, so the harness never sends them. A device has no touch equivalent. diff --git a/src/E2E.Mobile/DeviceSupport.cs b/src/E2E.Mobile/DeviceSupport.cs index 030c9dc1..12719637 100644 --- a/src/E2E.Mobile/DeviceSupport.cs +++ b/src/E2E.Mobile/DeviceSupport.cs @@ -16,16 +16,6 @@ namespace E2E.Mobile; /// The logical size of the screen, in points. public readonly record struct ViewportSize(double Width, double Height); -/// How far a scroll gesture travels inside its rect. Unset is the default. -public enum Momentum -{ - /// A quarter of the box: shorter than any row, so every row lands fully on screen at some stop. - Slow, - - /// Four fifths of the box. - Fast, -} - /// The screenshot fields a viewport probe reads off the device client's response. public sealed record RawScreenshotResult { diff --git a/src/E2E/Engine/IEngine.cs b/src/E2E/Engine/IEngine.cs index 2f07f87c..31d2f5a6 100644 --- a/src/E2E/Engine/IEngine.cs +++ b/src/E2E/Engine/IEngine.cs @@ -160,6 +160,12 @@ Task> LocateAsync(LocatorExpression expression, Time /// Task SwipeAsync(ScrollDirection direction, CancellationToken cancellationToken) => Unsupported("scroll"); + /// + /// with the stride of the gesture: upstream's swipe(direction, + /// momentum). An engine that takes a momentum overrides it; the default drops it, so an engine with one stride swipes by that. + /// + Task SwipeAsync(ScrollDirection direction, Momentum? momentum, CancellationToken cancellationToken) => SwipeAsync(direction, cancellationToken); + /// /// Goes back one entry in the session history. With no earlier entry it does nothing. /// Engines that declare implement it. diff --git a/src/E2E/Engine/LocatorAction.cs b/src/E2E/Engine/LocatorAction.cs index c0187fde..a1d77225 100644 --- a/src/E2E/Engine/LocatorAction.cs +++ b/src/E2E/Engine/LocatorAction.cs @@ -37,8 +37,8 @@ public sealed record Focus : LocatorAction; /// Scrolls the node's nearest scrollable container until the node is in the viewport. public sealed record ScrollIntoView : LocatorAction; - /// Scrolls a scrollable node about three quarters of its height or width. The viewport swipe is . - public sealed record Swipe(ScrollDirection Direction) : LocatorAction; + /// Scrolls a scrollable node about three quarters of its height or width. The viewport swipe is . + public sealed record Swipe(ScrollDirection Direction, Momentum? Momentum = null) : LocatorAction; } /// Which way a scroll moves the content into view. reveals what is below. @@ -49,3 +49,16 @@ public enum ScrollDirection Left, Right, } + +/// Fling strength of a swipe: how far the gesture travels. Upstream's Momentum (none, slow, fast). +public enum Momentum +{ + /// The engine's default stride. + None, + + /// A short stride, shorter than any row, so every row lands fully on screen at some stop. + Slow, + + /// A long stride. + Fast, +} diff --git a/src/E2E/Engine/PageScript.cs b/src/E2E/Engine/PageScript.cs index 79806ff3..431bf03c 100644 --- a/src/E2E/Engine/PageScript.cs +++ b/src/E2E/Engine/PageScript.cs @@ -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" }); } diff --git a/src/E2E/Engine/WebEngine.cs b/src/E2E/Engine/WebEngine.cs index a9b0cd57..591fa56e 100644 --- a/src/E2E/Engine/WebEngine.cs +++ b/src/E2E/Engine/WebEngine.cs @@ -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)) { @@ -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)); } })); @@ -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 + "."); @@ -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 diff --git a/src/E2E/Locator/Screen.cs b/src/E2E/Locator/Screen.cs index 66fd45ac..2b700f92 100644 --- a/src/E2E/Locator/Screen.cs +++ b/src/E2E/Locator/Screen.cs @@ -20,6 +20,7 @@ public sealed class Screen private readonly Func>>? _locate; private readonly Func _cancellation; private readonly Action _verified; + private readonly Func? _swipe; internal Screen( Func> observe, @@ -30,8 +31,10 @@ internal Screen( TimeSpan assertionTimeout, SoftFailures? softFailures = null, TimeProvider? clock = null, - Func>>? locate = null) + Func>>? locate = null, + Func? swipe = null) { + _swipe = swipe; SoftFailures = softFailures ?? new SoftFailures(); _observe = observe; _perform = perform; @@ -151,6 +154,104 @@ public Locator GetByTestId(TextMatch testId, TextMatchOptions options) public async Task DebugAsync(ActionOptions? options = null, CancellationToken cancellationToken = default) => Console.WriteLine(await ReadTreeAsync("screen.debug", options, cancellationToken).ConfigureAwait(false)); + /// + /// Scrolls the viewport until is visible: polls the target and swipes between polls, down and slowly + /// unless say otherwise. Every swipe is bounded by the scroll's own deadline, not the + /// action timeout. Upstream's screen.scrollUntilVisible. + /// + /// LOCATOR_NOT_FOUND when the target did not become visible in time; INVALID_LOCATOR when it belongs to another screen or attempt. + public Task ScrollUntilVisibleAsync(Locator target, ScrollUntilVisibleOptions? options = null, CancellationToken cancellationToken = default) => + ScrollUntilVisibleAsync(target, options, null, cancellationToken); + + /// The scroll of screen or of a locator: on a locator () the node itself swipes, so a scroll container pages. + internal async Task ScrollUntilVisibleAsync(Locator target, ScrollUntilVisibleOptions? options, Locator? scope, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + OwnLocator(target, "scrollUntilVisible"); + var timeout = options?.Timeout ?? ScrollUntilVisibleOptions.DefaultTimeout; + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero, "timeout"); + var direction = options?.Direction ?? ScrollDirection.Down; + var momentum = options?.Momentum ?? Momentum.Slow; + var token = Token(cancellationToken); + var label = target.Query.Describe(); + await RunStepAsync(StepKind.Screen, "screen.scrollUntilVisible", label, async () => + { + var deadline = new Deadline(timeout, Clock); + var startedAt = Clock.GetTimestamp(); + TestException NotVisible(Exception? cause) + { + var message = "target did not become visible while scrolling: " + label; + return new TestException("LOCATOR_NOT_FOUND", message, cause!) { Details = LocatorResolver.LocatorDetails(target.Query, Clock.GetElapsedTime(startedAt)) }; + } + + var sampled = false; + while (true) + { + token.ThrowIfCancellationRequested(); + var startedWith = deadline.Remaining(Clock.GetUtcNow()); + SemanticNode? node; + try + { + node = await target.ResolveSingleAsync(token, deadline).ConfigureAwait(false); + } + catch (EngineException ex) when (sampled && Time.CutOffAtDeadline(ex, startedWith)) + { + throw NotVisible(ex); + } + + sampled = true; + if (node is { States.Hidden: false }) + { + return; + } + + if (deadline.Expired(Clock.GetUtcNow())) + { + throw NotVisible(null); + } + + // Every swipe is bounded by the scroll's own deadline: the token a swipe runs under ends with it. + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(token); + bounded.CancelAfter(deadline.Remaining(Clock.GetUtcNow())); + try + { + await SwipeStepAsync(scope, direction, momentum, deadline, bounded.Token).ConfigureAwait(false); + } + catch (Exception ex) when (bounded.IsCancellationRequested && !token.IsCancellationRequested) + { + // The deadline ended the swipe: the scroll timed out, as an engine reporting a timeout on its budget would say. + throw NotVisible(ex is EngineException { Code: EngineErrorCodes.OperationTimeout } ? ex : new EngineException(EngineErrorCodes.OperationTimeout, "swipe exceeded the scroll timeout", retryable: false)); + } + catch (Exception ex) when (!token.IsCancellationRequested + && (deadline.Expired(Clock.GetUtcNow()) || (ex is EngineException { Code: EngineErrorCodes.OperationTimeout } && deadline.Remaining(Clock.GetUtcNow()) < Time.PollInterval))) + { + // The engine's timer can wake a moment before this clock reads the deadline, so a swipe that ran out of its + // budget within a poll interval of the deadline is the deadline too, whichever timer fired first. + throw NotVisible(ex); + } + } + }).ConfigureAwait(false); + } + + private Task SwipeStepAsync(Locator? scope, ScrollDirection direction, Momentum momentum, Deadline deadline, CancellationToken token) => + scope is null + ? (_swipe ?? throw new EngineException("UNSUPPORTED_CAPABILITY", "scroll is not supported by this engine.")).Invoke(direction, momentum, token) + : scope.PerformUntilAsync(new LocatorAction.Swipe(direction, momentum), deadline, token); + + /// + /// Refuses a locator not made by this screen: one made elsewhere would be evaluated through this target's engine, so it is + /// refused before any step. Upstream's ownLocator. + /// + internal void OwnLocator(Locator candidate, string api) + { + if (!ReferenceEquals(candidate.Screen, this)) + { + throw new TestException( + "INVALID_LOCATOR", + api + " requires a locator made by this screen; the one given belongs to another target's screen or another attempt"); + } + } + /// Renders an observation as returns it: redacted, one node per line. A session sets its own redactor and app origin; a screen outside one redacts the text with . internal Func? RenderTree { get; set; } @@ -258,6 +359,7 @@ public Locator Filter(TextMatch hasText) public Locator Filter(Locator has) { ArgumentNullException.ThrowIfNull(has); + _screen.OwnLocator(has, "filter({ has })"); return new Locator(_screen, new LocatorExpression { Kind = "filter", Source = Query, Has = has.Query }); } @@ -684,6 +786,37 @@ private async Task PerformAsync(LocatorAction action, ActionOptions? options, Ca } } + /// + /// Scrolls this locator's node until is visible: the node itself swipes, so a scroll container pages + /// without the pointer having to hover it first. Both locators must come from the same screen. Upstream's locator.scrollUntilVisible. + /// + public Task ScrollUntilVisibleAsync(Locator target, ScrollUntilVisibleOptions? options = null, CancellationToken cancellationToken = default) => + _screen.ScrollUntilVisibleAsync(target, options, this, cancellationToken); + + /// Performs one action on the one enabled match, waiting for it until (upstream's performUntil). + internal async Task PerformUntilAsync(LocatorAction action, Deadline deadline, CancellationToken token) + { + var started = _screen.Clock.GetTimestamp(); + while (true) + { + var node = await ResolveSingleAsync(token, deadline).ConfigureAwait(false); + if (node is { States.Disabled: false, States.Hidden: false }) + { + await _screen.PerformAsync(node, action, token).ConfigureAwait(false); + return; + } + + if (deadline.Expired(_screen.Clock.GetUtcNow())) + { + throw node is null + ? NotFound(_screen.Clock.GetElapsedTime(started)) + : new TestException("NOT_ACTIONABLE", Query.Describe() + (node.States.Hidden ? " is hidden." : " is disabled.")); + } + + await Task.Delay(_screen.PollInterval, token).ConfigureAwait(false); + } + } + /// /// Records one performed action as an engine event of the running step (upstream's recordAction, locator/engine.ts): /// the node it landed on as the resolve read it, and how long the node took to appear. The port retries no stale node, so the @@ -765,7 +898,7 @@ private async Task ResolveStrictAsync(CancellationToken cancellati } /// Returns the one match, or null when nothing matches. More than one match fails. - private async Task ResolveSingleAsync(CancellationToken token, Deadline? deadline = null) + internal async Task ResolveSingleAsync(CancellationToken token, Deadline? deadline = null) { var matches = await ResolveAsync(token, deadline: deadline).ConfigureAwait(false); if (matches.Count > 1) @@ -808,6 +941,21 @@ public class ActionOptions public TimeSpan? Timeout { get; init; } } +/// Options for Screen.ScrollUntilVisibleAsync and Locator.ScrollUntilVisibleAsync. +public sealed class ScrollUntilVisibleOptions +{ + internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// Which way to scroll. Defaults to . + public ScrollDirection? Direction { get; init; } + + /// The stride of each step. Defaults to . + public Momentum? Momentum { get; init; } + + /// How long to scroll before giving up, every swipe included. Defaults to 30 seconds. + public TimeSpan? Timeout { get; init; } +} + /// Options for . public sealed class PressSequentiallyOptions : ActionOptions { diff --git a/src/E2E/Run/E2ESession.cs b/src/E2E/Run/E2ESession.cs index c3ef47c6..0c22cce1 100644 --- a/src/E2E/Run/E2ESession.cs +++ b/src/E2E/Run/E2ESession.cs @@ -278,7 +278,8 @@ internal static async Task LaunchAsync(E2ESessionOptions options, IR options.ActionTimeout, options.AssertionTimeout, softFailures, - locate: engine.CanLocate ? engine.LocateAsync : null); + locate: engine.CanLocate ? engine.LocateAsync : null, + swipe: engine.SwipeAsync); screen.Steps = scope.Steps; screen.Exposure = scope.Exposure; screen.RememberSecret = secret => scope.Remember(new Dictionary(StringComparer.Ordinal) { [secret.Name] = secret }); diff --git a/tests/E2E.Tests/ScrollUntilVisible/ALocatorMadeByAnotherTargetsScreenTests.cs b/tests/E2E.Tests/ScrollUntilVisible/ALocatorMadeByAnotherTargetsScreenTests.cs new file mode 100644 index 00000000..007d0830 --- /dev/null +++ b/tests/E2E.Tests/ScrollUntilVisible/ALocatorMadeByAnotherTargetsScreenTests.cs @@ -0,0 +1,38 @@ +// Copyright 2026 TesterArmy. +// Modified by Dario Kondratiuk. +// SPDX-License-Identifier: Apache-2.0 + +namespace E2E.Tests.ScrollUntilVisible; + +/// +/// Upstream's describe("a locator made by another target's screen") (tests/unit/scroll-until-visible.test.ts). Not +/// ported: is refused by dragTo, because the port has no dragTo (COMPATIBILITY, Locators). +/// +public sealed class ALocatorMadeByAnotherTargetsScreenTests +{ + private const string Foreign = "the one given belongs to another target's screen or another attempt"; + + private static (ScrollScreen Web, ScrollScreen Mobile) Targets() => + (new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Target]] }), new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Target]] })); + + [Fact] + public async Task Is_refused_by_scrollUntilVisible_before_any_step_on_either_target() + { + var (web, mobile) = Targets(); + var failure = await Assert.ThrowsAsync(() => web.Screen.ScrollUntilVisibleAsync(mobile.Screen.GetByText("Accept"))); + Assert.Equal("INVALID_LOCATOR", failure.Code); + Assert.Equal("scrollUntilVisible requires a locator made by this screen; " + Foreign, failure.Message); + Assert.Empty(web.Steps.All()); + Assert.Empty(web.Swipes); + Assert.Empty(mobile.Swipes); + } + + [Fact] + public void Is_refused_by_filter_has() + { + var (web, mobile) = Targets(); + var failure = Assert.Throws(() => web.Screen.GetByRole("list").Filter(mobile.Screen.GetByText("Accept"))); + Assert.Equal("INVALID_LOCATOR", failure.Code); + Assert.Equal("filter({ has }) requires a locator made by this screen; " + Foreign, failure.Message); + } +} diff --git a/tests/E2E.Tests/ScrollUntilVisible/ScreenScrollUntilVisibleTests.cs b/tests/E2E.Tests/ScrollUntilVisible/ScreenScrollUntilVisibleTests.cs new file mode 100644 index 00000000..80dc8b8e --- /dev/null +++ b/tests/E2E.Tests/ScrollUntilVisible/ScreenScrollUntilVisibleTests.cs @@ -0,0 +1,197 @@ +// Copyright 2026 TesterArmy. +// Modified by Dario Kondratiuk. +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using E2E.Engine; + +namespace E2E.Tests.ScrollUntilVisible; + +/// +/// Upstream's describe('screen.scrollUntilVisible') (tests/unit/scroll-until-visible.test.ts): polls the target and +/// swipes the viewport between polls, slowly in the requested direction, until the target reads as visible or the deadline +/// passes. Every swipe is bounded by that deadline. +/// +/// Not ported: refuses an option it does not take before any swipe, because the options are a typed class. Partial, with +/// the reason in each name's comment: the port has no observation-root node to address a viewport swipe to (the swipe goes to the +/// engine's SwipeAsync), no translateLocatorError (an engine error reaches the test with the engine's own code, so a +/// locator swipe's timeout is not wrapped in ACTION_FAILED), and a typed Locator parameter that cannot be anything but a locator. +/// +/// +public sealed class ScreenScrollUntilVisibleTests +{ + private static readonly SemanticNode Target = ScrollScreen.Target; + + private static ScrollUntilVisibleOptions Options(TimeSpan timeout) => new() { Timeout = timeout }; + + [Fact] + public async Task On_a_locator_swipes_that_node_instead_of_the_viewport_so_a_scroll_container_pages() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Feed()], [ScrollScreen.Feed()], [ScrollScreen.Feed(Target)]] }); + var screen = scroll.Screen; + await screen.GetByRole("list", "Feed").ScrollUntilVisibleAsync(screen.GetByText("Accept")); + Assert.Equal( + [new Swipe("feed", ScrollDirection.Down, Momentum.Slow), new Swipe("feed", ScrollDirection.Down, Momentum.Slow)], + scroll.Swipes); + } + + /// Partial: the viewport swipe is addressed to the engine's SwipeAsync, not to an observation-root node. + [Fact] + public async Task Partial_Takes_a_direction_and_a_momentum_for_the_stride_of_each_step_on_the_screen_and_on_a_locator() + { + var viewport = new ScrollScreen(new ScrollScript { Screens = [[], [Target]] }); + await viewport.Screen.ScrollUntilVisibleAsync(viewport.Screen.GetByText("Accept"), new ScrollUntilVisibleOptions { Direction = ScrollDirection.Up, Momentum = Momentum.None }); + Assert.Equal([new Swipe("viewport", ScrollDirection.Up, Momentum.None)], viewport.Swipes); + + var scroll = new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Feed()], [ScrollScreen.Feed(Target)]] }); + var screen = scroll.Screen; + await screen.GetByRole("list", "Feed").ScrollUntilVisibleAsync(screen.GetByText("Accept"), new ScrollUntilVisibleOptions { Direction = ScrollDirection.Right, Momentum = Momentum.None }); + Assert.Equal([new Swipe("feed", ScrollDirection.Right, Momentum.None)], scroll.Swipes); + } + + [Fact] + public async Task Returns_at_once_when_the_target_is_already_visible_with_no_swipe() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[Target]] }); + await scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept")); + Assert.Empty(scroll.Swipes); + } + + /// Partial: the swipe is addressed to the engine's viewport swipe, not to an observation-root node. + [Fact] + public async Task Partial_Swipes_down_slowly_at_the_viewport_until_the_target_appears_then_stops() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[], [], [Target]] }); + await scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept")); + Assert.Equal( + [new Swipe("viewport", ScrollDirection.Down, Momentum.Slow), new Swipe("viewport", ScrollDirection.Down, Momentum.Slow)], + scroll.Swipes); + } + + [Fact] + public async Task Keeps_scrolling_past_a_target_the_engine_reports_hidden() + { + var hidden = ScrollScreen.HiddenTarget; + var scroll = new ScrollScreen(new ScrollScript { Screens = [[hidden], [hidden], [Target]] }); + await scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept")); + Assert.Equal(2, scroll.Swipes.Count); + } + + [Fact] + public async Task Fails_as_LOCATOR_NOT_FOUND_naming_the_locator_once_the_deadline_passes_and_records_the_failed_step() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[]] }); + var failure = await Assert.ThrowsAsync(() => scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept"), Options(TimeSpan.FromMilliseconds(250)))); + Assert.Equal("LOCATOR_NOT_FOUND", failure.Code); + Assert.Equal("target did not become visible while scrolling: getByText(\"Accept\")", failure.Message); + Assert.Equal("getByText(\"Accept\")", failure.Details?.Locator); + Assert.Equal("Accept", failure.Details?.Name); + Assert.NotNull(failure.Details?.WaitedMs); + Assert.True(scroll.Swipes.Count >= 1); + var step = Assert.Single(scroll.Steps.All()); + Assert.Equal(StepKind.Screen, step.Kind); + Assert.Equal("screen.scrollUntilVisible", step.Api); + Assert.Equal("getByText(\"Accept\")", step.Label); + Assert.Equal(StepStatus.Failed, step.Status); + Assert.Equal("LOCATOR_NOT_FOUND", step.Error?.Code); + } + + [Fact] + public async Task Bounds_a_viewport_swipe_by_its_own_deadline_a_hung_swipe_is_LOCATOR_NOT_FOUND_within_the_scroll_timeout_not_the_action_timeout() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[]], Hang = true, ActionTimeout = TimeSpan.FromSeconds(5) }); + var started = Stopwatch.StartNew(); + var failure = await Assert.ThrowsAsync(() => scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept"), Options(TimeSpan.FromMilliseconds(250)))); + Assert.Equal("LOCATOR_NOT_FOUND", failure.Code); + Assert.Equal("target did not become visible while scrolling: getByText(\"Accept\")", failure.Message); + Assert.Equal(EngineErrorCodes.OperationTimeout, Assert.IsType(failure.InnerException).Code); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(2)); + var step = Assert.Single(scroll.Steps.All()); + Assert.Equal("screen.scrollUntilVisible", step.Api); + Assert.Equal(StepStatus.Failed, step.Status); + } + + /// Partial: the cause is the engine's timeout itself; the port wraps no engine error in ACTION_FAILED. + [Fact] + public async Task Partial_Bounds_a_locator_swipe_by_the_same_deadline() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Feed()]], Hang = true, ActionTimeout = TimeSpan.FromSeconds(5) }); + var screen = scroll.Screen; + var started = Stopwatch.StartNew(); + var failure = await Assert.ThrowsAsync(() => + screen.GetByRole("list", "Feed").ScrollUntilVisibleAsync(screen.GetByText("Accept"), Options(TimeSpan.FromMilliseconds(250)))); + Assert.Equal("LOCATOR_NOT_FOUND", failure.Code); + Assert.Equal("target did not become visible while scrolling: getByText(\"Accept\")", failure.Message); + Assert.Equal(EngineErrorCodes.OperationTimeout, Assert.IsType(failure.InnerException).Code); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(2)); + var step = Assert.Single(scroll.Steps.All()); + Assert.Equal(StepStatus.Failed, step.Status); + Assert.Equal("LOCATOR_NOT_FOUND", step.Error?.Code); + } + + [Fact] + public async Task Owns_the_outcome_whichever_timer_fires_first_a_swipe_timing_out_on_the_budget_the_deadline_left_it_is_LOCATOR_NOT_FOUND() + { + // A swipe whose budget is the deadline's remainder, ending on the engine's own clock a moment before this one reads the deadline as passed. + var scroll = new ScrollScreen(new ScrollScript { Screens = [[]], HangFor = TimeSpan.FromMilliseconds(110), ActionTimeout = TimeSpan.FromSeconds(5) }); + var failure = await Assert.ThrowsAsync(() => scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept"), Options(TimeSpan.FromMilliseconds(200)))); + Assert.Equal("LOCATOR_NOT_FOUND", failure.Code); + Assert.Equal(EngineErrorCodes.OperationTimeout, Assert.IsType(failure.InnerException).Code); + } + + /// Partial: the swipe is addressed to the engine's viewport swipe, not to an observation-root node. + [Fact] + public async Task Partial_Still_swipes_with_less_than_a_poll_interval_of_the_budget_left() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[], [Target]] }); + await scroll.Screen.ScrollUntilVisibleAsync(scroll.Screen.GetByText("Accept"), Options(TimeSpan.FromMilliseconds(50))); + Assert.Equal([new Swipe("viewport", ScrollDirection.Down, Momentum.Slow)], scroll.Swipes); + } + + /// Partial: the port has no ACTION_FAILED translation, so the failure is the engine's own OPERATION_TIMEOUT, uncontextualised. + [Fact] + public async Task Partial_Keeps_an_engine_timeout_far_from_the_deadline_as_the_action_failure_it_is() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Feed()]], HangFor = TimeSpan.FromMilliseconds(20), ActionTimeout = TimeSpan.FromSeconds(5) }); + var screen = scroll.Screen; + var failure = await Assert.ThrowsAsync(() => + screen.GetByRole("list", "Feed").ScrollUntilVisibleAsync(screen.GetByText("Accept"), Options(TimeSpan.FromSeconds(30)))); + Assert.Equal(EngineErrorCodes.OperationTimeout, failure.Code); + var step = Assert.Single(scroll.Steps.All()); + Assert.Equal(StepStatus.Failed, step.Status); + Assert.Equal(EngineErrorCodes.OperationTimeout, step.Error?.Code); + } + + /// Partial: a typed Locator parameter cannot be anything but a locator, so this checks null, before any step or swipe. + [Fact] + public async Task Partial_Rejects_a_target_that_is_not_an_e2e_locator_before_recording_a_step() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[Target]] }); + await Assert.ThrowsAsync(() => scroll.Screen.ScrollUntilVisibleAsync(null!)); + Assert.Empty(scroll.Steps.All()); + Assert.Empty(scroll.Swipes); + } + + [Fact] + public async Task On_a_locator_labels_the_step_with_the_target_whose_description_carries_the_scope_once() + { + var scroll = new ScrollScreen(new ScrollScript { Screens = [[ScrollScreen.Feed()], [ScrollScreen.Feed(Target)]] }); + var container = scroll.Screen.GetByRole("list", "Feed"); + await container.ScrollUntilVisibleAsync(container.GetByText("Accept")); + var step = Assert.Single(scroll.Steps.All()); + Assert.Equal("screen.scrollUntilVisible", step.Api); + // The port writes a scoped description as a chain, `parent.child`, where upstream writes `parent >> child`. + Assert.Equal("getByRole(\"list\", \"Feed\").getByText(\"Accept\")", step.Label); + Assert.Equal(StepStatus.Passed, step.Status); + } + + [Fact] + public async Task Needs_the_swipe_action_only_once_it_has_to_scroll_a_visible_target_passes_on_an_engine_without_one() + { + var visible = new ScrollScreen(new ScrollScript { Screens = [[Target]], Swipeable = false }); + await visible.Screen.ScrollUntilVisibleAsync(visible.Screen.GetByText("Accept")); + var absent = new ScrollScreen(new ScrollScript { Screens = [[]], Swipeable = false }); + var failure = await Assert.ThrowsAsync(() => absent.Screen.ScrollUntilVisibleAsync(absent.Screen.GetByText("Accept"))); + Assert.Equal("UNSUPPORTED_CAPABILITY", failure.Code); + } +} diff --git a/tests/E2E.Tests/ScrollUntilVisible/ScrollScreen.cs b/tests/E2E.Tests/ScrollUntilVisible/ScrollScreen.cs new file mode 100644 index 00000000..e763c020 --- /dev/null +++ b/tests/E2E.Tests/ScrollUntilVisible/ScrollScreen.cs @@ -0,0 +1,117 @@ +// Copyright 2026 TesterArmy. +// Modified by Dario Kondratiuk. +// SPDX-License-Identifier: Apache-2.0 + +using E2E.Engine; + +namespace E2E.Tests.ScrollUntilVisible; + +/// One swipe a fake engine took: where it was addressed, which way, and how far. +internal sealed record Swipe(string Ref, ScrollDirection Direction, Momentum? Momentum); + +/// What a screen shows before any swipe, then after each; the last entry stays. +internal sealed class ScrollScript +{ + public required IReadOnlyList> Screens { get; init; } + + /// Whether the engine takes a swipe; default true. + public bool Swipeable { get; init; } = true; + + /// Every swipe hangs until the scroll's deadline ends it. + public bool Hang { get; init; } + + /// Every swipe hangs this long on the engine's own clock, then fails as an engine reporting a timeout does. + public TimeSpan? HangFor { get; init; } + + /// The action timeout of the screen; default 1 second. + public TimeSpan ActionTimeout { get; init; } = TimeSpan.FromSeconds(1); +} + +/// +/// A screen over a fake engine that pages through one swipe at a time and logs every swipe, as +/// upstream's scrollScreen builds one. The viewport swipe is addressed to viewport: the port has no observation-root +/// node to address it to. +/// +internal sealed class ScrollScreen +{ + public static readonly SemanticNode Target = new() { Ref = "target", Role = "button", Name = "Accept", Text = "Accept" }; + + public static readonly SemanticNode HiddenTarget = new() { Ref = "target", Role = "button", Name = "Accept", Text = "Accept", States = new NodeStates { Hidden = true } }; + + /// The scroll container every screen shows, with whatever it has scrolled to as its children. + public static SemanticNode Feed(params SemanticNode[] children) => new() { Ref = "feed", Role = "list", Name = "Feed", Children = children }; + + private static readonly TimeSpan SwipeTime = TimeSpan.FromMilliseconds(16); + + private readonly List _swipes = []; + + public ScrollScreen(ScrollScript script) + { + Steps = new StepRecorder("attempt"); + IReadOnlyList Current() + { + lock (_swipes) + { + return script.Screens[Math.Min(_swipes.Count, script.Screens.Count - 1)]; + } + } + + async Task Swipe(string reference, ScrollDirection direction, Momentum? momentum, CancellationToken token) + { + if (script.Hang) + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + } + + if (script.HangFor is { } hang) + { + await Task.Delay(hang, CancellationToken.None); + throw new EngineException(EngineErrorCodes.OperationTimeout, "swipe exceeded " + hang.TotalMilliseconds + " ms", retryable: false); + } + + // A swipe resolves once the scroll it caused is on screen, a frame or more later. + await Task.Delay(SwipeTime, CancellationToken.None); + lock (_swipes) + { + _swipes.Add(new Swipe(reference, direction, momentum)); + } + } + + Screen = new Screen( + _ => Task.FromResult(new Observation { Route = "/", Roots = Current() }), + async (node, action, token) => + { + if (action is not LocatorAction.Swipe swipe) + { + throw new InvalidOperationException("unexpected " + action); + } + + await Swipe(node.Ref, swipe.Direction, swipe.Momentum, token); + }, + () => CancellationToken.None, + () => { }, + script.ActionTimeout, + script.ActionTimeout, + swipe: script.Swipeable + ? (direction, momentum, token) => Swipe("viewport", direction, momentum, token) + : null) + { + Steps = Steps, + }; + } + + public Screen Screen { get; } + + public StepRecorder Steps { get; } + + public IReadOnlyList Swipes + { + get + { + lock (_swipes) + { + return [.. _swipes]; + } + } + } +} From d9adb69fdecaf7a0aaed42b468e37e8cee1c493f Mon Sep 17 00:00:00 2001 From: Dario Kondratiuk Date: Sun, 25 Oct 2026 13:33:34 -0300 Subject: [PATCH 2/2] Describe the strides of Momentum per engine Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DH1b4gwq2kmMK94pdZRQZm --- src/E2E/Engine/LocatorAction.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/E2E/Engine/LocatorAction.cs b/src/E2E/Engine/LocatorAction.cs index a1d77225..e1a881e7 100644 --- a/src/E2E/Engine/LocatorAction.cs +++ b/src/E2E/Engine/LocatorAction.cs @@ -53,12 +53,12 @@ public enum ScrollDirection /// Fling strength of a swipe: how far the gesture travels. Upstream's Momentum (none, slow, fast). public enum Momentum { - /// The engine's default stride. + /// No fling: half an extent on the web, half a node on a device. None, - /// A short stride, shorter than any row, so every row lands fully on screen at some stop. + /// 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. Slow, - /// A long stride. + /// A long stride: four fifths of a node on a device, one and a half extents on the web. Fast, }