Skip to content

Commit 79254ac

Browse files
authored
Merge pull request #88 from fluttersdk/feat/extract-dusk-telescope-devtools
refactor!: extract dusk/telescope adapters to magic_devtools + M1/M2 stabilization
2 parents 1b0091e + 9256c72 commit 79254ac

18 files changed

Lines changed: 382 additions & 3682 deletions

.claude/rules/testability.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
path: "lib/src/ui/**/*.dart"
3+
---
4+
5+
# Building Views That Are Stable and E2E-Drivable
6+
7+
Views are part of the definition of done only when their primary user flows can be driven end to end by a dusk agent or a CI test. The rules below keep widget identity and semantic labels stable enough for label-based resolution (rules 1-3) and make dusk drivability part of the definition of done (rule 4).
8+
9+
## 1. Drive button loading state with `processingListenable` + `MagicBuilder`
10+
11+
Use a targeted subtree rebuild instead of a full-view `setState`. This keeps the button tree stable, avoids identity loss, and prevents the dusk agent from losing its reference mid-action.
12+
13+
Pattern (`magic_form_data.dart:267`, `magic_builder.dart:76`):
14+
15+
```dart
16+
MagicBuilder<bool>(
17+
listenable: form.processingListenable,
18+
builder: (isProcessing) => WButton(
19+
semanticLabel: 'Save profile',
20+
isLoading: isProcessing,
21+
// Disable while processing: form.process() throws StateError if
22+
// called again mid-flight (magic_form_data.dart:285), and it blocks
23+
// duplicate submits / double-taps.
24+
onTap: isProcessing ? null : () => form.process(_submit),
25+
child: WText(trans('common.save')),
26+
),
27+
)
28+
```
29+
30+
- `form.processingListenable` is a `ValueListenable<bool>` on `MagicFormData` — only the `MagicBuilder` subtree rebuilds.
31+
- `MagicBuilder<T>` wraps `ValueListenableBuilder` with a cleaner API; see `magic_builder.dart:76`.
32+
- Cross-link: Step M1 makes `processingListenable` + `MagicBuilder` the scaffolded default in generated view stubs.
33+
- Never replace this with `setState(() => _loading = ...)` on the parent widget — it tears down and remounts interactive descendants, breaking dusk locators.
34+
35+
## 2. Assign stable `ValueKey` to interactive elements that can be conditionally rendered or reordered
36+
37+
Widget identity in the Flutter element tree is position-based by default. When a button toggles between states or appears in a dynamic list, a stable key guarantees dusk resolves the same element across frames.
38+
39+
```dart
40+
// Submit button that conditionally renders a loading state
41+
WButton(
42+
key: const ValueKey('submit-profile'),
43+
semanticLabel: 'Save profile',
44+
...
45+
),
46+
47+
// Action button inside a dynamic list item
48+
WButton(
49+
key: ValueKey('delete-item-${item.id}'),
50+
semanticLabel: 'Delete ${item.name}',
51+
...
52+
),
53+
```
54+
55+
Rule of thumb: any interactive widget (`WButton`, `WInput`, custom controls) inside a `Column`/`ListView` with conditional siblings, or one that can be toggled visible/invisible, needs a `ValueKey`.
56+
57+
## 3. Set `semanticLabel` on icon-only and ambiguous interactive widgets
58+
59+
The dusk agent resolves elements by semantic label. Without a label, an icon-only button is invisible to label-based resolution.
60+
61+
`WButton.semanticLabel` (`wind/lib/src/widgets/w_button.dart:113`) propagates to the `Semantics` node wrapping the interactive surface:
62+
63+
```dart
64+
// Icon-only button — no visible text, must have a label
65+
WButton(
66+
key: const ValueKey('back-button'),
67+
semanticLabel: 'Go back',
68+
onTap: () => Route.back(),
69+
child: WIcon(Icons.arrow_back),
70+
),
71+
72+
// Ambiguous action (same icon used multiple times on screen)
73+
WButton(
74+
key: ValueKey('edit-user-${user.id}'),
75+
semanticLabel: 'Edit user ${user.name}',
76+
onTap: () => _edit(user),
77+
child: WIcon(Icons.edit),
78+
),
79+
```
80+
81+
For generic Flutter widgets that do not accept `semanticLabel` directly, wrap with `Semantics(label: '...', child: widget)`.
82+
83+
## 4. Definition of done for view work includes dusk drivability
84+
85+
A view is done when:
86+
- Its primary user flows (form submit, list action, navigation) can be driven by a dusk agent using semantic labels and stable keys.
87+
- No interactive element in a primary flow is label-free or identity-unstable across a state change.
88+
- The processingListenable + MagicBuilder pattern is used for any submit/loading state.
89+
90+
"Tests pass" and "looks correct" are necessary but not sufficient — dusk drivability is the third gate.

