Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ 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 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`.

---

## [0.0.8] - 2026-06-17
Expand Down
39 changes: 38 additions & 1 deletion doc/commands/dusk-snap.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The flag is parsed via `(ctx.input.option('includeEnrichers') as bool?) ?? false
<a name="returns"></a>
## Returns

The VM Service handler returns a JSON envelope `{ "snapshot": "<yaml>" }`. 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": "<yaml>", "groupId": "<id>" }`. 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):**

Expand All @@ -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": "<yaml>",
"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 (CLI) or dusk_exceptions (MCP) 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` (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 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):
- 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:
Expand Down
28 changes: 25 additions & 3 deletions doc/mcp/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>]` token, its role,
label, actions, bounds, optional `overflow: true` and `typeable: true` sub-lines, and any
enricher-contributed indented lines.
Success: `{ "snapshot": "<yaml>", "groupId": "<id>" }`. The `snapshot` value is a YAML
document where each node is annotated with a `[ref=e<N>]` 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

Expand Down
18 changes: 18 additions & 0 deletions lib/src/commands/dusk_snap_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ class DuskSnapCommand extends ArtisanCommand {
'ext.dusk.snap',
params,
);
// 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<String, dynamic>?;
if (renderErrors != null) {
final count = renderErrors['count'];
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<dynamic>? ?? const [];
for (final e in recent) {
final entry = e as Map<String, dynamic>;
ctx.output.error(' - ${entry['type']}: ${entry['message']}');
}
}

final snapshot = result['snapshot'] as String? ?? jsonEncode(result);
ctx.output.writeln(snapshot);
return 0;
Expand Down
7 changes: 7 additions & 0 deletions lib/src/dusk_artisan_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down
95 changes: 46 additions & 49 deletions lib/src/extensions/ext_navigation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,59 +203,56 @@ Future<developer.ServiceExtensionResponse> 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',
);
}
}
}
}
Expand Down
36 changes: 36 additions & 0 deletions lib/src/extensions/ext_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +46,18 @@ import '../utils/error_envelope.dart';
/// ```json
/// { "snapshot": "<yaml>", "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": "<yaml>", "groupId": "...",
/// "renderErrors": { "count": 1,
/// "recent": [ { "type": "FlutterError", "message": "Incorrect use of ..." } ],
/// "hint": "Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for full messages + stack traces." } }
/// ```

void registerSnapExtension() {
if (!kDebugMode) return;
Expand Down Expand Up @@ -125,9 +138,32 @@ Future<Map<String, dynamic>> 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<Map<String, dynamic>> captured =
recentCapturedExceptions(limit: 50);

return <String, dynamic>{
'snapshot': buffer.toString(),
'groupId': groupId,
if (captured.isNotEmpty)
'renderErrors': <String, dynamic>{
'count': captured.length,
'recent': captured
.take(3)
.map((Map<String, dynamic> e) => <String, dynamic>{
'type': e['type'],
'message':
(e['message'] as String? ?? '').split('\n').first,
})
.toList(),
'hint': 'Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for '
'full messages + stack traces.',
},
};
} finally {
handle.dispose();
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>`, which falls back to CDP `Page.captureScreenshot` when artisan was started with `--cdp-port` (CLI-only, not this MCP tool).

*Gestures (pointer actions)*
Expand Down
32 changes: 32 additions & 0 deletions test/src/commands/dusk_snap_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading