From 522cadd515a9b79ecd98ed6a7ec71cff7d946af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 20 Jun 2026 03:44:40 +0300 Subject: [PATCH 1/4] feat(snap): surface captured render FlutterErrors in the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A widget that throws at build time (a ParentDataWidget misuse such as a wind flex-1 / Expanded placed under a Semantics/WAnchor instead of directly inside a Flex, or an overflow) can render partially yet stay invisible in the semantics tree. An action against it then silently no-ops with zero signal to the agent driving dusk, which makes the root cause effectively unobservable from snapshots alone. ext.dusk.snap now adds a `renderErrors` block (count + recent {type, message} + hint) drawn from the existing FlutterError.onError capture buffer, omitted when clean. dusk:snap prints a `⚠ N render error(s)` banner above the tree. The full messages + stacks remain available via dusk:exceptions. This makes a silently broken screen impossible to miss without remembering to query exceptions separately. Adds ext_snapshot_render_errors_test; updates the dusk_snap MCP descriptor + CHANGELOG. --- CHANGELOG.md | 4 + lib/src/commands/dusk_snap_command.dart | 16 ++++ lib/src/dusk_artisan_provider.dart | 7 ++ lib/src/extensions/ext_snapshot.dart | 35 ++++++++ .../ext_snapshot_render_errors_test.dart | 80 +++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 test/src/extensions/ext_snapshot_render_errors_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e9baf..7a7905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- **`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 above the tree.** 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`. + --- ## [0.0.8] - 2026-06-17 diff --git a/lib/src/commands/dusk_snap_command.dart b/lib/src/commands/dusk_snap_command.dart index 76629ec..4ee9d75 100644 --- a/lib/src/commands/dusk_snap_command.dart +++ b/lib/src/commands/dusk_snap_command.dart @@ -37,6 +37,22 @@ class DuskSnapCommand extends ArtisanCommand { 'ext.dusk.snap', params, ); + // Surface captured render/build FlutterErrors first so a silently-broken + // widget (e.g. a ParentDataWidget misuse that makes a button render but + // ignore taps) is impossible to miss. Full detail is in dusk:exceptions. + final renderErrors = result['renderErrors'] as Map?; + if (renderErrors != null) { + final count = renderErrors['count']; + ctx.output.writeln('⚠ $count render error(s) captured on this screen ' + '(run dusk:exceptions for full detail):'); + final recent = renderErrors['recent'] as List? ?? const []; + for (final e in recent) { + final entry = e as Map; + ctx.output.writeln(' - ${entry['type']}: ${entry['message']}'); + } + ctx.output.writeln(''); + } + final snapshot = result['snapshot'] as String? ?? jsonEncode(result); ctx.output.writeln(snapshot); return 0; diff --git a/lib/src/dusk_artisan_provider.dart b/lib/src/dusk_artisan_provider.dart index d516c66..4575c44 100644 --- a/lib/src/dusk_artisan_provider.dart +++ b/lib/src/dusk_artisan_provider.dart @@ -149,6 +149,13 @@ class DuskArtisanProvider extends ArtisanServiceProvider { 'signal only; call dusk_exceptions for the full non-fatal ' 'error history including overflow details.\n' '\n' + 'When non-fatal render/build FlutterErrors have been captured ' + '(e.g. a ParentDataWidget misuse that makes a button render but ' + 'ignore taps), the response adds a `renderErrors` block ' + '(count + recent messages). Treat a non-zero count as the likely ' + 'reason an action against a visible widget silently no-ops; call ' + 'dusk_exceptions for the full message + stack.\n' + '\n' 'Usage:\n' '- Call before any dusk_* action tool; refs become stale ' 'after navigation, modal open/close, or significant widget ' diff --git a/lib/src/extensions/ext_snapshot.dart b/lib/src/extensions/ext_snapshot.dart index 6af8a36..793332e 100644 --- a/lib/src/extensions/ext_snapshot.dart +++ b/lib/src/extensions/ext_snapshot.dart @@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:fluttersdk_artisan/artisan.dart'; import 'package:fluttersdk_wind_diagnostics_contracts/fluttersdk_wind_diagnostics_contracts.dart'; +import '../dusk_error_capture.dart'; import '../dusk_plugin.dart'; import '../ref_registry.dart'; import '../utils/error_envelope.dart'; @@ -45,6 +46,18 @@ import '../utils/error_envelope.dart'; /// ```json /// { "snapshot": "", "groupId": "snapshot-1700000000000" } /// ``` +/// +/// When non-fatal render/build FlutterErrors have been captured (ParentDataWidget +/// misuse, overflow, etc.), a `renderErrors` block is added so a silently-broken +/// widget is visible without a separate `ext.dusk.exceptions` call. Omitted when +/// clean: +/// +/// ```json +/// { "snapshot": "", "groupId": "...", +/// "renderErrors": { "count": 1, +/// "recent": [ { "type": "FlutterError", "message": "Incorrect use of ..." } ], +/// "hint": "Run dusk:exceptions for full messages + stack traces." } } +/// ``` void registerSnapExtension() { if (!kDebugMode) return; @@ -125,9 +138,31 @@ Future> duskSnapBuild({ walkOwner(RendererBinding.instance.rootPipelineOwner); + // Surface captured non-fatal render/build FlutterErrors (ParentDataWidget + // misuse, overflow, etc.) directly in the snapshot. A widget that throws at + // build time can render partially and stay invisible in the semantics tree, + // so an action against it silently no-ops. Including a renderErrors summary + // here means a broken screen is impossible to miss without remembering to + // call ext.dusk.exceptions separately. Omitted entirely when there are none. + final List> captured = + recentCapturedExceptions(limit: 50); + return { 'snapshot': buffer.toString(), 'groupId': groupId, + if (captured.isNotEmpty) + 'renderErrors': { + 'count': captured.length, + 'recent': captured + .take(3) + .map((Map e) => { + 'type': e['type'], + 'message': + (e['message'] as String? ?? '').split('\n').first, + }) + .toList(), + 'hint': 'Run dusk:exceptions for full messages + stack traces.', + }, }; } finally { handle.dispose(); diff --git a/test/src/extensions/ext_snapshot_render_errors_test.dart b/test/src/extensions/ext_snapshot_render_errors_test.dart new file mode 100644 index 0000000..992fbe9 --- /dev/null +++ b/test/src/extensions/ext_snapshot_render_errors_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_dusk/src/dusk_error_capture.dart'; +import 'package:fluttersdk_dusk/src/extensions/ext_snapshot.dart'; +import 'package:fluttersdk_dusk/src/ref_registry.dart'; + +/// Verifies that `duskSnapBuild` surfaces captured non-fatal render/build +/// FlutterErrors in its payload under `renderErrors`. A widget that throws at +/// build time (ParentDataWidget misuse, overflow) can render partially and stay +/// invisible in the semantics tree, so an action against it silently no-ops. +/// Embedding the error summary in every snapshot makes that impossible to miss. +void main() { + group('duskSnapBuild renderErrors', () { + setUp(() { + RefRegistry.resetForTesting(); + resetCapturedExceptionsForTesting(); + }); + tearDown(() { + RefRegistry.resetForTesting(); + resetCapturedExceptionsForTesting(); + }); + + testWidgets('omits renderErrors when no FlutterError was captured', + (tester) async { + installErrorCapture(); + addTearDown(uninstallErrorCapture); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Text('clean')), + ), + ); + + final payload = await tester.runAsync(() => duskSnapBuild()); + + expect(payload!.containsKey('renderErrors'), isFalse, + reason: 'a clean screen must not carry a renderErrors block'); + expect(payload.containsKey('snapshot'), isTrue); + }); + + testWidgets('includes a renderErrors summary when an overflow is captured', + (tester) async { + installErrorCapture(); + addTearDown(uninstallErrorCapture); + + // Force a RenderFlex overflow (reported via FlutterError.onError). + tester.view.physicalSize = const Size(50, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Row( + children: [ + SizedBox(width: 400, height: 100), + SizedBox(width: 400, height: 100), + ], + ), + ), + ); + // Consume the overflow exception so the test binding does not fail it; + // the dusk capture buffer has already recorded it via FlutterError.onError. + tester.takeException(); + + final payload = await tester.runAsync(() => duskSnapBuild()); + + expect(payload!.containsKey('renderErrors'), isTrue, + reason: 'a screen with a captured FlutterError must report it'); + final renderErrors = payload['renderErrors'] as Map; + expect(renderErrors['count'], greaterThanOrEqualTo(1)); + final recent = renderErrors['recent'] as List; + expect(recent, isNotEmpty); + expect((recent.first as Map)['message'] as String, + contains('overflowed by')); + expect(renderErrors['hint'], contains('dusk:exceptions')); + }); + }); +} From 63a07eaed516b829c841dab3110579bd6bc01965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 20 Jun 2026 04:23:52 +0300 Subject: [PATCH 2/4] fix(navigate): prefer the consumer navigate adapter over Navigator.pushNamed On a Router-only stack (go_router / auto_route) Navigator.onGenerateRoute is null, so ext.dusk.navigate's Navigator.pushNamed raised an asynchronous "no corresponding route" FlutterError on every navigate. The failure is async, so the handler's try/catch could not suppress it; it landed in the FlutterError buffer and now shows up as a false positive in the new renderErrors snapshot block. ext.dusk.navigate now dispatches through DuskPlugin.navigateAdapter first when registered (MagicRoute.to for Magic apps) -- the correct path for Router-based apps -- and only falls back to Navigator.pushNamed when no adapter is wired. Eliminates the spurious capture. Navigation tests green (32); full suite 789. --- CHANGELOG.md | 4 ++ lib/src/extensions/ext_navigation.dart | 95 +++++++++++++------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7905b..5bf18bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`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 above the tree.** 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`. + --- ## [0.0.8] - 2026-06-17 diff --git a/lib/src/extensions/ext_navigation.dart b/lib/src/extensions/ext_navigation.dart index dc5d620..7a77bca 100644 --- a/lib/src/extensions/ext_navigation.dart +++ b/lib/src/extensions/ext_navigation.dart @@ -203,59 +203,56 @@ Future extDuskNavigateHandler( // 2. Dismiss open modal overlays so navigation is unobstructed. await dismissAllModals(); - // 3. Push the route. Try Navigator 1.0 (pushNamed via onGenerateRoute) - // first; on failure (typically Navigator.onGenerateRoute is null - // because the app uses a Router-based stack like go_router / auto_route), - // fall back to the platform-channel route-information broadcast which - // every Router-backed delegate listens to. - final Element? root = WidgetsBinding.instance.rootElement; + // 3. Push the route. Prefer a consumer-registered navigate adapter + // (typically MagicRoute.to wired in host main.dart) when present: it + // dispatches through the app's own router public API (go_router / + // auto_route), which is the correct path for Router-based apps and + // avoids the spurious "no corresponding route" FlutterError that + // `Navigator.pushNamed` raises on a Router-only stack (its + // `onGenerateRoute` is null there, and the failure is asynchronous so a + // try/catch around the call cannot suppress it — it lands in the + // FlutterError buffer and pollutes ext.dusk.snap / ext.dusk.exceptions). + // `pushed = true` means dispatch attempted, NOT that the route was + // honored — the URL verify below is the source of truth. bool pushed = false; - if (root != null) { - final NavigatorState? navigator = _findNavigator(root); - if (navigator != null) { - try { - // Fire-and-forget. `Navigator.pushNamed` returns a Future that - // completes when the pushed route is POPPED, not when it lands — - // awaiting it would block this handler until the agent navigates - // away, which deadlocks any test (and any test-like context) that - // never pops. The push itself happens synchronously inside the - // call; the post-dispatch endOfFrame ticks below guarantee the - // new route is mounted before we URL-verify. - unawaited(navigator.pushNamed(route)); - pushed = true; - } catch (e) { - // Navigator.onGenerateRoute null (go_router stack). Fall through to - // the cross-router platform channel below. - developer.log( - '[fluttersdk_dusk] extDuskNavigateHandler: Navigator.pushNamed ' - 'failed for "$route" ($e); falling back to ' - 'SystemNavigator.routeInformationUpdated.', - name: 'dusk', - ); - } + final adapter = DuskPlugin.navigateAdapter; + if (adapter != null) { + try { + pushed = await adapter(route); + } catch (e) { + developer.log( + '[fluttersdk_dusk] extDuskNavigateHandler: navigateAdapter ' + 'threw for "$route" ($e); falling back to Navigator / ' + 'SystemNavigator.routeInformationUpdated.', + name: 'dusk', + ); } } + + // Fallback for apps WITHOUT a registered adapter: Navigator 1.0 pushNamed. if (!pushed) { - // Consumer-registered adapter (typically MagicRoute.to wired in - // host main.dart). Cleanest dispatch path for app frameworks that - // own their own router (Magic / GoRouter with custom - // RouteInformationProvider), because the host pushes through the - // router's public API instead of platform-channel broadcasts the - // delegate may not be listening to. `pushed = true` means dispatch - // attempted, NOT that the route was honored — URL verify below is - // the source of truth (the adapter has no way to report whether - // the router accepted or silently dropped the path). - final adapter = DuskPlugin.navigateAdapter; - if (adapter != null) { - try { - pushed = await adapter(route); - } catch (e) { - developer.log( - '[fluttersdk_dusk] extDuskNavigateHandler: navigateAdapter ' - 'threw for "$route" ($e); falling back to ' - 'SystemNavigator.routeInformationUpdated.', - name: 'dusk', - ); + final Element? root = WidgetsBinding.instance.rootElement; + if (root != null) { + final NavigatorState? navigator = _findNavigator(root); + if (navigator != null) { + try { + // Fire-and-forget. `Navigator.pushNamed` returns a Future that + // completes when the pushed route is POPPED, not when it lands — + // awaiting it would block this handler until the agent navigates + // away, which deadlocks any test that never pops. The push itself + // happens synchronously inside the call; the post-dispatch + // endOfFrame ticks below guarantee the new route is mounted before + // we URL-verify. + unawaited(navigator.pushNamed(route)); + pushed = true; + } catch (e) { + developer.log( + '[fluttersdk_dusk] extDuskNavigateHandler: Navigator.pushNamed ' + 'failed for "$route" ($e); falling back to ' + 'SystemNavigator.routeInformationUpdated.', + name: 'dusk', + ); + } } } } From 4512238a54f9870f3bc363c05e9961a76c35601d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 22 Jun 2026 12:45:14 +0300 Subject: [PATCH 3/4] docs+test: address PR #21 review (doc-sync, em-dash, navigate regression) - Document the new snap renderErrors block + CLI banner in doc/commands/dusk-snap.md, doc/mcp/tool-reference.md, and llms.txt (Golden Rule 1 doc-sync gate). - Replace the em-dashes introduced by this PR (CHANGELOG + 3 ext_navigation.dart comments) with comma/semicolon/period per the no-dash rule. - Add a navigate regression test that proves adapter-is-preferred-over-Navigator: with both a registered adapter and a pushable named route present, the adapter receives the route and the Navigator target never mounts. Guards the reorder. --- CHANGELOG.md | 2 +- doc/commands/dusk-snap.md | 39 +++++++++++- doc/mcp/tool-reference.md | 28 ++++++++- lib/src/extensions/ext_navigation.dart | 8 +-- llms.txt | 2 +- test/src/extensions/ext_navigation_test.dart | 66 ++++++++++++++++++++ 6 files changed, 135 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf18bb..c1fcccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### 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`. +- **`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`. --- diff --git a/doc/commands/dusk-snap.md b/doc/commands/dusk-snap.md index 66cef3c..9f78e2c 100644 --- a/doc/commands/dusk-snap.md +++ b/doc/commands/dusk-snap.md @@ -47,7 +47,7 @@ The flag is parsed via `(ctx.input.option('includeEnrichers') as bool?) ?? false ## Returns -The VM Service handler returns a JSON envelope `{ "snapshot": "" }`. The CLI unwraps the `snapshot` field and writes the raw YAML to stdout; when the field is missing the entire JSON object is dumped instead. +The VM Service handler returns a JSON envelope `{ "snapshot": "", "groupId": "" }`. The CLI unwraps the `snapshot` field and writes the raw YAML to stdout; when the field is missing the entire JSON object is dumped instead. **Success envelope (illustrative):** @@ -62,6 +62,43 @@ The `typeable: true` sub-line marks the single surviving `textbox` node after th When `--includeEnrichers` is set, each entry gains indented lines contributed by the registered enrichers (see [Enricher fragments](#enricher-fragments)). +### Render errors + +When the `FlutterError.onError` capture buffer holds non-fatal render or build errors (for example a `ParentDataWidget` misuse such as `Expanded` placed outside a `Flex`, or a layout overflow), the response includes a `renderErrors` block: + +```json +{ + "snapshot": "", + "groupId": "snapshot-1700000000000", + "renderErrors": { + "count": 2, + "recent": [ + { "type": "FlutterError", "message": "Incorrect use of ParentDataWidget." }, + { "type": "FlutterError", "message": "A RenderFlex overflowed by 32 pixels." } + ], + "hint": "Run dusk:exceptions for full messages + stack traces." + } +} +``` + +Fields: + +| Field | Type | Description | +|-------|------|-------------| +| `count` | int | Total number of captured render/build errors. May exceed `recent.length`. | +| `recent` | array (max 3) | Most recent entries; each has `type` (string) and `message` (first line only). | +| `hint` | string | Fixed advisory pointing to `dusk:exceptions` for full detail. | + +The `renderErrors` block is **omitted entirely** when no errors are in the buffer. A clean screen produces the standard two-key envelope (`snapshot` + `groupId`). + +**CLI banner:** when `renderErrors` is present, `dusk:snap` prints a warning banner before the YAML: + +``` +⚠ 2 render error(s) captured on this screen (run dusk:exceptions for full detail): + - FlutterError: Incorrect use of ParentDataWidget. + - FlutterError: A RenderFlex overflowed by 32 pixels. +``` + **Error envelope:** The VM Service handler propagates errors as `ServiceExtensionResponse.error(extensionError, message)`. The CLI surfaces the exception via `ArtisanContext.callExtension` and exits with a non-zero status. Typical failure modes: diff --git a/doc/mcp/tool-reference.md b/doc/mcp/tool-reference.md index 4c32e37..376e6ae 100644 --- a/doc/mcp/tool-reference.md +++ b/doc/mcp/tool-reference.md @@ -880,9 +880,31 @@ carrying `typeable: true` when calling `dusk_type`. ### Returns -Success: a YAML document where each node is annotated with a `[ref=e]` token, its role, -label, actions, bounds, optional `overflow: true` and `typeable: true` sub-lines, and any -enricher-contributed indented lines. +Success: `{ "snapshot": "", "groupId": "" }`. The `snapshot` value is a YAML +document where each node is annotated with a `[ref=e]` token, its role, label, actions, +bounds, optional `overflow: true` and `typeable: true` sub-lines, and any enricher-contributed +indented lines. + +When non-fatal render or build `FlutterError`s are in the capture buffer (for example a +`ParentDataWidget` misuse or a layout overflow), a `renderErrors` block is added to the +response: + +```json +{ + "snapshot": "...", + "groupId": "snapshot-1700000000000", + "renderErrors": { + "count": 1, + "recent": [ { "type": "FlutterError", "message": "Incorrect use of ParentDataWidget." } ], + "hint": "Run dusk:exceptions for full messages + stack traces." + } +} +``` + +`renderErrors` is omitted entirely when the screen is clean. `recent` contains at most 3 +entries, each with `type` (string) and `message` (first line of the error). `count` reflects +the full buffer depth, which may exceed 3. Use `dusk_exceptions` to retrieve full messages and +stack traces. ### Example call diff --git a/lib/src/extensions/ext_navigation.dart b/lib/src/extensions/ext_navigation.dart index 7a77bca..ca35c6c 100644 --- a/lib/src/extensions/ext_navigation.dart +++ b/lib/src/extensions/ext_navigation.dart @@ -210,10 +210,10 @@ Future extDuskNavigateHandler( // avoids the spurious "no corresponding route" FlutterError that // `Navigator.pushNamed` raises on a Router-only stack (its // `onGenerateRoute` is null there, and the failure is asynchronous so a - // try/catch around the call cannot suppress it — it lands in the + // try/catch around the call cannot suppress it; it lands in the // FlutterError buffer and pollutes ext.dusk.snap / ext.dusk.exceptions). // `pushed = true` means dispatch attempted, NOT that the route was - // honored — the URL verify below is the source of truth. + // honored; the URL verify below is the source of truth. bool pushed = false; final adapter = DuskPlugin.navigateAdapter; if (adapter != null) { @@ -237,8 +237,8 @@ Future extDuskNavigateHandler( if (navigator != null) { try { // Fire-and-forget. `Navigator.pushNamed` returns a Future that - // completes when the pushed route is POPPED, not when it lands — - // awaiting it would block this handler until the agent navigates + // completes when the pushed route is POPPED, not when it lands. + // Awaiting it would block this handler until the agent navigates // away, which deadlocks any test that never pops. The push itself // happens synchronously inside the call; the post-dispatch // endOfFrame ticks below guarantee the new route is mounted before diff --git a/llms.txt b/llms.txt index fce6e0f..3e7ca42 100644 --- a/llms.txt +++ b/llms.txt @@ -5,7 +5,7 @@ **MCP tools (33)**, see `doc/mcp/tool-reference.md` for full input schemas. The 30 `ext.dusk.*` and 3 `artisan:dusk:*` (substrate-routed) tools are listed below. *Snapshot / Screenshot* -- `dusk_snap` (`ext.dusk.snap`): Capture Semantics tree YAML with stable `[ref=eN]` tokens. Nodes inside a currently-overflowing ancestor carry an `overflow: true` sub-line. Nested `textbox` nodes collapse by render-object containment to a single ref tagged `typeable: true` (the node `dusk_type` resolves); sibling fields sharing a label stay distinct. +- `dusk_snap` (`ext.dusk.snap`): Capture Semantics tree YAML with stable `[ref=eN]` tokens. Nodes inside a currently-overflowing ancestor carry an `overflow: true` sub-line. Nested `textbox` nodes collapse by render-object containment to a single ref tagged `typeable: true` (the node `dusk_type` resolves); sibling fields sharing a label stay distinct. When non-fatal render/build FlutterErrors are in the capture buffer, the response includes a `renderErrors: {count, recent: [{type, message}], hint}` block so a silently-broken screen is visible without a separate `dusk_exceptions` call. - `dusk_screenshot` (`ext.dusk.screenshot`, in-isolate): Capture base64-encoded JPEG/PNG frame. Can hang on web under CanvasKit+DWDS; for reliable web screenshots use the CLI `dusk:screenshot --output=`, which falls back to CDP `Page.captureScreenshot` when artisan was started with `--cdp-port` (CLI-only, not this MCP tool). *Gestures (pointer actions)* diff --git a/test/src/extensions/ext_navigation_test.dart b/test/src/extensions/ext_navigation_test.dart index 75c9ef4..f4a668b 100644 --- a/test/src/extensions/ext_navigation_test.dart +++ b/test/src/extensions/ext_navigation_test.dart @@ -208,6 +208,72 @@ void main() { expect(routeSeenByAdapter, equals('/settings')); }); + testWidgets('adapter is preferred over Navigator when both are available', + (WidgetTester tester) async { + // Regression guard for the adapter-first reorder. Constructs a widget + // tree where BOTH a registered navigate adapter AND a pushable named + // Navigator route exist, then asserts: + // (a) the adapter received the route, and + // (b) the Navigator did NOT push it (the target widget never mounted). + // + // If the reorder were reverted so Navigator fires before the adapter, + // Navigator.pushNamed('/adapter-wins-target') would succeed, the + // target Scaffold would mount, and find.text('adapter-wins-content') + // would return a match, failing assertion (b). + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + // 1. Mount an app with a pushable named route so Navigator COULD push. + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + routes: { + '/': (BuildContext context) => + const Scaffold(body: Text('adapter-wins-home')), + '/adapter-wins-target': (BuildContext context) => const Scaffold( + body: Text('adapter-wins-content'), + ), + }, + ), + ); + + // 2. Register a recording adapter that claims the route (returns true). + // This is the same pattern as the existing test; the critical + // difference is that a named route for the same path also exists above. + String? routeSeenByAdapter; + DuskPlugin.registerNavigateAdapter((String route) async { + routeSeenByAdapter = route; + return true; // claim the route so Navigator fallback must be skipped. + }); + addTearDown(() => DuskPlugin.registerNavigateAdapter(null)); + + // 3. Drive the handler. + // ignore: unawaited_futures + extDuskNavigateHandler( + 'ext.dusk.navigate', + { + 'route': '/adapter-wins-target', + 'includeSnapshot': 'false', + }, + ); + await tester.pump(); + await tester.pump(); + + // 4a. Adapter must have received the route. + expect(routeSeenByAdapter, equals('/adapter-wins-target')); + + // 4b. Navigator must NOT have pushed the target route: the target + // widget's unique content marker must be absent from the tree. + // A find.text that returns nothing proves Navigator.pushNamed was + // never called (or was called but skipped because pushed was true). + expect( + find.text('adapter-wins-content'), + findsNothing, + reason: 'Navigator must not push the route when the adapter claimed it', + ); + }); + // Negative-path coverage (router never honors the route → navigated:false // + reason field) is exercised end-to-end via the uptizm-app MCP smoke // test, where the actual GoRouter + the dusk_navigate VM service call From c14b38273f842932b127609d2c655f06a34fef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Mon, 22 Jun 2026 13:04:47 +0300 Subject: [PATCH 4/4] fix(snap): route render-error banner to stderr + cite both CLI/MCP Address PR #21 review (Copilot): - The render-error banner now goes to stderr via ctx.output.error, so stdout stays the pure snapshot payload (the repo's documented stdout=payload / stderr=diagnostics convention). warning() also writes stdout in this Output impl, so error() is the correct stderr channel. - The renderErrors hint and the dusk:snap banner now cite both dusk:exceptions (CLI) and dusk_exceptions (MCP) so non-CLI consumers are not confused. - Add a command-level test for the renderErrors branch (snapshot stays clean, banner surfaced). Docs (dusk-snap.md) + CHANGELOG updated for the stderr move. --- CHANGELOG.md | 2 +- doc/commands/dusk-snap.md | 6 ++-- lib/src/commands/dusk_snap_command.dart | 16 ++++++---- lib/src/extensions/ext_snapshot.dart | 5 +-- test/src/commands/dusk_snap_command_test.dart | 32 +++++++++++++++++++ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1fcccf..dbd951f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Added -- **`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 above the tree.** 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`. +- **`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 diff --git a/doc/commands/dusk-snap.md b/doc/commands/dusk-snap.md index 9f78e2c..762fd45 100644 --- a/doc/commands/dusk-snap.md +++ b/doc/commands/dusk-snap.md @@ -76,7 +76,7 @@ When the `FlutterError.onError` capture buffer holds non-fatal render or build e { "type": "FlutterError", "message": "Incorrect use of ParentDataWidget." }, { "type": "FlutterError", "message": "A RenderFlex overflowed by 32 pixels." } ], - "hint": "Run dusk:exceptions for full messages + stack traces." + "hint": "Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for full messages + stack traces." } } ``` @@ -87,11 +87,11 @@ Fields: |-------|------|-------------| | `count` | int | Total number of captured render/build errors. May exceed `recent.length`. | | `recent` | array (max 3) | Most recent entries; each has `type` (string) and `message` (first line only). | -| `hint` | string | Fixed advisory pointing to `dusk:exceptions` for full detail. | +| `hint` | string | Fixed advisory pointing to `dusk:exceptions` (CLI) / `dusk_exceptions` (MCP) for full detail. | The `renderErrors` block is **omitted entirely** when no errors are in the buffer. A clean screen produces the standard two-key envelope (`snapshot` + `groupId`). -**CLI banner:** when `renderErrors` is present, `dusk:snap` prints a warning banner before the YAML: +**CLI banner:** when `renderErrors` is present, `dusk:snap` prints a render-error banner to **stderr** (stdout stays the pure snapshot text, so tooling that captures only stdout is unaffected): ``` ⚠ 2 render error(s) captured on this screen (run dusk:exceptions for full detail): diff --git a/lib/src/commands/dusk_snap_command.dart b/lib/src/commands/dusk_snap_command.dart index 4ee9d75..e20a6ac 100644 --- a/lib/src/commands/dusk_snap_command.dart +++ b/lib/src/commands/dusk_snap_command.dart @@ -37,20 +37,22 @@ class DuskSnapCommand extends ArtisanCommand { 'ext.dusk.snap', params, ); - // Surface captured render/build FlutterErrors first so a silently-broken - // widget (e.g. a ParentDataWidget misuse that makes a button render but - // ignore taps) is impossible to miss. Full detail is in dusk:exceptions. + // Surface captured render/build FlutterErrors so a silently-broken widget + // (e.g. a ParentDataWidget misuse that makes a button render but ignore + // taps) is impossible to miss. This is a diagnostic, not snapshot payload, + // so it goes to stderr via ctx.output.error: stdout stays the pure snapshot + // for tooling that captures only the snapshot text. Full detail lives in + // dusk:exceptions (CLI) / dusk_exceptions (MCP). final renderErrors = result['renderErrors'] as Map?; if (renderErrors != null) { final count = renderErrors['count']; - ctx.output.writeln('⚠ $count render error(s) captured on this screen ' - '(run dusk:exceptions for full detail):'); + ctx.output.error('⚠ $count render error(s) captured on this screen ' + '(run dusk:exceptions / dusk_exceptions for full detail):'); final recent = renderErrors['recent'] as List? ?? const []; for (final e in recent) { final entry = e as Map; - ctx.output.writeln(' - ${entry['type']}: ${entry['message']}'); + ctx.output.error(' - ${entry['type']}: ${entry['message']}'); } - ctx.output.writeln(''); } final snapshot = result['snapshot'] as String? ?? jsonEncode(result); diff --git a/lib/src/extensions/ext_snapshot.dart b/lib/src/extensions/ext_snapshot.dart index 793332e..a60ed9a 100644 --- a/lib/src/extensions/ext_snapshot.dart +++ b/lib/src/extensions/ext_snapshot.dart @@ -56,7 +56,7 @@ import '../utils/error_envelope.dart'; /// { "snapshot": "", "groupId": "...", /// "renderErrors": { "count": 1, /// "recent": [ { "type": "FlutterError", "message": "Incorrect use of ..." } ], -/// "hint": "Run dusk:exceptions for full messages + stack traces." } } +/// "hint": "Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for full messages + stack traces." } } /// ``` void registerSnapExtension() { @@ -161,7 +161,8 @@ Future> duskSnapBuild({ (e['message'] as String? ?? '').split('\n').first, }) .toList(), - 'hint': 'Run dusk:exceptions for full messages + stack traces.', + 'hint': 'Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for ' + 'full messages + stack traces.', }, }; } finally { diff --git a/test/src/commands/dusk_snap_command_test.dart b/test/src/commands/dusk_snap_command_test.dart index f1b3ead..f699a86 100644 --- a/test/src/commands/dusk_snap_command_test.dart +++ b/test/src/commands/dusk_snap_command_test.dart @@ -80,6 +80,38 @@ void main() { expect(output.content, contains('[ref=e1]')); }); + test('handle surfaces a renderErrors banner alongside the snapshot', + () async { + final output = BufferedOutput(); + final ctx = _StubContext( + input: MapInput(const {}), + output: output, + response: const { + 'snapshot': '- button "Save" [ref=e1]', + 'renderErrors': { + 'count': 2, + 'recent': [ + { + 'type': 'FlutterError', + 'message': 'Incorrect use of ParentDataWidget.', + }, + ], + 'hint': 'Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for ' + 'full messages + stack traces.', + }, + }, + ); + final exit = await DuskSnapCommand().handle(ctx); + expect(exit, equals(0)); + // The banner is surfaced (it routes to stderr via ctx.output.error in + // production; BufferedOutput merges that channel into content for the + // assertion). + expect(output.content, contains('2 render error(s) captured')); + expect(output.content, contains('Incorrect use of ParentDataWidget.')); + // The snapshot payload is still emitted. + expect(output.content, contains('[ref=e1]')); + }); + test('handle falls back to JSON encode when response has no snapshot key', () async { final output = BufferedOutput();