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
}
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)...
}
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);
}
}
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
public static final class Actionability {
private boolean enabled = true;
private int stabilityIntervalMs = 100;
private boolean hitTargetCheck = true;
private boolean autoScroll = true;
}
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();
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();
- It resolves the element the same way
Locator.click()does, including self-healing, and waits until it is visible and enabled. - It scrolls the element to the centre of the viewport and moves the mouse onto it with
Actions, as a real user would. - It waits until nothing else covers the element's centre point, such as a cookie banner, a modal or a sticky header.
- It clicks, then asserts that the
clickevent 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();
Both use the same path (fillAndVerify):
- Resolve the element as
Locator.fill(String)would (waiting, with self-healing) and wait until it is visible and enabled. - Wait until the field is not
readonly, then scroll it to the viewport centre. - Clear it and type the text exactly as
Locator.type(String)does. - 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)