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
}
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)...
}
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);
}
}
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
public static final class Actionability {
private boolean enabled = true;
private int stabilityIntervalMs = 100;
private boolean hitTargetCheck = true;
private boolean autoScroll = true;
}
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();
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();
- Resolves the element the same way
Locator.click()does, including self-healing, and waits until it's visible and enabled. - Scrolls it to the centre of the viewport and moves the mouse onto it with
Actions, like a real user. - Waits until nothing else — a cookie banner, a modal, a sticky header — covers its centre point.
- Clicks it, then asserts that the
clickevent 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();
Both share one path (fillAndVerify):
- Resolve the element like
Locator.fill(String)would (waiting, with self-healing), and wait until it's 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 — 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)