.claude/rules/ui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ path: "lib/src/ui/**/*.dart"
2020
- `MagicFeedback` — toast/snackbar feedback integration
2121
- `MagicTitle(title: 'Page', child: widget)` — declarative title override widget. Sets `TitleManager.setOverride()` on mount, clears on dispose. Updates on `didUpdateWidget` when title changes. Use for data-dependent titles that resolve after route mount
2222
- Wind UI integration: views use `WDiv`, `WText`, `WButton` etc. for styling via `className` props
23+
- E2E drivability: stable keys, semantic labels, and `processingListenable` + `MagicBuilder` are required for primary user flows — see `testability.md`

CHANGELOG.md

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ All notable changes to this project will be documented in this file.
1313
- [ ] `example/` updated when the change touches the canonical consumer scaffold
1414
- [ ] `flutter test` green; `dart analyze` clean; `dart format` no diff; `dart pub publish --dry-run` no blocking errors
1515

16+
### Stabilization (magic-stabilize-dusk-telescope plan)
17+
18+
- **BREAKING: the Dusk + Telescope Magic adapters moved out of magic core into the new sibling `magic_devtools` package.** `MagicDuskIntegration` (14 enrichers), `MagicTelescopeIntegration` (5 watchers + `MagicHttpFacadeAdapter`) and their tests now live in `magic_devtools`; magic core no longer depends on `fluttersdk_dusk` or `fluttersdk_telescope` at all. The class and function names are unchanged; only the import path moves and ownership shifts to a dedicated dev-tooling package. Consumer migration (pre-1.0 clean break, no shim):
19+
20+
```dart
21+
// before (interim sub-barrel, never released):
22+
import 'package:magic/dusk_integration.dart';
23+
import 'package:magic/telescope_integration.dart';
24+
25+
// after — add magic_devtools as a dev_dependency, then:
26+
import 'package:magic_devtools/dusk.dart';
27+
import 'package:magic_devtools/telescope.dart';
28+
// MagicDuskIntegration.install(); / MagicTelescopeIntegration.install();
29+
```
30+
31+
Deletes `lib/src/cli/{dusk,telescope}_integration.dart`, the `lib/{dusk,telescope}_integration.dart` sub-barrels, and `test/cli/{dusk,telescope}_integration_test.dart` from magic; drops the two `fluttersdk_dusk` / `fluttersdk_telescope` dependency lines from `pubspec.yaml`.
32+
- **Granular scaffold + documentation is the default (M1).** The E2E-drivability defaults (`processingListenable` + `MagicBuilder`, stable `ValueKey`, `semanticLabel` on ambiguous interactive widgets) are documented in `.claude/rules/testability.md` and reflected in generated view stubs. Opt-in, no runtime behavior break for existing consumers.
33+
- **Testability rules formalized (M2).** `.claude/rules/testability.md` defines view drivability as the third gate of "done" alongside passing tests and correct appearance, with the three widget-identity rules dusk depends on.
34+
- **`fluttersdk_artisan` constraint bumped `^0.0.7` -> `^0.0.8`.** Drop-in: magic uses no artisan symbol changed between the two versions.
35+
1636
### Fixed (consumer-blocking bugs surfaced by `/tmp` fresh-app E2E test plan)
1737

