diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbd951f..b110c66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,12 +10,18 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.
### Added
+- **`ext.dusk.find` now surfaces a `matchCount` field and an ambiguity `diagnostic` in its success response when `--semanticsLabel` or `--text` matches more than one Semantics node.** Previously the handler silently returned the first match, so `--semanticsLabel "Password"` over-matched the email field on forms where both `` nodes shared the same label. The response now includes `matchCount: N` on every match; when `N > 1` a `diagnostic` key carries a human-readable hint (`label 'X' matched N nodes; refine with --key, --text, or --contains`). Single-match and no-match behaviour is unchanged (backward-compatible). Touches `lib/src/extensions/ext_find.dart`; covered by `test/src/extensions/ext_find_test.dart`.
+
- **`ext.dusk.snap` now surfaces captured non-fatal render/build FlutterErrors in a `renderErrors` block, and `dusk:snap` prints a `⚠ N render error(s)` banner to stderr while stdout stays the pure snapshot.** A widget that throws at build time (a `ParentDataWidget` misuse such as `flex-1`/`Expanded` placed under a `Semantics`/`WAnchor` instead of directly inside a Flex, or an overflow) can render partially and stay invisible in the semantics snapshot, so an action against it silently no-ops with no signal to the agent. The snapshot payload now carries `renderErrors: {count, recent: [{type, message}], hint}` (populated from the existing `FlutterError.onError` capture buffer, omitted entirely when clean), so a broken screen is impossible to miss without separately calling `ext.dusk.exceptions`. Touches `lib/src/extensions/ext_snapshot.dart`, `lib/src/commands/dusk_snap_command.dart`; covered by `test/src/extensions/ext_snapshot_render_errors_test.dart`.
### Changed
- **`ext.dusk.navigate` now tries the consumer navigate adapter (`DuskPlugin.navigateAdapter`, e.g. `MagicRoute.to`) BEFORE `Navigator.pushNamed`.** On a Router-only stack (go_router / auto_route) `Navigator.onGenerateRoute` is null, so `Navigator.pushNamed` raised an asynchronous "no corresponding route" `FlutterError` on every navigate. Because the failure was async, the handler's try/catch could not suppress it, and it landed in the FlutterError buffer, now doubly visible via the new `renderErrors` snapshot block as a false positive. Adapter-first dispatch routes through the app's own router public API (the correct path for these apps) and skips the throwing `Navigator.pushNamed` entirely; it remains the fallback for apps with no registered adapter. Touches `lib/src/extensions/ext_navigation.dart`.
+### Fixed
+
+- **`dusk:doctor` check 3 (snapshot enrichers) now emits INFO when no enrichers are registered, instead of WARN.** Enrichers are opt-in; zero is a valid state, not a problem. The WARN reading alongside "integration wired" (check 5) created false contradiction. Touches `lib/src/commands/dusk_doctor_command.dart`; test case updated in `test/src/commands/dusk_doctor_command_test.dart`.
+
---
## [0.0.8] - 2026-06-17
diff --git a/doc/commands/dusk-find.md b/doc/commands/dusk-find.md
index ba880b6..4adf359 100644
--- a/doc/commands/dusk-find.md
+++ b/doc/commands/dusk-find.md
@@ -62,21 +62,36 @@ The CLI guards an empty params map (`Provide at least one of --text / --contains
**Success envelope (illustrative):**
+Single match:
+
```json
{
"ref": "q1",
- "matchCount": 1,
- "rect": [120, 400, 240, 48],
- "role": "button",
- "label": "Sign in"
+ "matched": true,
+ "matchCount": 1
}
```
-`matchCount > 1` indicates the predicate is ambiguous: the handle still resolves to the first match, but the agent should narrow with an extra predicate (typically `--key`) before acting.
+Multi-match (ambiguous predicate):
+
+```json
+{
+ "ref": "q1",
+ "matched": true,
+ "matchCount": 2,
+ "diagnostic": "label 'Password' matched 2 nodes; refine with --key, --text, or --contains"
+}
+```
+
+`matchCount > 1` means the predicate is ambiguous: the handle still resolves to the FIRST match (backward-compatible), but the agent should narrow with an additional predicate before acting. Common disambiguation strategies:
+
+- Add `--key=` when the widget carries a `ValueKey`.
+- Add `--text=` when the accessibility label and the visible text differ.
+- Use `--contains=` when only part of the label is unique.
**Error envelope:**
-The VM Service handler propagates errors as `ServiceExtensionResponse.error(extensionError, message)`. The CLI surfaces them via `ArtisanContext.callExtension` and exits non-zero. Common messages include `No widget matched predicates: {...}`.
+The VM Service handler propagates errors as `ServiceExtensionResponse.error(extensionError, message)`. The CLI surfaces them via `ArtisanContext.callExtension` and exits non-zero. Common messages include `No widget matched predicates: {}`.
---
@@ -154,6 +169,67 @@ The two predicates AND together; useful when the screen has multiple "Save" butt
---
+
+## e-ref staleness and when to prefer q-handles
+
+`e` tokens minted by `dusk:snap` are frozen to the Semantics node that was
+live at snap time. They become defunct the moment the node leaves the tree, which
+happens on any route push, list rebuild, or conditional widget swap. The
+`RefRegistry` that backs `e` tokens does NOT re-resolve; calling an action
+with a stale `e` returns a `defunct (element no longer mounted)` failure.
+
+`q` handles minted by `dusk:find` store the predicate set instead of the
+node, and re-walk the live tree on every action call. They survive navigations,
+hot-reloads, and full widget rebuilds as long as the predicate still matches
+something in the tree.
+
+**When to reach for `dusk:find` / `q` instead of using the `e` from a
+snap:**
+
+- The page might rebuild between snap and action (e.g. Settings pages with
+ dynamic sections, lists driven by async data).
+- The agent will retry an action (gate failure, transient loading state).
+- The flow spans more than one navigation hop; an `e` from the previous
+ screen is always stale after the route change.
+- The agent holds a ref across a hot-reload.
+
+The `RefRegistry` is intentionally frozen for `e` (it is a FIFO token store,
+not a live observer). There is no mechanism to refresh a stale `e` in place;
+the design intent is that `dusk:snap` re-mints the ref after every page change.
+For rebuild-prone pages, prefer `dusk:find` / `dusk:observe` from the start.
+
+---
+
+
+## Avoiding `--semanticsLabel` over-match
+
+`--semanticsLabel` performs an exact case-sensitive match against
+`SemanticsNode.label` and returns the FIRST node in tree order. When two or
+more nodes carry the same label (e.g. two `TextField` widgets both labelled
+`Password` on a sign-up form, or a list of repeated row controls), the handle
+resolves to the first node in tree order, which may not be the intended target.
+
+The `matchCount` field in the response tells the agent how many nodes matched.
+A `diagnostic` key appears when `matchCount > 1`, e.g.:
+
+```
+label 'Password' matched 2 nodes; refine with --key, --text, or --contains
+```
+
+**Disambiguation strategies (most to least precise):**
+
+1. Add `--key=` when the widget carries a `ValueKey`. This is the
+ most precise predicate and survives label changes.
+2. Combine `--semanticsLabel=Password --text=Confirm` when the second node has
+ distinct visible text (some widgets expose both a label and a text value).
+3. Use `--contains=` when only part of the label is unique
+ across the matching nodes.
+4. Use `dusk:observe` with a narrow `intent` and inspect the returned candidate
+ list; each candidate includes role, bounds, and enricher fields that let the
+ agent identify the correct target before minting the handle.
+
+---
+
## See also
diff --git a/lib/src/commands/dusk_doctor_command.dart b/lib/src/commands/dusk_doctor_command.dart
index 38b0416..7e8ec59 100644
--- a/lib/src/commands/dusk_doctor_command.dart
+++ b/lib/src/commands/dusk_doctor_command.dart
@@ -234,10 +234,7 @@ class DuskDoctorCommand extends ArtisanCommand {
const String label = 'snapshot enrichers';
final int count = enrichersProbe();
if (count == 0) {
- ctx.output.warning(
- '$label: no enrichers registered; install Magic + Wind integrations '
- 'for richer snapshots',
- );
+ ctx.output.info('$label: enrichers are opt-in; none registered');
return;
}
ctx.output.success('$label: enrichers registered: $count');
diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart
index 7bc6eac..4f5e55f 100644
--- a/lib/src/extensions/ext_find.dart
+++ b/lib/src/extensions/ext_find.dart
@@ -34,8 +34,11 @@ void registerFindExtension() {
/// live Semantics + Element tree once to verify the predicates resolve to
/// a node, then mints a `q` handle backed by the stored predicate set.
///
-/// On first match returns `{"ref": "q", "matched": true}`. When no node
-/// matches, returns `{"ref": null, "matched": false}` — no handle is minted.
+/// On first match returns `{"ref": "q", "matched": true, "matchCount": N}`.
+/// When `matchCount > 1`, an additional `diagnostic` key carries a
+/// human-readable hint so agents know to disambiguate with `--text`,
+/// `--contains`, or a widget `--key`. When no node matches, returns
+/// `{"ref": null, "matched": false}` — no handle is minted.
///
/// The handle is opaque from the agent's perspective: passing it back to
/// `ext.dusk.tap` etc. triggers a fresh tree walk at that moment, so a
@@ -78,7 +81,8 @@ Future extDuskFindHandler(
// NOT store the resolved RefEntry — the handle re-executes the
// walk on every action call so the agent gets the latest rect /
// element after intermediate rebuilds.
- final RefEntry? entry = resolveQuery(query);
+ final (RefEntry? entry, int matchCount, String? diagnostic) =
+ resolveQueryWithCount(query);
if (entry == null) {
return developer.ServiceExtensionResponse.result(
jsonEncode({
@@ -92,12 +96,17 @@ Future extDuskFindHandler(
// (no groupId scope; action handlers rebuild RefEntry on call).
final String token = RefRegistry.registerQuery(query);
- return developer.ServiceExtensionResponse.result(
- jsonEncode({
- 'ref': token,
- 'matched': true,
- }),
- );
+ final Map payload = {
+ 'ref': token,
+ 'matched': true,
+ 'matchCount': matchCount,
+ };
+ // 4. Surface ambiguity diagnostic when more than one node matched.
+ if (diagnostic != null) {
+ payload['diagnostic'] = diagnostic;
+ }
+
+ return developer.ServiceExtensionResponse.result(jsonEncode(payload));
} catch (e, stackTrace) {
developer.log(
'[fluttersdk_dusk] ext.dusk.find error: $e\n$stackTrace',
@@ -134,35 +143,65 @@ Future extDuskFindHandler(
/// When multiple predicates are set they all must match the same node /
/// element (intersection).
RefEntry? resolveQuery(DuskQuery query) {
+ return resolveQueryWithCount(query).$1;
+}
+
+/// Variant of [resolveQuery] that also returns the total number of Semantics
+/// nodes that matched the label predicate and an optional ambiguity
+/// diagnostic.
+///
+/// Returns a record `(entry, matchCount, diagnostic)`:
+/// - `entry` — first match, or `null` when nothing matched.
+/// - `matchCount` — number of nodes that matched (1 on a single match, 0
+/// when nothing matches; only meaningful for the `semanticsLabel` / `text`
+/// Semantics-walk paths; key-based and text-only Element paths return 1 on
+/// a match, 0 on no match).
+/// - `diagnostic` — non-null only when `matchCount > 1`; a message suitable
+/// for surfacing to an agent, e.g. `label 'Password' matched 2 nodes;
+/// refine with --key, --text, or --contains`.
+///
+/// Single-match and no-match behaviour is identical to [resolveQuery];
+/// callers that do not need ambiguity detection may use [resolveQuery]
+/// directly.
+(RefEntry?, int, String?) resolveQueryWithCount(DuskQuery query) {
// 1. Key-based match: Element tree walk. Cheapest, most specific.
if (query.keyValue != null) {
final Element? element = _findElementByKey(query.keyValue!);
- if (element == null) return null;
- if (!_elementMatchesOtherPredicates(element, query)) return null;
- return _entryFromElement(element);
+ if (element == null) return (null, 0, null);
+ if (!_elementMatchesOtherPredicates(element, query)) return (null, 0, null);
+ return (_entryFromElement(element), 1, null);
}
// 2. Semantics-label match: walk the Semantics tree first because it
// surfaces merged accessibility labels (Button "Submit" with no
// Text descendant still resolves).
if (query.semanticsLabel != null) {
- final SemanticsNode? node =
- _findSemanticsNodeByLabel(query.semanticsLabel!);
- if (node == null) return null;
- return _entryFromSemanticsNode(node);
+ final (SemanticsNode? node, int count) =
+ _findSemanticsNodeByLabelWithCount(query.semanticsLabel!);
+ if (node == null) return (null, 0, null);
+ final String? diagnostic = count > 1
+ ? "label '${query.semanticsLabel}' matched $count nodes; "
+ 'refine with --key, --text, or --contains'
+ : null;
+ return (_entryFromSemanticsNode(node), count, diagnostic);
}
// 3. text-only match: Semantics-label first (covers labelled widgets
// where the visible text is the accessibility label), then Element-
// tree Text widget fallback.
if (query.text != null) {
- final SemanticsNode? node = _findSemanticsNodeByLabel(query.text!);
+ final (SemanticsNode? node, int count) =
+ _findSemanticsNodeByLabelWithCount(query.text!);
if (node != null) {
- return _entryFromSemanticsNode(node);
+ final String? diagnostic = count > 1
+ ? "label '${query.text}' matched $count nodes; "
+ 'refine with --key, --text, or --contains'
+ : null;
+ return (_entryFromSemanticsNode(node), count, diagnostic);
}
final Element? element = _findElementByTextData(query.text!);
- if (element == null) return null;
- return _entryFromElement(element);
+ if (element == null) return (null, 0, null);
+ return (_entryFromElement(element), 1, null);
}
// 4. containsText match: substring search across Semantics labels then
@@ -172,14 +211,14 @@ RefEntry? resolveQuery(DuskQuery query) {
final SemanticsNode? node =
_findSemanticsNodeByLabelContains(query.containsText!);
if (node != null) {
- return _entryFromSemanticsNode(node);
+ return (_entryFromSemanticsNode(node), 1, null);
}
final Element? element = _findElementByTextContains(query.containsText!);
- if (element == null) return null;
- return _entryFromElement(element);
+ if (element == null) return (null, 0, null);
+ return (_entryFromElement(element), 1, null);
}
- return null;
+ return (null, 0, null);
}
// ---------------------------------------------------------------------------
@@ -289,41 +328,41 @@ SemanticsNode? _findSemanticsNodeByLabelContains(String needle) {
return found;
}
-/// Walks the live Semantics tree and returns the first node whose [label]
-/// equals [needle].
+/// Walks the live Semantics tree and counts ALL nodes whose [label] equals
+/// [needle], returning the first match alongside the total count.
///
/// Production-bound widget trees expose their semantics owner via
/// `RendererBinding.instance.rootPipelineOwner.semanticsOwner`. The Flutter
-/// test harness, however, mounts the widget tree under a CHILD pipeline
-/// owner attached to the test view (see `ext_snapshot_dispatcher_test.dart`
-/// docs for the rationale). We walk the root owner first, then every child
-/// owner registered under it, so this helper works in BOTH environments.
-SemanticsNode? _findSemanticsNodeByLabel(String needle) {
+/// test harness mounts the widget tree under a CHILD pipeline owner attached
+/// to the test view, so the walk covers the root owner and all child owners.
+///
+/// The walk never stops early after finding the first node, so the returned
+/// count reflects ALL matches in the tree. When `count > 1` the caller
+/// should surface an ambiguity diagnostic to the agent.
+(SemanticsNode?, int) _findSemanticsNodeByLabelWithCount(String needle) {
SemanticsNode? found;
+ int count = 0;
void visit(SemanticsNode node) {
- if (found != null) return;
if (node.label == needle) {
- found = node;
- return;
+ count += 1;
+ found ??= node;
}
node.visitChildren((SemanticsNode child) {
visit(child);
- return found == null;
+ // Always continue walking to collect the full count.
+ return true;
});
}
void visitOwner(PipelineOwner owner) {
- if (found != null) return;
final SemanticsNode? root = owner.semanticsOwner?.rootSemanticsNode;
if (root != null) visit(root);
- owner.visitChildren((PipelineOwner child) {
- if (found == null) visitOwner(child);
- });
+ owner.visitChildren(visitOwner);
}
visitOwner(RendererBinding.instance.rootPipelineOwner);
- return found;
+ return (found, count);
}
/// Cross-checks an Element-tree match against the supplied query's
diff --git a/skills/fluttersdk-dusk/SKILL.md b/skills/fluttersdk-dusk/SKILL.md
index 146c188..06dedf2 100644
--- a/skills/fluttersdk-dusk/SKILL.md
+++ b/skills/fluttersdk-dusk/SKILL.md
@@ -1,11 +1,11 @@
---
name: fluttersdk-dusk
description: "fluttersdk_dusk: E2E driver for Flutter apps that lets an LLM agent see (snap, observe, screenshot) and act (tap, type, drag, scroll, navigate) on a running Flutter app via 33 MCP tools (`dusk_*`) and 34 matching CLI commands (`./bin/fsa dusk:*`). Snapshots emit a YAML Semantics tree with stable `[ref=eN]` tokens; `dusk_find` and `dusk_observe` mint re-resolvable `q` query handles. Every gesture passes a 6-step actionability gate with substring-parseable failure reasons (`not enabled`, `zero rect`, `off-viewport`, `not stable`, `obscured by`, `defunct`). TRIGGER when: any `dusk_*` MCP tool call, any `dusk:*` CLI command, `./bin/fsa` invocation, the user asks the agent to drive / inspect / test / debug a running Flutter app, the user mentions snap / observe / actionability / ref / eN / qN, or the conversation touches end-to-end testing of a Flutter UI. DO NOT TRIGGER when: only authoring `flutter_test` widget tests, only reading telescope ring buffers without driving the UI (use fluttersdk-telescope), or only modifying Dart source without running it."
-version: 0.0.8
+version: 0.0.9
when_to_use: "Any task where the agent drives or inspects a running Flutter app via dusk: calling `dusk_*` MCP tools in a loop (snap, tap, type, screenshot, hot_reload_and_snap), invoking `./bin/fsa dusk:` from a shell, recovering from an actionability failure, choosing between `e` and `q` ref tokens, waiting for text or network idle, navigating routes, or filling a form."
---
-
+
# fluttersdk_dusk
@@ -189,6 +189,22 @@ Default: snap returns `e`; use them inline. Switch to `dusk_find` /
`dusk_observe` and `q` the moment the agent enters a retry or
multi-step flow against the same target.
+**e-ref staleness on rebuild-prone pages.** `e` tokens are frozen to
+the Semantics node at snap time. The `RefRegistry` backing them does NOT
+re-resolve; on pages that rebuild (Settings, lists driven by async data,
+any page with conditional sections), use `dusk_find` from the start
+instead of snapping and then regretting the stale `defunct` failure.
+
+**`--semanticsLabel` over-match.** `dusk_find { semanticsLabel: "X" }`
+exact-matches against the accessibility label and resolves to the FIRST node;
+ambiguity is now surfaced via `matchCount` and `diagnostic` in the response.
+On forms with repeated labels ("Password" and "Confirm Password" both labelled
+"Password"), the handle points at the first match. Check the `matchCount`
+field in the response; when `> 1`, read the `diagnostic` key and add a second
+predicate (`--key`, `--text`, or `--contains`) before acting. Full
+disambiguation table: `references/actionability-and-refs.md` section
+"semanticsLabel exact-match and over-match".
+
## 5. Quick install + doctor (when dusk is missing)
If `./bin/fsa dusk:snap` returns "VM Service URI absent" (or any
diff --git a/skills/fluttersdk-dusk/references/actionability-and-refs.md b/skills/fluttersdk-dusk/references/actionability-and-refs.md
index e7642c4..9665b15 100644
--- a/skills/fluttersdk-dusk/references/actionability-and-refs.md
+++ b/skills/fluttersdk-dusk/references/actionability-and-refs.md
@@ -166,6 +166,13 @@ predicates.
- Become defunct when the node unmounts (the widget leaves the tree).
After hot-reload, navigation, or a list rebuild, expect every
not-refreshed `e` to fail with `"defunct"`.
+- **The `RefRegistry` backing `e` tokens is FROZEN (a FIFO token
+ store, not a live observer).** There is no way to refresh a stale
+ `e` in place; the design intent is that `dusk_snap` re-mints
+ the ref after every page change. For pages that rebuild frequently
+ (Settings, lists driven by async data, any page with conditional
+ sections), prefer `dusk_find` / `dusk_observe` from the start to
+ get a `q` handle that re-resolves on every action.
When to use them: immediately after the snap that minted them, when
the UI is static, when the action is one-shot.
@@ -228,3 +235,36 @@ the action is safe but the gate fails:
Both flags are per-call. There is no global way to disable the gate;
that is intentional. Disable per call, not per session.
+
+## `--semanticsLabel` exact-match and over-match
+
+`dusk_find { semanticsLabel: "Password" }` performs a case-sensitive exact
+match against `SemanticsNode.label` and resolves to the FIRST node in tree
+order; when more than one node matches, ambiguity is surfaced via `matchCount`
+and `diagnostic` in the response. On forms where multiple fields share the
+same label (e.g. a Password field and a Confirm Password field both labelled
+"Password", or a list of rows each containing a "Delete" button), the handle
+points at the first match.
+
+The `matchCount` key in the success response tells the agent how many nodes
+matched. When `matchCount > 1` the response also carries a `diagnostic` key:
+
+```json
+{
+ "ref": "q3",
+ "matched": true,
+ "matchCount": 2,
+ "diagnostic": "label 'Password' matched 2 nodes; refine with --key, --text, or --contains"
+}
+```
+
+The handle still resolves (backward-compatible), so existing scripts that
+do not read `diagnostic` are unaffected. New agent code should read
+`matchCount` and, when `> 1`, apply one of these disambiguation strategies:
+
+| Strategy | When to use |
+|---|---|
+| Add `--key=` | The widget carries a `ValueKey`; most precise, survives label changes. |
+| Add `--text=` | The accessibility label and visible text differ; combines with `semanticsLabel` as an AND predicate. |
+| Use `--contains=` | Only part of the label is unique; e.g. `--contains="Confirm"` to single out "Confirm Password". |
+| Use `dusk_observe` | When no single predicate is unique; observe returns candidates with bounds and enricher fields the agent can reason about before minting a handle. |
diff --git a/test/src/commands/dusk_doctor_command_test.dart b/test/src/commands/dusk_doctor_command_test.dart
index 98acc79..e547d32 100644
--- a/test/src/commands/dusk_doctor_command_test.dart
+++ b/test/src/commands/dusk_doctor_command_test.dart
@@ -202,7 +202,8 @@ void main() {
expect(output.content, contains('enrichers registered: 2'));
});
- test('WARN when DuskPlugin.enrichers is empty', () async {
+ test('INFO when DuskPlugin.enrichers is empty (enrichers are opt-in)',
+ () async {
DuskDoctorCommand.enrichersProbe = () => 0;
final output = BufferedOutput();
@@ -212,10 +213,7 @@ void main() {
expect(exit, equals(0));
expect(
output.content,
- contains(
- 'no enrichers registered; install Magic + Wind integrations for '
- 'richer snapshots',
- ),
+ contains('enrichers are opt-in; none registered'),
);
});
@@ -413,10 +411,10 @@ Future main() async {
test(
'exit code is 0 when every check passes (defaults: empty enrichers '
- 'flip to WARN, but WARN never fails)', () async {
+ 'emit INFO, no errors)', () async {
// With default seams (empty enrichers, no Chrome, no DUSK_DISABLE,
- // semantics on, no main.dart) the only ERROR-class check (#4) passes,
- // so exit code is 0 even with multiple WARN / INFO rows below.
+ // semantics on, no main.dart) all checks pass with no ERROR rows,
+ // so exit code is 0.
final output = BufferedOutput();
final exit = await DuskDoctorCommand()
.handle(ArtisanContext.bare(MapInput(const {}), output));
diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart
index 21740d3..f1e7453 100644
--- a/test/src/extensions/ext_find_test.dart
+++ b/test/src/extensions/ext_find_test.dart
@@ -526,6 +526,105 @@ void main() {
);
});
+ group('multi-match semanticsLabel diagnostic', () {
+ setUp(RefRegistry.resetForTesting);
+ tearDown(RefRegistry.resetForTesting);
+
+ testWidgets(
+ '(f) two nodes sharing a semanticsLabel produce a multi-match diagnostic',
+ (WidgetTester tester) async {
+ tester.view.physicalSize = const Size(800, 600);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ // Two distinct semantics nodes with the same label — models the
+ // "Password" over-match scenario from REPORT #15.
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ Semantics(
+ label: 'Password',
+ textField: true,
+ container: true,
+ child: const SizedBox(width: 200, height: 50),
+ ),
+ Semantics(
+ label: 'Password',
+ textField: true,
+ container: true,
+ child: const SizedBox(width: 200, height: 50),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final response = await extDuskFindHandler(
+ 'ext.dusk.find',
+ {'semanticsLabel': 'Password'},
+ );
+
+ // Still resolves (backward-compatible); a q-handle is minted.
+ expect(response.result, isNotNull);
+ final Map decoded =
+ jsonDecode(response.result!) as Map;
+ expect(decoded['matched'], isTrue);
+ expect(decoded['ref'], startsWith('q'));
+
+ // Multi-match diagnostic is present.
+ expect(decoded['matchCount'], equals(2));
+ expect(
+ decoded['diagnostic'] as String? ?? '',
+ contains("label 'Password' matched 2 nodes"),
+ );
+ },
+ );
+
+ testWidgets(
+ '(f) single-match semanticsLabel carries no multi-match diagnostic',
+ (WidgetTester tester) async {
+ tester.view.physicalSize = const Size(800, 600);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Semantics(
+ label: 'UniqueLabel',
+ button: true,
+ container: true,
+ child: const SizedBox(width: 100, height: 100),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final response = await extDuskFindHandler(
+ 'ext.dusk.find',
+ {'semanticsLabel': 'UniqueLabel'},
+ );
+
+ expect(response.result, isNotNull);
+ final Map decoded =
+ jsonDecode(response.result!) as Map;
+ expect(decoded['matched'], isTrue);
+ expect(decoded['matchCount'], equals(1));
+ // No diagnostic key present on single match.
+ expect(decoded.containsKey('diagnostic'), isFalse);
+ },
+ );
+ });
+
group('RefRegistry query store', () {
setUp(RefRegistry.resetForTesting);