diff --git a/CHANGELOG.md b/CHANGELOG.md index def78e7..412c497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `MagicPerfIntegration`: the wiring that assembles the performance-diagnostic + data path across four packages. It sets `MagicController.onRefreshUI` to a + counter keyed by controller runtime type, registers a `NavigatorObserver` + through `MagicRouter.addObserver` that times each route push to the first + post-frame callback after the new route builds, calls + `Wind.installPerfResolver()`, registers telescope's `FramePerfWatcher`, and + assigns the four `fluttersdk_dusk` pointers (`framePerfReader`, + `perfExtrasReader`, `perfSessionBeginHook`, `perfSessionEndHook`). This + package is the only place dusk, telescope, wind and magic are all visible at + once, so it is the only place those pointers can be assigned; dusk declares + them with no-op defaults and never imports the packages it reports on. +- `MagicDevtools.installPre()` now installs `MagicPerfIntegration`. It belongs + in the pre-`Magic.init()` half because `MagicRouter.addObserver` throws once + the router has been built, and that `StateError` is deliberately not caught: + a silently unregistered observer would produce a report with no route + transitions and nothing to explain their absence. + ## [0.0.3] - 2026-08-05 Documentation only; no runtime change. The package code is identical to 0.0.2. diff --git a/analysis_options.yaml b/analysis_options.yaml index d070858..6cd226c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,6 +5,14 @@ analyzer: # List as descriptive placeholders, not HTML. Mirrors magic's own # analysis_options (these files were extracted from magic verbatim). unintended_html_in_doc_comment: ignore + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/src/magic_devtools.dart b/lib/src/magic_devtools.dart index 4c8958b..3dc8ab3 100644 --- a/lib/src/magic_devtools.dart +++ b/lib/src/magic_devtools.dart @@ -2,8 +2,11 @@ import 'package:fluttersdk_dusk/dusk.dart'; import 'package:fluttersdk_telescope/telescope.dart'; import 'dusk_integration.dart'; +import 'perf_integration.dart'; import 'telescope_integration.dart'; +export 'perf_integration.dart'; + /// One-call wiring for the Magic dev-tooling bundle: fluttersdk_dusk + /// fluttersdk_telescope and their Magic integrations, installed in the two /// phases that straddle [Magic.init]. @@ -57,15 +60,32 @@ class MagicDevtools { /// [DumpWatcher]: the two watchers telescope leaves opt-in but every Magic /// dev session wants (uncaught exceptions and `debugPrint` dumps). /// + /// Also installs [MagicPerfIntegration], the performance data path. It + /// belongs in this half rather than [installPost] because it registers a + /// [NavigatorObserver] through `MagicRouter.addObserver`, which throws once + /// the router has been built; the rest of its wiring would work from either + /// half and is kept with it at the one install site. + /// /// Each underlying install is idempotent, so a second call in the same /// isolate is safe. Register additional watchers after this call via /// [TelescopePlugin.registerWatcher]. + /// + /// It is NOT safe to call late, though, and that is new: the perf + /// integration registers a [NavigatorObserver], and `MagicRouter.addObserver` + /// throws a [StateError] once the router has been built. A host that installs + /// this behind a lazy debug toggle after `runApp` used to get harmless + /// no-ops and now crashes. The throw is deliberate, since a silently + /// unregistered observer would produce a report with no route transitions + /// and nothing to explain their absence, but it means this belongs at boot + /// and nowhere else. static void installPre() { DuskPlugin.install(); TelescopePlugin.install(); TelescopePlugin.registerWatcher(ExceptionWatcher()); TelescopePlugin.registerWatcher(DumpWatcher()); + + MagicPerfIntegration.install(); } /// Post-`Magic.init()` half: wire Magic's runtime into both tools. diff --git a/lib/src/perf_integration.dart b/lib/src/perf_integration.dart new file mode 100644 index 0000000..1be2090 --- /dev/null +++ b/lib/src/perf_integration.dart @@ -0,0 +1,255 @@ +import 'dart:collection'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:fluttersdk_dusk/dusk.dart' + show + framePerfReader, + perfExtrasReader, + perfSessionBeginHook, + perfSessionEndHook; +import 'package:fluttersdk_telescope/telescope.dart'; +import 'package:magic/magic.dart'; + +/// dusk's own no-op defaults, captured the first time [MagicPerfIntegration] +/// is about to overwrite them. +/// +/// Captured rather than re-typed here, so a change to dusk's declared shape +/// reaches the reset instead of leaving this package and its tests agreeing +/// with each other about a contract that had moved. +/// +/// NOT top-level `final`s, which is the version this replaces and which did +/// not work: a top-level `final` in Dart initialises on first READ, and the +/// only reader is `resetForTesting()`, which runs after `install()` has +/// already assigned over the pointers. It captured this package's own closures +/// and restored them, so every "back to the default" assertion was really +/// asserting that install had happened. Verified with a standalone repro +/// before replacing it. +Map Function()? _duskFramePerfDefault; +Map Function()? _duskPerfExtrasDefault; +void Function()? _duskSessionBeginDefault; +void Function()? _duskSessionEndDefault; + +/// Assembles the whole performance-diagnostic data path: magic's controller and +/// route activity, wind's aggregate counters, telescope's frame buffer, and the +/// four pointers `fluttersdk_dusk` reads them all through. +/// +/// Host integration (debug-only, and BEFORE `Magic.init()`; see [install]): +/// ```dart +/// if (kDebugMode) MagicDevtools.installPre(); +/// ``` +/// +/// This package is the only place in the ecosystem where dusk, telescope, wind +/// and magic are all visible at once, which is why the pointer assignment can +/// only live here: dusk's frozen dependency contract forbids it from importing +/// any of the three packages whose data it reports. +/// +/// The failure mode this class exists to prevent is silent. Every pointer has a +/// structurally-complete no-op default, so an unassigned one produces a report +/// of zeros rather than an error, in a different repository, at the end of a +/// driven run that looked like it worked. +/// +/// `fluttersdk_wind` is reached through magic's barrel, which re-exports it +/// wholesale (`magic/lib/magic.dart:4`); importing it directly here would be +/// flagged as an unnecessary import. +class MagicPerfIntegration { + MagicPerfIntegration._(); + + /// How many route transitions are retained. A long session navigates far + /// more than a report can rank, and the recent ones are the ones near the + /// interaction the operator just drove. + static const int _maxRouteTransitions = 200; + + /// Idempotent install. Safe to call multiple times within the same isolate + /// lifetime. + /// + /// MUST run before the router is built, i.e. from + /// `MagicDevtools.installPre()` ahead of `Magic.init()`: + /// [MagicRouter.addObserver] throws a [StateError] once `routerConfig` has + /// been read (`magic/lib/src/routing/magic_router.dart:158`). That throw is + /// deliberately not caught. A swallowed one would leave the report with no + /// route transitions and nothing to explain their absence. + static void install() { + if (_installed) return; + + // 1. Registered once and tracked separately, because the guard below is + // armed at the END rather than here. Arming it early would make a retry + // after a throw in steps 2 to 4 a silent no-op, which is the failure + // this class exists to prevent; arming it late without this flag would + // register a second observer on that retry. + if (!_observerRegistered) { + MagicRouter.instance.addObserver(_observer); + _observerRegistered = true; + } + + // 2. magic: one hook on the single notifyListeners() call site in + // MagicController, counted per controller runtime type so the report can + // name which controller is rebuilding the screen. + MagicController.onRefreshUI = _recordNotify; + + // 3. wind and telescope: the two producers. Installing wind's resolver + // costs nothing on its own; counting stays off until a session's begin + // hook enables it. + Wind.installPerfResolver(); + final FramePerfWatcher watcher = FramePerfWatcher(); + TelescopePlugin.registerWatcher(watcher); + _watcher = watcher; + + // 4. The four dusk pointers. Each returns exactly the key set pinned in + // `dusk/lib/src/utils/perf_readers.dart`; the consumer is in another + // repository, so a renamed key is invisible until a driven run. + // + // dusk's own defaults are captured HERE, immediately before they are + // overwritten, because that is the last moment they are still readable. + _duskFramePerfDefault ??= framePerfReader; + _duskPerfExtrasDefault ??= perfExtrasReader; + _duskSessionBeginDefault ??= perfSessionBeginHook; + _duskSessionEndDefault ??= perfSessionEndHook; + framePerfReader = () => { + 'frames': TelescopeStore.recentFramePerf() + .map>((FramePerfRecord r) => r.toJson()) + .toList(), + 'livenessCounter': FramePerfWatcher.livenessCounter, + }; + perfExtrasReader = () => { + 'controllerNotifies': controllerNotifyCounts, + 'routeTransitions': routeTransitions, + }; + perfSessionBeginHook = () { + WindPerfCounters.reset(); + WindPerfCounters.enabled = true; + // clearFramePerf(), never clear(): the latter wipes the HTTP, log and + // exception buffers a developer may be reading alongside the session, + // and resetForTesting() is @visibleForTesting and would fail analysis. + TelescopeStore.clearFramePerf(); + // The magic-side counters are session-scoped for the same reason wind's + // are: without this, every session reports the sum of all previous ones. + _controllerNotifies.clear(); + _routeTransitions.clear(); + }; + perfSessionEndHook = () { + // Counting off, totals intact: `perf_end` reads them to build its + // report, and `WindParser.parse` is too hot to leave instrumented. + WindPerfCounters.enabled = false; + }; + + // 5. Last, so a throw anywhere above leaves the door open for a retry. + _installed = true; + } + + /// Whether [install] has been called at least once. + @visibleForTesting + static bool get isInstalled => _installed; + + /// How many times each controller type has called `refreshUI()` since the + /// last session began, keyed by `runtimeType.toString()`. + static Map get controllerNotifyCounts => + Map.of(_controllerNotifies); + + /// Route pushes observed since the last session began, oldest first. Each + /// entry carries `route` (the page name magic stamps on its routes, which is + /// the route name or else its path), `durationMicros` (push to the first + /// post-frame callback after the new route built) and `time`. + static List> get routeTransitions => + _routeTransitions.toList(); + + /// Test-only reset. Drops the idempotency guard, clears the magic-side + /// counters, uninstalls the frame watcher, and restores all four dusk + /// pointers to their no-op defaults so a later test asserting the + /// missing-integration behaviour does not see a leaked binding. + /// + /// Also forces wind's counting off: a test that ran the begin hook would + /// otherwise leave `WindParser.parse` instrumented for every later test. + /// + /// Does NOT unregister the observer (a fresh `MagicRouter.reset()` drops it + /// with the router instance) and cannot unregister the watcher from + /// [TelescopePlugin], whose list is private; uninstalling the watcher is + /// what stops it recording. + @visibleForTesting + static void resetForTesting() { + _installed = false; + _observerRegistered = false; + _controllerNotifies.clear(); + _routeTransitions.clear(); + MagicController.onRefreshUI = null; + _watcher?.uninstall(); + _watcher = null; + WindPerfCounters.enabled = false; + // Restored from the values dusk itself declared rather than hand-written + // here. Re-typing them would let this package and its tests agree on a key + // set that had drifted from dusk's, and the assertions would keep passing + // while production drifted with them. See the four fields at the top of + // this file for why they are captured on the first install and not at load. + // + // Null only when install() never ran, in which case the pointers are + // already at dusk's defaults and there is nothing to put back. + if (_duskFramePerfDefault != null) { + framePerfReader = _duskFramePerfDefault!; + perfExtrasReader = _duskPerfExtrasDefault!; + perfSessionBeginHook = _duskSessionBeginDefault!; + perfSessionEndHook = _duskSessionEndDefault!; + } + } + + static void _recordNotify(MagicController controller) { + _controllerNotifies.update( + controller.runtimeType.toString(), + (int count) => count + 1, + ifAbsent: () => 1, + ); + } + + static void _recordRouteTransition(String route, int durationMicros) { + _routeTransitions.addLast({ + 'route': route, + 'durationMicros': durationMicros, + 'time': DateTime.now().toIso8601String(), + }); + while (_routeTransitions.length > _maxRouteTransitions) { + _routeTransitions.removeFirst(); + } + } + + static bool _installed = false; + static bool _observerRegistered = false; + static FramePerfWatcher? _watcher; + static final _RouteTransitionObserver _observer = _RouteTransitionObserver(); + static final Map _controllerNotifies = {}; + static final Queue> _routeTransitions = + Queue>(); +} + +/// Times a route push from the moment the navigator reports it to the first +/// post-frame callback after it, which is the first point the new route has +/// actually built and laid out. +/// +/// Only pushes are timed. A pop tears a route down rather than building one, so +/// it has no equivalent span, and go_router replaces the whole page stack on a +/// `go()`, which the navigator reports as a push of the incoming route. +class _RouteTransitionObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + + // An anonymous push is a dialog or a bottom sheet, not a page transition. + // showDialog and showModalBottomSheet both go through the navigator, so in + // a dialog-heavy session they would share the bounded list with the real + // transitions and evict the very entries the report is ranking. Skipped + // rather than bucketed: a duration nobody can attribute to a screen is not + // one an agent can act on. + final String? name = route.settings.name; + if (name == null) return; + + final Stopwatch watch = Stopwatch()..start(); + + // One-shot by design, one per push: unlike a per-frame drain there is + // nothing to re-register, because the span closes on the next frame. + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + watch.stop(); + MagicPerfIntegration._recordRouteTransition( + name, + watch.elapsedMicroseconds, + ); + }); + } +} diff --git a/test/perf_integration_test.dart b/test/perf_integration_test.dart new file mode 100644 index 0000000..cbd492a --- /dev/null +++ b/test/perf_integration_test.dart @@ -0,0 +1,401 @@ +import 'dart:ui' show FrameTiming, PlatformDispatcher, TimingsCallback; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_dusk/dusk.dart' + show + framePerfReader, + perfExtrasReader, + perfSessionBeginHook, + perfSessionEndHook; +import 'package:fluttersdk_telescope/telescope.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_devtools/magic_devtools.dart'; + +/// Tests for [MagicPerfIntegration], the single place dusk, telescope, wind and +/// magic meet. +/// +/// The failure mode this file exists to prevent is silent: an unassigned reader +/// pointer or an unregistered observer produces a structurally complete report +/// of zeros, with no error, in a different repository. So every test here +/// asserts on the DATA that reaches the pointer, never on `install()` merely +/// returning. + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +class _AlphaController extends MagicController {} + +class _BetaController extends MagicController {} + +/// Builds a [FrameTiming] from the raw microsecond stamps its public factory +/// takes; that factory's own docstring says it exists for unit tests, and +/// `tester.pump()` delivers no timing of its own. +FrameTiming _timing({required int frameNumber}) { + const int vsyncStart = 0; + const int buildStart = 1000; + const int buildFinish = buildStart + 4000; + const int rasterFinish = buildFinish + 2000; + + return FrameTiming( + vsyncStart: vsyncStart, + buildStart: buildStart, + buildFinish: buildFinish, + rasterStart: buildFinish, + rasterFinish: rasterFinish, + rasterFinishWallTime: rasterFinish, + frameNumber: frameNumber, + ); +} + +/// Fires a timings batch the way the engine would. +/// +/// Asserts the dispatcher is armed first: with no timings callback registered +/// `onReportTimings` is null, and a silently-null `?.call` would make every +/// count below vacuous. +void _fireTimings(List timings) { + final TimingsCallback? report = PlatformDispatcher.instance.onReportTimings; + expect( + report, + isNotNull, + reason: + 'the platform dispatcher must be armed for an injected batch to ' + 'reach the frame watcher at all', + ); + report!(timings); +} + +FramePerfRecord _frameRecord(int frameNumber) => FramePerfRecord( + frameNumber: frameNumber, + buildMicros: 4000, + rasterMicros: 2000, + vsyncOverheadMicros: 1000, + totalSpanMicros: 7000, + time: DateTime(2026, 8, 25), + blocks: const {}, +); + +/// Moves wind's counters the way the app does: by building a real W-widget. +/// +/// The `record*` entry points are `@internal` to `fluttersdk_wind`, since they +/// exist for its own parse path, so reaching for them here would assert +/// against a surface no consumer is meant to touch. A pump is also the honest +/// version of this setup: it is what actually moves these numbers in an app. +Future _buildOneWidget(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv(className: 'p-4'), + ), + ), + ); +} + +/// Builds the widget once against a COLD parse cache, so the build that +/// follows is guaranteed to be a hit. +/// +/// `WindParser._styleCache` is a static map keyed by className plus theme +/// state, so every test in this isolate shares it. Without pinning it, whether +/// a build counts as a hit or a miss depends on which test ran first: these two +/// cases used to pass together and fail when either was run alone, because one +/// warmed the key the other asserted on. +Future _warmTheParseCache(WidgetTester tester) async { + WindParser.clearCache(); + await _buildOneWidget(tester); + // Pump something else in between: pumping an identical tree does not rebuild + // it, so the next _buildOneWidget would parse nothing at all and the hit the + // caller is waiting for would never happen. + await tester.pumpWidget(const SizedBox.shrink()); + WindPerfCounters.reset(); +} + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + // `MagicDevtools.installPre()` installs telescope's DumpWatcher, which + // replaces the global `debugPrint`. Nothing used to put it back, so Flutter's + // own post-test check ("the value of a foundation debug variable was changed + // by the test") fired on whichever `testWidgets` case happened to run NEXT. + // That made the failure land on an innocent test and only under some + // orderings, which is why it survived a green suite. + // + // The restore has to happen INSIDE the test body, which is what + // [restoreDebugPrint] is for. `_verifyInvariants()` runs immediately after + // `await testBody()` in `AutomatedTestWidgetsFlutterBinding.runTest` + // (`flutter_test/lib/src/binding.dart:1974`), so both `tearDown` and + // `addTearDown` are too late: a `testWidgets` case that installs the watcher + // fails on ITSELF before either runs. The `tearDown` below is a net for the + // plain `test()` cases, which have no invariant check. + late void Function(String?, {int? wrapWidth}) originalDebugPrint; + + /// Puts the global `debugPrint` back, from inside the test body. + void restoreDebugPrint() => debugPrint = originalDebugPrint; + + setUp(() { + originalDebugPrint = debugPrint; + MagicApp.reset(); + Magic.flush(); + MagicRouter.reset(); + MagicPerfIntegration.resetForTesting(); + TelescopeStore.resetForTesting(); + }); + + tearDown(() { + MagicPerfIntegration.resetForTesting(); + MagicRouter.reset(); + TelescopeStore.resetForTesting(); + WindPerfCounters.enabled = false; + WindPerfCounters.reset(); + debugPrint = originalDebugPrint; + }); + + group('MagicPerfIntegration.install', () { + test('registers exactly one observer and one watcher when called twice', () { + MagicPerfIntegration.install(); + MagicPerfIntegration.install(); + + expect(MagicPerfIntegration.isInstalled, isTrue); + expect(MagicRouter.instance.observers, hasLength(1)); + + // TelescopePlugin keeps its watcher list private, so the watcher count is + // asserted through its only observable effect: a second FramePerfWatcher + // would add a second timings callback and record the same frame twice. + _fireTimings([_timing(frameNumber: 7)]); + expect(TelescopeStore.recentFramePerf(), hasLength(1)); + }); + + test('attributes notify counts to each controller runtime type', () { + MagicPerfIntegration.install(); + + final _AlphaController alpha = _AlphaController(); + final _BetaController beta = _BetaController(); + alpha.refreshUI(); + alpha.refreshUI(); + beta.refreshUI(); + + expect(MagicPerfIntegration.controllerNotifyCounts, { + '_AlphaController': 2, + '_BetaController': 1, + }); + }); + + testWidgets('surfaces the StateError when the router is already built', ( + WidgetTester tester, + ) async { + MagicRoute.page('/', () => const SizedBox()); + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + // A swallowed StateError would leave the report with no route + // transitions and no explanation for why. + expect(MagicPerfIntegration.install, throwsStateError); + expect(MagicPerfIntegration.isInstalled, isFalse); + }); + }); + + group('the dusk pointers', () { + test('framePerfReader returns the recorded frames and the counter', () { + TelescopeStore.recordFramePerf(_frameRecord(11)); + TelescopeStore.recordFramePerf(_frameRecord(12)); + + MagicPerfIntegration.install(); + + final Map payload = framePerfReader(); + expect( + payload.keys, + unorderedEquals(['frames', 'livenessCounter']), + ); + + final List frames = payload['frames']! as List; + expect(frames, hasLength(2)); + expect( + frames.cast>().map( + (Map f) => f['frameNumber'], + ), + [11, 12], + ); + expect(payload['livenessCounter'], isA()); + }); + + test('perfExtrasReader returns the notify counts', () { + MagicPerfIntegration.install(); + _AlphaController().refreshUI(); + + final Map payload = perfExtrasReader(); + expect( + payload.keys, + unorderedEquals(['controllerNotifies', 'routeTransitions']), + ); + expect(payload['controllerNotifies'], { + '_AlphaController': 1, + }); + expect(payload['routeTransitions'], isEmpty); + }); + + testWidgets('perfExtrasReader carries a named, timed route transition', ( + WidgetTester tester, + ) async { + MagicRoute.page('/', () => const SizedBox()); + MagicRoute.page('/monitors', () => const SizedBox()); + + // Before the router is built, which is the whole reason the observer + // registration lives in installPre(). + MagicPerfIntegration.install(); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + MagicRouter.instance.to('/monitors'); + await tester.pumpAndSettle(); + + final List transitions = + perfExtrasReader()['routeTransitions']! as List; + expect(transitions, isNotEmpty); + + final Map last = + transitions.last! as Map; + expect(last['route'], '/monitors'); + expect(last['durationMicros'], isA()); + expect(last['durationMicros']! as int, greaterThanOrEqualTo(0)); + }); + + testWidgets('perfSessionBeginHook clears only the perf state', ( + WidgetTester tester, + ) async { + WindPerfCounters.enabled = true; + await _warmTheParseCache(tester); + await _buildOneWidget(tester); + // A non-zero before the hook runs is the whole point: asserting zero + // afterwards proves nothing if it was already zero, which is what this + // case did while it happened to run first. + expect(WindPerfCounters.cacheHits, greaterThan(0)); + TelescopeStore.recordFramePerf(_frameRecord(3)); + TelescopeStore.recordDump( + DumpRecord(message: 'sibling buffer', time: DateTime(2026, 8, 25)), + ); + + MagicPerfIntegration.install(); + _AlphaController().refreshUI(); + perfSessionBeginHook(); + + expect(WindPerfCounters.cacheHits, 0); + expect(TelescopeStore.recentFramePerf(), isEmpty); + expect(MagicPerfIntegration.controllerNotifyCounts, isEmpty); + // TelescopeStore.clear() would have taken this with it, which is why the + // hook calls clearFramePerf() instead. + expect( + TelescopeStore.recentDumps().map((DumpRecord r) => r.message), + contains('sibling buffer'), + ); + }); + + testWidgets('the session pair turns wind counting on and back off', ( + WidgetTester tester, + ) async { + // Warmed here rather than inherited from whichever test ran before, so + // the build below is a hit whatever the order or the shuffle seed. + await _warmTheParseCache(tester); + MagicPerfIntegration.install(); + expect(WindPerfCounters.enabled, isFalse); + + perfSessionBeginHook(); + + // Zeroing without enabling would report a wind section of all zeros + // beside populated frame and magic sections, with no error to say why. + expect(WindPerfCounters.enabled, isTrue); + expect(WindPerfCounters.cacheHits, 0); + + await _buildOneWidget(tester); + perfSessionEndHook(); + + // The end hook stops the counting but leaves the totals alone, because + // `perf_end` reads them to build its report. + expect(WindPerfCounters.enabled, isFalse); + expect(WindPerfCounters.cacheHits, 1); + }); + }); + + group('MagicPerfIntegration.resetForTesting', () { + test('restores the hook, the counters and all four pointers', () { + MagicPerfIntegration.install(); + _AlphaController().refreshUI(); + perfSessionBeginHook(); + TelescopeStore.recordFramePerf(_frameRecord(5)); + + MagicPerfIntegration.resetForTesting(); + + expect(MagicPerfIntegration.isInstalled, isFalse); + expect(MagicController.onRefreshUI, isNull); + expect(MagicPerfIntegration.controllerNotifyCounts, isEmpty); + // A reset that left counting on would tax every later test in the suite. + expect(WindPerfCounters.enabled, isFalse); + expect(framePerfReader(), { + 'frames': >[], + 'livenessCounter': 0, + }); + expect(perfExtrasReader(), { + 'controllerNotifies': {}, + 'routeTransitions': >[], + }); + + // The restored hooks are no-ops: the frame buffer survives the begin + // hook and counting stays off after the end hook. + perfSessionBeginHook(); + perfSessionEndHook(); + expect(TelescopeStore.recentFramePerf(), hasLength(1)); + expect(WindPerfCounters.enabled, isFalse); + }); + }); + + group('MagicDevtools.installPre', () { + test('installs the perf integration before the router is built', () { + MagicDevtools.installPre(); + // A net, not the mechanism. The restore that matters is the inline call + // at the end of this body, for the reason on [restoreDebugPrint]; this + // case is a plain `test()` today, so a tearDown would serve either way, + // but the day it becomes a `testWidgets` it would fail on itself before + // any tearDown runs. + // + // If you do convert it, note that `installPre()` also leaves a + // SemanticsHandle active (dusk's snapshot pipeline enables semantics), + // which is a second end-of-test invariant and needs its own dispose. + // Measured by converting this case as a probe. + addTearDown(restoreDebugPrint); + + expect(MagicPerfIntegration.isInstalled, isTrue); + expect(MagicRouter.instance.observers, hasLength(1)); + restoreDebugPrint(); + }); + }); + group('route transitions', () { + test('an anonymous push is not recorded', () { + // showDialog and showModalBottomSheet push unnamed routes through the + // same navigator. Recording them would let a dialog-heavy session evict + // the real page transitions out of the bounded list the report ranks. + MagicPerfIntegration.install(); + addTearDown(MagicPerfIntegration.resetForTesting); + + final int before = MagicPerfIntegration.routeTransitions.length; + + MagicRouter.instance.observers.first.didPush( + PageRouteBuilder( + pageBuilder: (_, _, _) => const SizedBox.shrink(), + ), + null, + ); + + // No pump needed and that is the point: an unnamed push returns before + // scheduling the post-frame callback that would close the span, so + // there is nothing in flight to wait for. + expect(MagicPerfIntegration.routeTransitions, hasLength(before)); + }); + }); +}