1838
- **`make:*` commands now work on consumers that pull magic from pub.dev / path: dependency.** `MakeControllerCommand`, `MakeModelCommand`, and the other 12 `make:*` commands used to call `StubLoader.load('controller')` directly, which searches `$ARTISAN_STUBS_DIR``$MAGIC_CLI_STUBS_DIR``fluttersdk_artisan-<version>/assets/stubs/`. Magic's own stubs live at `<magic>/assets/stubs/`; neither env var was set in typical environments, and the fluttersdk_artisan pub-cache fallback contained only artisan substrate stubs. The 14 generators now load raw stub content via the new `MagicStubLoader` helper (which resolves `<magic>/assets/stubs/<name>.stub` from the consumer's `.dart_tool/package_config.json` magic entry) and pass the content through `getStub()` for `ArtisanGeneratorCommand.buildClass` to consume as a literal template. Adds `lib/src/cli/helpers/magic_stub_loader.dart`; touches `lib/src/cli/commands/make_*.dart` × 14.
@@ -29,7 +49,7 @@ All notable changes to this project will be documented in this file.
2949

3050
### Improvements (UX)
3151

32-
- **`magic:install` post-install message documents the optional Dusk + Telescope setup chain.** Removed the obsolete sqlite3.wasm warning (the install command auto-fetches sqlite3.wasm 3.3.1 since the artisan-install-command-magic plan). Added a 6-command setup recipe (`plugin:install fluttersdk_dusk` + `plugin:install fluttersdk_telescope` + `dusk:install` + `telescope:install` + the `fluttersdk_dusk`/`fluttersdk_telescope` pubspec declares) so operators discover the debug-tooling path without consulting the docs. Touches `install.yaml` (`post_install.message`).
52+
- **`magic:install` post-install message documents the optional Dusk + Telescope setup chain.** Removed the obsolete sqlite3.wasm warning (the install command auto-fetches sqlite3.wasm 3.3.1 since the artisan-install-command-magic plan). Added a setup recipe pointing operators at the `magic_devtools` dev_dependency (plus `fluttersdk_dusk` / `fluttersdk_telescope`) and the `package:magic_devtools/{dusk,telescope}.dart` adapter imports, so the debug-tooling path is discoverable without consulting the docs. Touches `install.yaml` (`post_install.message`).
3353

3454
### Changed
3555

@@ -40,28 +60,6 @@ All notable changes to this project will be documented in this file.
4060
- `magic:install --with-debug-tooling` single-command flag that chains the 6-step Dusk + Telescope setup recipe (currently the post_install message documents the recipe; the flag would auto-execute it). Tracking issue: TBD.
4161
- `MainDartSmartMerger` should consolidate the 4 `if (kDebugMode) { ... }` blocks that `dusk:install` + `telescope:install` emit into 2 blocks (pre-`Magic.init()` host plugins + post-`Magic.init()` Magic adapters). Currently each install command writes its own block, producing four single-statement blocks. Tracking issue: TBD.
4262

43-
### BREAKING
44-
45-
- Dependency migration: `fluttersdk_dusk` and `fluttersdk_telescope` moved from `dependencies:` to `dev_dependencies:`. Vanilla magic consumers no longer pull these dev-tooling packages transitively. Consumers wanting the Magic-side integrations must add the packages to their own pubspec and switch their import path (see migration below).
46-
- Barrel removal: `MagicDuskIntegration`, `MagicTelescopeIntegration`, the 14 enrichers, the 5 watchers, and `MagicHttpFacadeAdapter` are no longer exported from the main `package:magic/magic.dart` barrel. The class and function names are unchanged; only the import path moves.
47-
- New opt-in sub-barrels at `lib/dusk_integration.dart` and `lib/telescope_integration.dart`. Consumer migration:
48-
49-
Replace:
50-
```dart
51-
import 'package:magic/magic.dart';
52-
// ... MagicDuskIntegration.install();
53-
```
54-
With:
55-
```dart
56-
import 'package:magic/magic.dart';
57-
import 'package:magic/dusk_integration.dart';
58-
import 'package:magic/telescope_integration.dart';
59-
// ... MagicDuskIntegration.install();
60-
// ... MagicTelescopeIntegration.install();
61-
```
62-
63-
The `package:magic/magic.dart` import stays for other Magic types (MagicRouter, MagicApplication, etc.).
64-
6563
### Changed (artisan-install-command-magic plan)
6664

