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 Originally published at hakangul.lovable.app

TestFly Part 13: Actionability Engine — Auto-Wait and Verified Click/Fill/Clear Assertions

TL;DR: TestFly 1.1.0 adds an actionability engine that every click, hover, type, clear and append call runs through before touching the DOM: visible, enabled where relevant, geometrically stable, not covered by another element, and scrolled into view if needed. On top of it, assertThat(x).click() / .fill(text) / .clear() turn the action itself into an assertion — they verify the click event actually reached the element and that typed text stayed in the field. A failure throws NotActionableException naming the element and the exact blocker.


How Does TestFly Decide 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 regardless of its flags. WaitEngine.waitForActionable(element, kind) polls until all the required checks pass, re-scrolling the element into view if it drifts offscreen between polls:

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 bubbles up as StaleElementReferenceException so the caller (the Locator chain) can re-resolve it from scratch instead of failing on a detached node.

What Exactly 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 config (default 100ms) is both the polling interval and the minimum time an element's bounding box must stay put — the same anti-animation idea Playwright uses, adapted 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 any existing catch (TimeoutException e) handler keeps working. The message names the element and the specific blocker — for example which overlay is covering the centre point, or that the bounding box kept moving. Nothing silently clicks the wrong spot or types into a half-animated field.

How Do I Configure or Bypass Actionability 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

Setting actionability.enabled: false falls back to the legacy waits: waitForClickable for clicks, waitForVisible for everything else — useful during a migration, or if a page genuinely needs the old behaviour.

For a single locator, Locator.force() skips just the stability and not-covered checks (Playwright's force: true), while still requiring the element 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 route through the same waitForActionable call, so the behaviour is consistent across the whole API surface.

How Does assertThat(x).click() Verify a Click Actually Landed?

A plain Locator.click() performs the actionability wait and clicks — same as before. assertThat(x).click() goes one step further and turns the action into an assertion:

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

On success it logs a passed Clicked: ... step. On failure it's a regular AssertionError (or a recorded soft failure in soft mode) naming which step failed. If the click navigates away or opens an alert before the event listener can read back the result, the click still counts as delivered — it doesn't fail just because the page moved on. Locator.click() itself is unchanged; this is an additive, stricter sibling on LocatorAssert.

How Do assertThat(x).fill(text) and .clear() Verify the Field Actually Changed?

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

Both share one path (fillAndVerify):

  1. Resolve the element like Locator.fill(String) would (waiting, with self-healing), and wait until it's 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 — catching 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 itself is never logged or put in a failure message — only its length is. Fields that reformat input as you type (phone masks, for example) will fail this stricter assertion; use Locator.fill(text) followed by assertThat(x).hasValue(formattedValue) for those instead.

FAQ

Does turning on actionability checks slow every test down?

Each action waits roughly one extra polling interval (100ms by default) compared to the pre-1.1.0 waits, since the engine needs at least two consecutive stable polls to confirm the bounding box stopped moving. Checks fail open when JavaScript is unavailable, so there's no hard dependency on a JS-capable session.

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

No. Locator.click() is unchanged and still just performs the actionability wait and clicks. assertThat(x).click() is an additive, verified sibling that also asserts 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 verifiably changed the page; there's no well-defined "negated click."

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

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

Top comments (0)