Actionability gate
Overview
The actionability gate is the precondition check the four direct-action handlers
(tap, hover, drag, type) run BEFORE synthesising the pointer or key
event. It lives in lib/src/utils/actionability_gate.dart and is invoked
through ensureActionable(entry, ref: ...). The gate guards an agent against
firing a gesture against a widget that cannot accept it: disabled, collapsed,
off-screen, animating, or covered by another widget.
A failed gate throws a typed DuskActionabilityException whose message is
re-emitted by the VM-Service handler as
ServiceExtensionResponse.error(extensionError, exception.message). Agents
parse the failure-reason substring to decide whether to re-snap, re-find,
scroll, wait, or abort.
Precondition chain
The gate evaluates six preconditions in evaluation order (Step 0 defunct preflight + Steps 1-5 ordered checks). The order is
FROZEN per CLAUDE.md Off-limits: agents branch on the failure-reason
substring, so adding, removing, or reordering checks is a breaking change.
- Defunct (preflight); the entry's
Elementmay have been deactivated by parent rebuild, route pop, or list-item recycle since the snapshot minted the ref.findRenderObject()returns null, or the framework throws on the inactive element. This guard runs before the five ordered checks below. Failure reasons:defunct (element no longer attached to a render object)ordefunct (element no longer mounted). The agent's recovery is to re-snap. - Enabled; the entry's
SemanticsNodeis non-null AND itsflagsCollection.isEnabledisTristate.isFalse.Tristate.none(no enabled flag set, e.g. plainText) andTristate.isTrueboth pass. The gate only fails when the framework has explicitly marked the widget disabled. Synthetic entries without a capturedSemanticsNode(for example,find_by_textresults) pass through this check untouched. - Zero-area rect; the entry's
rect.width == 0 || rect.height == 0. A zero-area rect cannot receive a pointer event atrect.centerand almost always indicates the widget has been collapsed or detached between snapshot and action. - Off-viewport; the entry's rect does not intersect the active
FlutterView's logical viewport (recomputed every call fromWidgetsBinding.instance.platformDispatcher.views.firstOrNullso window resizes between actions are honored). The gate first attemptsRenderObject.showOnScreento bring the element into view, then re-checks; it fails only when scroll-into-view cannot place the target inside the viewport. Skipped gracefully when noFlutterViewis attached (headless test harnesses, multi-view race). - Stable (Wave 3 addition); the entry's bounding box, re-resolved from
the live
RenderBoxafter one frame, has not drifted by more than 0.5 logical pixels on any side. Animated widgets (sliding sheets, expanding tiles, page transitions) fail this gate so the agent waits for the animation to settle before retrying. Baseline is the post-auto-scroll rect from step 3, not the original entry rect, so deliberate scroll motion does not trip this check. Opt out viacheckStable: false. - Receives events (Wave 3 addition); a hit-test at
rect.centeron the active view confirms the entry's render object (or a descendant) appears in the hit-test path. If the topmost target is anything else, an overlay, modal scrim, or stacked widget is swallowing the pointer. The thrown reason carries the obscurer'sruntimeType. A graceful degradation accepts the action when the hit-test path contains only a rootRenderView/_ReusableRenderView(Flutter Web's debug compositor sometimes pipes hit-tests through a snapshot view that does not mirror the live element subtree). Opt out viacheckReceivesEvents: false.
Failure reason substrings
The thrown message has the shape
Widget ref=$ref is not actionable: $reason. Agents perform substring matches
against $reason to branch their recovery. The substring list is FROZEN:
| Reason substring | Trips when | Suggested agent recovery |
|---|---|---|
defunct (...) |
findRenderObject() returns null OR Element is in _ElementLifecycle.defunct lifecycle state |
Re-snap; the widget was deactivated. |
not enabled |
flagsCollection.isEnabled == Tristate.isFalse |
Re-snap; the widget may enable later. |
zero rect |
rect.width == 0 || rect.height == 0 |
Re-snap or re-find; layout has shifted. |
off-viewport |
rect does not overlap the viewport even after showOnScreen + one frame |
dusk_scroll_to_ref then retry. |
not stable |
live rect drifted > 0.5 logical pixels on any side after one frame | dusk_wait_for_network_idle or settle delay. |
obscured by |
hit-test at rect.center resolves to a non-descendant render object first |
Dismiss the obscurer (modal, scrim, overlay). |
The off-viewport reason carries the rect and viewport
(off-viewport (rect=..., viewport=...)); the not-stable reason carries the
maximum side delta (not stable (rect changed by X.Xpx)); the obscured reason
carries the obscurer's runtime type (obscured by other widget (top=...)).
Match on the leading substring shown above, not the trailing detail.
Opt-out flags
ensureActionable exposes two opt-out parameters; both default to true to
match Playwright's "4-gate" actionability semantics:
Future ensureActionable(
RefEntry entry, {
required String ref,
bool checkStable = true,
bool checkReceivesEvents = true,
});
| Flag | Disables | When to opt out |
|---|---|---|
checkStable: false |
the stable precondition (4) | widget tests that fabricate synthetic RefEntry rects which do not match the live render-object geometry. |
checkReceivesEvents: false |
the receives-events precondition (5) | the same widget-test scenarios, plus environments where the platform compositor swallows hit-tests. |
Action handlers in production never override the defaults; only widget tests of the gate itself flip these flags.
Intentional gate skips
Three action handlers do NOT route through the actionability gate. The skips
are deliberate, documented under Known gaps in CHANGELOG.md, and listed
here so contributors do not add the gate "for symmetry":
scroll; operates on the parentScrollablerather than the ref target. Gating the ref would refuse scrolls against widgets that are off-viewport, which is exactly the scenariodusk_scrollis meant to fix.select_option; dispatches through Material / Cupertino popup machinery that owns its own enabled check. Adding the gate would double-check enabled state and miss the popup-specific failure modes.press_key; targets the currently focused widget, not a ref. The gate contract requires aRefEntry; the focused widget may not have a token.
Promoting these skips to gated handlers is a deferred candidate; see the
CHANGELOG ### Known gaps section.
Cross-package implications
The four gated actions share a common error envelope. DuskActionabilityException
is caught by the VM-Service handler and re-emitted via
ServiceExtensionResponse.error(extensionError, exception.message). The
MCP/CLI layer wraps the wire error in a DuskErrorEnvelope carrying the
flat message string; consuming agents (Claude Code, Cursor, Windsurf via the
MCP tool surface) parse the envelope and branch on the reason substring.
The contract guarantee is:
- The reason substrings are frozen and listed above.
- The evaluation order is frozen so substring branching stays deterministic.
- The exception is always typed; the wire format is always the flat string.
- Agents re-snap or re-find on failure. The gate never silently retries; the cost of a silent retry on an animating or obscured widget is a flaky test the agent cannot diagnose.
A change to any of these guarantees requires a coordinated bump across
fluttersdk_dusk (this package), magic (whose MagicDuskIntegration ships
seven enrichers and may surface gated actions through Magic facades), and
wind (whose WindClassNameEnricher participates in the snapshot pipeline
that mints the refs the gate guards). Treat the gate's public contract as
load-bearing across the FlutterSDK ecosystem.