6765
- **`magic:install` now delegates canonical Flutter scaffold to artisan's `install` command in-process.** After `stagedInstaller.commit()` returns Success, `delegateArtisanInstall` invokes `InstallCommand.scaffoldInto` (from the artisan public barrel) to write `bin/dispatcher.dart` + barrels + pubspec dep + bin/fsa. Gated inside the existing `if (result is Success)` block so dry-run / Conflict / Error results skip the delegation and atomic-commit semantics are preserved. Magic-specific extras (conditional configs, dynamic `lib/config/app.dart`, `lib/main.dart` smart-merge, sqlite3.wasm) remain magic-side.

CLAUDE.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,8 @@ Laravel-inspired Flutter framework with Facades, Eloquent ORM, Service Providers
2424
Magic ships opt-in sub-barrels alongside the main `lib/magic.dart`:
2525
- `lib/cli.dart`: Flutter-free CLI/artisan provider exports (no Flutter dependency)
2626
- `lib/testing.dart`: test fakes (Http, Auth, Echo, etc.)
27-
- `lib/dusk_integration.dart`: Magic adapter for fluttersdk_dusk (MagicDuskIntegration); consumer must add `fluttersdk_dusk` to its own pubspec as a dev-dependency
28-
- `lib/telescope_integration.dart`: Magic adapter for fluttersdk_telescope (MagicTelescopeIntegration); consumer must add `fluttersdk_telescope` to its own pubspec as a dev-dependency
2927

30-
These two adapter sub-barrels are NOT exported from `lib/magic.dart`. `fluttersdk_dusk` and `fluttersdk_telescope` are magic dev-dependencies only; they are not transitive prod deps for consumers.
28+
The Magic adapters for the dev-tooling ecosystem (`MagicDuskIntegration`, `MagicTelescopeIntegration`) now live in the sibling `magic_devtools` package. Consumers add `magic_devtools` as a dev_dependency and import `package:magic_devtools/dusk.dart` / `package:magic_devtools/telescope.dart`. Magic core no longer depends on `fluttersdk_dusk` or `fluttersdk_telescope` at all.
3129

