DEV Community

Cover image for TestFly Part 13: Actionability engine, auto-wait and verified click/fill/clear assertions
Hakan GÜL
Hakan GÜL

Posted on Edited on Originally published at hakangul.lovable.app

TestFly Part 13: Actionability engine, auto-wait and verified click/fill/clear assertions

TestFly 1.1.0 adds an actionability engine. Every click, hover, type, clear and append call goes through it before touching the DOM. The engine checks that the element is visible, enabled where that matters, geometrically stable and not covered by another element, and scrolls it into view if needed. On top of that, assertThat(x).click() / .fill(text) / .clear() make the action itself an assertion: they verify that the click event reached the element and that the typed text stayed in the field. When an element never becomes ready, TestFly throws NotActionableException with the element and the exact blocker in the message.

How TestFly decides an element is ready for an action

Every interaction is tagged with an ActionKind, which decides which checks apply:

public enum ActionKind {
    CLICK(true, true),   // enabled, and not covered by another element
    HOVER(false, true),  // not covered; a disabled element can still be hovered
    EDIT(true, false);   // enabled; being covered doesn't matter for keyboard input
}
Enter fullscreen mode Exit fullscreen mode

Every kind requires the element to be visible and stable, whatever its flags say. WaitEngine.waitForActionable(element, kind) polls until all required checks pass. If the element drifts offscreen between polls, it scrolls it back into view:

public static WebElement waitForActionable(WebElement element, ActionKind kind) {
    TestFlyConfig.Actionability cfg = actionabilityConfig();
    if (!cfg.isEnabled()) {
        return kind == ActionKind.CLICK ? waitForClickable(element) : waitForVisible(element);
    }
    // visible, enabled (if required), stable bounding box, not covered (if required)...
}
Enter fullscreen mode Exit fullscreen mode

A stale element surfaces as StaleElementReferenceException. The caller (the Locator chain) then re-resolves it from scratch instead of failing on a detached node.

What gets checked, and in what order

Check Applies to Detail
Visible all kinds element has a non-empty bounding box and isn't display:none/visibility:hidden
Enabled CLICK, EDIT skipped for HOVER; a disabled button can still be hovered
Stable all kinds bounding box unchanged between two consecutive polls (default poll interval: 100ms)
Not covered CLICK, HOVER nothing else receives a hit-test at the element's centre; skipped for EDIT
Auto-scroll all kinds scrolls the element to the viewport centre if it's offscreen when checked

The stabilityIntervalMs setting (default 100ms) is both the polling interval and the minimum time the bounding box must stay put. Playwright uses the same idea to wait out animations; TestFly adapts it to Selenium's WebElement.

What happens when an element never becomes actionable

public class NotActionableException extends TimeoutException {
    public NotActionableException(String message, Throwable cause) {
        super(message, cause);
    }
}
Enter fullscreen mode Exit fullscreen mode

It extends TimeoutException, so existing catch (TimeoutException e) handlers keep working. The message names the element and the specific blocker, for example the overlay covering the centre point, or a bounding box that kept moving. A test never silently clicks the wrong spot or types into a field that is still animating.

Configuring or bypassing the checks

# testfly.yml
actionability:
  enabled: true           # false restores the pre-1.1.0 waits
  stabilityIntervalMs: 100
  hitTargetCheck: true
  autoScroll: true
Enter fullscreen mode Exit fullscreen mode
public static final class Actionability {
    private boolean enabled = true;
    private int stabilityIntervalMs = 100;
    private boolean hitTargetCheck = true;
    private boolean autoScroll = true;
}
Enter fullscreen mode Exit fullscreen mode

With actionability.enabled: false, TestFly falls back to the legacy waits: waitForClickable for clicks and waitForVisible for everything else. This helps during a migration, or when a page really needs the old behaviour.

For a single locator, Locator.force() skips only the stability and not-covered checks (like Playwright's force: true). The element still has to be visible, and enabled where the action needs it:

find("#banner-close").force().click();
Enter fullscreen mode Exit fullscreen mode

BasePage.doubleClick, rightClick, selectBy* and BaseConditions.click/type all go through the same waitForActionable call, so the behaviour is the same across the whole API.

How assertThat(x).click() verifies that a click landed

A plain Locator.click() runs the actionability wait and clicks, as before. assertThat(x).click() also turns the action into an assertion:

assertThat(loginButton).click();
Enter fullscreen mode Exit fullscreen mode
  1. It resolves the element the same way Locator.click() does, including self-healing, and waits until it is visible and enabled.
  2. It scrolls the element to the centre of the viewport and moves the mouse onto it with Actions, as a real user would.
  3. It waits until nothing else covers the element's centre point, such as a cookie banner, a modal or a sticky header.
  4. It clicks, then asserts that the click event reached the element or one of its descendants.

On success it logs a passed Clicked: ... step. On failure you get a regular AssertionError (or a recorded soft failure in soft mode) that names the step that failed. If the click navigates away or opens an alert before the event listener can report back, the click still counts as delivered, so a page that moves on does not cause a failure. Locator.click() itself has not changed; this is an additional, stricter method on LocatorAssert.

How assertThat(x).fill(text) and .clear() verify the field changed

assertThat(userNameField).fill("admin");
assertThat(passwordField).fill(password);
assertThat(searchField).clear();
Enter fullscreen mode Exit fullscreen mode

Both use the same path (fillAndVerify):

  1. Resolve the element as Locator.fill(String) would (waiting, with self-healing) and wait until it is visible and enabled.
  2. Wait until the field is not readonly, then scroll it to the viewport centre.
  3. Clear it and type the text exactly as Locator.type(String) does.
  4. Wait until the field's value equals the text and stays that way. This catches cases where maxlength, an input mask or framework state (React, Vue…) silently changed or reset what was typed.

clear() is the same path with an empty string. For type="password" fields, the value is never logged or put in a failure message; only its length is. Fields that reformat input while you type, such as phone masks, will fail this stricter assertion. For those, use Locator.fill(text) followed by assertThat(x).hasValue(formattedValue).

FAQ

Does turning on actionability checks slow every test down?

Each action waits about one extra polling interval (100ms by default) compared with the pre-1.1.0 waits, because the engine needs at least two consecutive stable polls to confirm the bounding box has stopped moving. When JavaScript is unavailable the checks fail open, so the session does not have to support JS.

Does assertThat(x).click() replace Locator.click()?

No. Locator.click() is unchanged and still only runs the actionability wait and clicks. assertThat(x).click() is a separate, verified method. It also asserts that the click event reached the element, logs a Clicked: ... step, and supports as(...), within(...) and soft mode.

Can I combine not() with the verified click()/fill()/clear()?

No. These three methods call rejectNegation(...) and throw if not() was chained before them. They assert that an action happened and changed the page in a way that can be checked, and a "negated click" has no clear meaning.

What's the difference between force() and actionability.enabled: false?

force() works per locator and skips only the stability and not-covered checks; the element must still be visible, and enabled where the action requires it. actionability.enabled: false is global. It reverts every action in the suite to the pre-1.1.0 waits (waitForClickable/waitForVisible) and drops all the new checks at once.

Top comments (0)