3230
```
3331
lib/

assets/stubs/controller.resource.stub

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ class {{ className }}Controller extends MagicController
5757
// Business Logic
5858
// ---------------------------------------------------------------------------
5959

60+
// Submit-button loading is form-scoped, NOT page-scoped. Drive it through
61+
// the form's processingListenable + MagicBuilder so only the button rebuilds;
62+
// a full-view setLoading() would tear down and remount sibling inputs, which
63+
// also breaks dusk locators. The recommended view pattern:
64+
//
65+
// MagicBuilder<bool>(
66+
// listenable: form.processingListenable,
67+
// builder: (isProcessing) => WButton(
68+
// isLoading: isProcessing,
69+
// onTap: isProcessing ? null : () => form.process(() => controller.store(form.data)),
70+
// child: WText(trans('common.save')),
71+
// ),
72+
// )
73+
//
74+
// Reserve setLoading()/setSuccess()/setError() below for PAGE-level state
75+
// (the index/show data fetch), not for the submit button.
76+
6077
/// Load all items from the API.
6178
Future<void> load() async {
6279
setLoading();

assets/stubs/view.stateful.stub

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ class _{{ className }}ViewState extends State<{{ className }}View> {
2727

2828
@override
2929
Widget build(BuildContext context) {
30+
// Form submit buttons should load through the form's processingListenable
31+
// so only the button rebuilds, not the whole view. This keeps inputs and
32+
// dusk locators stable across a submit. Recommended pattern:
33+
//
34+
// MagicForm(
35+
// formData: form,
36+
// child: Column(
37+
// children: [
38+
// WFormInput(form: form, name: 'name'),
39+
// MagicBuilder<bool>(
40+
// listenable: form.processingListenable,
41+
// builder: (isProcessing) => WButton(
42+
// isLoading: isProcessing,
43+
// onTap: isProcessing ? null : () => form.process(_submit),
44+
// child: WText(trans('common.save')),
45+
// ),
46+
// ),
47+
// ],
48+
// ),
49+
// )
50+
//
51+
// Prefer this over driving the button through a full-view setState/setLoading.
3052
return WDiv(
3153
className: 'flex flex-col p-6',
3254
children: [

example/pubspec.lock

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
# Generated by pub
22
# See https://dart.dev/tools/pub/glossary#lockfile
33
packages:
4-
archive:
5-
dependency: transitive
6-
description:
7-
name: archive
8-
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
9-
url: "https://pub.dev"
10-
source: hosted
11-
version: "4.0.9"
124
args:
135
dependency: transitive
146
description:
@@ -329,26 +321,10 @@ packages:
329321
dependency: "direct main"
330322
description:
331323
name: fluttersdk_artisan
332-
sha256: "67c9bf721a9c6164f41cad2370d68b1bb61f57eff05048e29708ef99f4437fc2"
333-
url: "https://pub.dev"
334-
source: hosted
335-
version: "0.0.7"
336-
fluttersdk_dusk:
337-
dependency: transitive
338-
description:
339-
name: fluttersdk_dusk
340-
sha256: b4eeed51cd3ad715da27b391c287f32281433c540c25fc5cf006b591f006aac5
341-
url: "https://pub.dev"
342-
source: hosted
343-
version: "0.0.2"
344-
fluttersdk_telescope:
345-
dependency: transitive
346-
description:
347-
name: fluttersdk_telescope
348-
sha256: "69a77f4a5427966b70b7bd1807d87a63182150f14e50bdd587f2a69272872bff"
324+
sha256: "008a29e38629ee1295a601fc9146d976ecf642b93e6dbe6750a9336ed4cac05c"
349325
url: "https://pub.dev"
350326
source: hosted
351-
version: "0.0.2"
327+
version: "0.0.8"
352328
fluttersdk_wind:
353329
dependency: transitive
354330
description:
@@ -405,14 +381,6 @@ packages:
405381
url: "https://pub.dev"
406382
source: hosted
407383
version: "4.1.2"
408-
image:
409-
dependency: transitive
410-
description:
411-
name: image
412-
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
413-
url: "https://pub.dev"
414-
source: hosted
415-
version: "4.8.0"
416384
image_picker:
417385
dependency: transitive
418386
description:
@@ -716,14 +684,6 @@ packages:
716684
url: "https://pub.dev"
717685
source: hosted
718686
version: "3.9.1"
719-
posix:
720-
dependency: transitive
721-
description:
722-
name: posix
723-
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
724-
url: "https://pub.dev"
725-
source: hosted
726-
version: "6.5.0"
727687
pub_semver:
728688
dependency: transitive
729689
description:

install.yaml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,12 @@ post_install:
113113
exception, model, gate watchers). Both gate on kDebugMode so production
114114
builds tree-shake them entirely.
115115
116-
To enable, add to pubspec.yaml dependencies (depend_on_referenced_packages
117-
lint requires explicit declares even though magic transitively provides
118-
them):
116+
The Magic adapters live in the sibling `magic_devtools` package. To
117+
enable, add it (plus the tooling packages) to dev_dependencies:
119118
120-
fluttersdk_dusk: ^0.0.2
121-
fluttersdk_telescope: ^0.0.2
119+
magic_devtools: ^0.0.1
120+
fluttersdk_dusk: ^0.0.7
121+
fluttersdk_telescope: ^0.0.4
122122
123123
Then:
124124
@@ -128,8 +128,15 @@ post_install:
128128
dart run magic:artisan dusk:install
129129
dart run magic:artisan telescope:install
130130
131-
The last two commands wire the kDebugMode setup blocks into
132-
lib/main.dart. MCP tools surface for Claude Code / Cursor via:
131+
Wire the adapters in lib/main.dart (debug-only, after Magic.init()):
132+
133+
import 'package:magic_devtools/dusk.dart';
134+
import 'package:magic_devtools/telescope.dart';
135+
// ... if (kDebugMode) { MagicDuskIntegration.install(); }
136+
// ... { MagicTelescopeIntegration.install(); }
137+
138+
The dusk:install / telescope:install commands wire the kDebugMode setup
139+
blocks into lib/main.dart. MCP tools surface for Claude Code / Cursor via:
133140
134141
dart run magic:artisan mcp:install
135142
# then restart Claude Code; /mcp lists 40+ dusk_/telescope_ tools.

lib/dusk_integration.dart

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)