diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6cf1fc..a344f36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,38 @@ compare by name, a custom detector can name its own, and a reader keeps a unit it does not know by name instead of dropping the measurement. - `SimpleStructuralDetector.report` takes an optional `measurement`. +- Snapshots and `ext.sleuth.issues` carry `notUsefulMarks`, the issues + marked Not useful in the overlay this session (oldest first, at most + 200), each with the issue's key, title, category, severity, confidence, + measurement, route, `markedAt` and whether it is still reported + (`live`). Like `suppressedCount` the key is metadata: written only when + there is a mark, whatever the projection, and never a `SnapshotSection`. + `SessionSnapshot.notUsefulMarks` and `NotUsefulMark` read it back. + `doc/mcp_schema.{json,md}` document it and the issue `measurement` + shape, and the sidecar lists it in `snapshotMetadataKeys`. +- `WidgetHighlight.occurrenceId`: the identity of the element a highlight + was measured from, equal to the `occurrenceId` of its issue. +- `DebugSnapshot.forcedRebuildsOf` (debug callbacks): for each widget type + a parent's build rebuilt, the types whose builds forced it, framework + widgets included, and how often. `forcedTypesCapped` reports its caps. + +### Changed + +- `FrameVerdict.relatedIssues` lists only the issues that can explain the + verdict's phase: the issues of the phase's category, `event_loop_stall` + for a start delay, and none for a stall or an unknown phase. Jank + symptoms are never listed, so exports carry fewer issues, and the + reason's `Related:` line names an issue of the phase. The overlay's JANK + tag shows only while `jank_detected` or `sustained_jank` is reported. +- Highlighting an issue outlines only its own widget: the element it was + reported for, else the first widget with its name. Another widget of + the same category is no longer outlined in its place. When the widget + is not on screen the card unticks, and no outline appears later. +- Copying the snapshot from the overlay counts as Sleuth's own work, so + it is left out of the next frame's start delay. A second tap during a + copy is ignored. +- A dismissed debug-mode banner stays dismissed when the dashboard is + closed and reopened. ## 0.38.0 diff --git a/doc/mcp_schema.json b/doc/mcp_schema.json index 74cbca15..75d224b2 100644 --- a/doc/mcp_schema.json +++ b/doc/mcp_schema.json @@ -79,7 +79,7 @@ "errors": ["arg_invalid_section", "arg_invalid_int", "arg_pagination_unused"], "data": { "_shape_source": "SessionSnapshot.toJson() in lib/src/models/session_snapshot.dart", - "_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount) always serialize.", + "_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount, notUsefulMarks) always serialize.", "_projectedSections": {"type": "List", "required": false, "nullable": false, "presence": "when any projection arg (sections, maxIssueCount or maxRouteCount) was set; holds the SnapshotSection keys in this payload, sorted alphabetically"}, "_projectionLimits": {"type": "Map", "required": false, "nullable": false, "presence": "when maxIssueCount or maxRouteCount was set", "shape": {"maxIssueCount": {"type": "int", "required": false, "nullable": false}, "maxRouteCount": {"type": "int", "required": false, "nullable": false}}}, "_projectionApplied": {"type": "String", "required": false, "nullable": false, "presence": "when any projection arg was set", "values": ["by_app", "by_sidecar_fallback"]}, @@ -92,6 +92,33 @@ "capturedFrames": {"type": "List", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"}, "currentIssues": {"type": "List", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"}, "suppressedCount": {"type": "int", "required": false, "nullable": false, "presence": "only when above 0"}, + "notUsefulMarks": { + "type": "List", + "required": false, + "nullable": false, + "presence": "only when an issue was marked Not useful in the overlay this session", + "doc": "Session-only marks, oldest first, at most 200. Like suppressedCount it is written whatever the projection.", + "item_shape": { + "key": {"type": "String", "required": true, "nullable": false, "doc": "The overlay list key: stableId (else the title), plus |widgetName when the issue names a widget."}, + "stableId": {"type": "String", "required": false, "nullable": false, "presence": "when the issue has one"}, + "title": {"type": "String", "required": true, "nullable": false}, + "category": {"type": "String", "required": true, "nullable": false, "values": ["build", "layout", "paint", "raster", "memory", "channel", "font", "network", "startup"]}, + "severity": {"type": "String", "required": true, "nullable": false, "values": ["ok", "warning", "critical"]}, + "confidence": {"type": "String", "required": true, "nullable": false, "values": ["confirmed", "likely", "possible"]}, + "measurement": {"type": "Map", "required": false, "nullable": false, "presence": "when the issue had a measurement with a finite value", "doc": "The same shape as a currentIssues item's measurement key.", "shape": { + "value": {"type": "num", "required": true, "nullable": false}, + "unit": {"type": "String", "required": true, "nullable": false, "doc": "milliseconds, seconds, percent, bytes, bytesPerSecond, count, perSecond, perMinute or frames. A reader keeps a unit it does not know by name."}, + "warningAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a warning line"}, + "criticalAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a critical line"}, + "windowMs": {"type": "num", "required": false, "nullable": false, "presence": "when the value was counted over a window; a fraction keeps microseconds"}, + "warningStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with warningAt, when a value must exceed it"}, + "criticalStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with criticalAt, when a value must exceed it"} + }}, + "route": {"type": "String", "required": false, "nullable": false, "presence": "when the issue carried a route"}, + "markedAt": {"type": "String (ISO-8601)", "required": true, "nullable": false}, + "live": {"type": "bool", "required": true, "nullable": false, "doc": "Whether the issue was still reported when the payload was built."} + } + }, "recentRequests": {"type": "List", "required": false, "nullable": false, "presence": "when NetworkMonitorDetector is enabled and the request ring buffer is non-empty"}, "heapSamples": {"type": "List", "required": false, "nullable": false, "presence": "when MemoryPressureDetector has at least one sample buffered"}, "phaseEvents": {"type": "List", "required": false, "nullable": false, "presence": "when the controller's rolling timeline-event buffer is non-empty"}, @@ -222,7 +249,8 @@ "data": { "issues": {"type": "List", "required": true, "nullable": false, "item_shape": "PerformanceIssue.toJson()"}, "route": {"type": "String", "required": false, "nullable": false, "presence": "only when the route arg was non-empty"}, - "vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."} + "vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."}, + "notUsefulMarks": {"type": "List", "required": false, "nullable": false, "presence": "only when an issue was marked Not useful in the overlay this session", "doc": "Every mark, whatever the route arg.", "item_shape": "see ext.sleuth.snapshot.data.notUsefulMarks item_shape"} } }, "ext.sleuth.routeHealth": { diff --git a/doc/mcp_schema.md b/doc/mcp_schema.md index e7ce4613..3f7db382 100644 --- a/doc/mcp_schema.md +++ b/doc/mcp_schema.md @@ -108,7 +108,7 @@ The handler returns `SessionSnapshot.toJson()`, defined in `lib/src/models/sessi | `maxIssueCount` | String (int) | keeps the top N already-ranked `currentIssues`. A negative or non-numeric value returns `arg_invalid_int`. Setting it while `sections` leaves out `currentIssues` returns `arg_pagination_unused`. | | `maxRouteCount` | String (int) | keeps the N most recent `routeSessions` by `startedAt`. It returns the same errors as `maxIssueCount`, with `routeSessions` in place of `currentIssues`. | -When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`) always serialize. +When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`, `notUsefulMarks`) always serialize. | `data` key | Type | Required | Presence | |---|---|---|---| @@ -124,6 +124,7 @@ When any projection arg is set, a payload field is present only if its section i | `capturedFrames` | List\ | yes | always, unless sections leaves it out | | `currentIssues` | List\ | yes | always, unless sections leaves it out | | `suppressedCount` | int | no | only when above 0 | +| `notUsefulMarks` | List\ | no | only when an issue was marked Not useful in the overlay this session. Session-only marks, oldest first, at most 200; item shape below. | | `recentRequests` | List\ | no | when NetworkMonitorDetector is enabled and the request ring buffer is non-empty | | `heapSamples` | List\ | no | when MemoryPressureDetector has at least one sample buffered | | `phaseEvents` | List\ | no | when the controller's rolling timeline-event buffer is non-empty | @@ -136,6 +137,33 @@ When any projection arg is set, a payload field is present only if its section i | `startupMetrics` | Map | no | when `Sleuth.init` captured first-frame data | | `routeSessions` | List\ | no | when the route history is not empty | +#### `notUsefulMarks[]` item shape + +| Key | Type | Required | Presence | +|---|---|---|---| +| `key` | String | yes | always; the overlay list key: `stableId` (else the title), plus `\|widgetName` when the issue names a widget | +| `stableId` | String | no | when the issue has one | +| `title` | String | yes | always | +| `category`, `severity`, `confidence` | String | yes | always; the enum names | +| `measurement` | Map | no | when the issue had a measurement with a finite value; the same shape as a `currentIssues` item's `measurement`, below | +| `route` | String | no | when the issue carried a route | +| `markedAt` | String (ISO-8601) | yes | always | +| `live` | bool | yes | always; whether the issue was still reported when the payload was built | + +#### Issue `measurement` sub-shape + +An issue item (`currentIssues[]`, `ext.sleuth.issues` `issues[]`) carries `measurement` when its detector reported a number: the value it measured and its warning and critical lines. The issue's `severity` is the detector's and can disagree with where the value sits against the lines, in either direction. + +| Key | Type | Required | Presence | +|---|---|---|---| +| `value` | num | yes | always | +| `unit` | String | yes | always; `milliseconds`, `seconds`, `percent`, `bytes`, `bytesPerSecond`, `count`, `perSecond`, `perMinute` or `frames`. A reader keeps a unit it does not know by name. | +| `warningAt` | num | no | when the detector has a warning line | +| `criticalAt` | num | no | when the detector has a critical line | +| `windowMs` | num | no | when the value was counted over a window; a fraction keeps microseconds | +| `warningStrict` | bool | no | only with `warningAt`, when a value must exceed it; always `true` when present | +| `criticalStrict` | bool | no | only with `criticalAt`, when a value must exceed it; always `true` when present | + #### `recurrenceTrends.` sub-shape | Key | Type | Required | Presence | @@ -216,6 +244,7 @@ Returns the current aggregated issues. It takes an optional `route` (String). Wi | `issues` | List\ | yes | always; each item is `PerformanceIssue.toJson()` | | `route` | String | no | only when the route arg was non-empty | | `vmConnected` | bool | yes | always; the same flag as `ext.sleuth.diagnose` `data.vmConnected`, which tells a VM-connected `basic` session from one without a VM link | +| `notUsefulMarks` | List\ | no | only when an issue was marked Not useful in the overlay this session; every mark, whatever the `route` arg. The item shape is `ext.sleuth.snapshot` `notUsefulMarks[]`. | ### `ext.sleuth.routeHealth` diff --git a/lib/sleuth.dart b/lib/sleuth.dart index 33517baa..4145b477 100644 --- a/lib/sleuth.dart +++ b/lib/sleuth.dart @@ -92,6 +92,7 @@ export 'src/models/frame_budget.dart'; export 'src/models/frame_verdict.dart'; export 'src/models/widget_highlight.dart'; export 'src/models/capture_buffer.dart'; +export 'src/models/not_useful_mark.dart' show NotUsefulMark; export 'src/models/session_snapshot.dart'; export 'src/models/snapshot_sections.dart'; export 'src/controller/sleuth_controller.dart' show SleuthConfig; @@ -678,7 +679,7 @@ class Sleuth { /// [resource]. static void trackResource(String name, Object resource) { if (kReleaseMode) return; - _controller?.trackedResourceDetector?.track(name, resource); + _controller?.trackResource(name, resource); } /// Best-effort decrement: removes a registration whose target is diff --git a/lib/src/analyzer/jank_relation.dart b/lib/src/analyzer/jank_relation.dart new file mode 100644 index 00000000..7da156b0 --- /dev/null +++ b/lib/src/analyzer/jank_relation.dart @@ -0,0 +1,39 @@ +import '../models/frame_verdict.dart'; +import '../models/performance_issue.dart'; + +/// Whether [issue] reports late frames themselves rather than a cause. +bool isJankSymptom(PerformanceIssue issue) => + issue.stableId == 'jank_detected' || issue.stableId == 'sustained_jank'; + +/// The issues in [issues] that can explain a frame that missed its +/// deadline in [phase]. +/// +/// A build, layout, paint or raster phase takes the issues of the same +/// category. A start delay is explained only by `event_loop_stall`. +/// `pipelineStall` and `unknown` name no phase work, so they take none. +/// Jank symptoms are never a cause. +List relatedToPhase( + PipelinePhase phase, + List issues, +) { + final category = switch (phase) { + PipelinePhase.build => IssueCategory.build, + PipelinePhase.layout => IssueCategory.layout, + PipelinePhase.paint => IssueCategory.paint, + PipelinePhase.raster => IssueCategory.raster, + PipelinePhase.schedulerDelay || + PipelinePhase.pipelineStall || + PipelinePhase.unknown => null, + }; + if (phase == PipelinePhase.schedulerDelay) { + return [ + for (final i in issues) + if (i.stableId == 'event_loop_stall') i, + ]; + } + if (category == null) return const []; + return [ + for (final i in issues) + if (i.category == category && !isJankSymptom(i)) i, + ]; +} diff --git a/lib/src/analyzer/render_pipeline_analyzer.dart b/lib/src/analyzer/render_pipeline_analyzer.dart index 82664e29..58fa9227 100644 --- a/lib/src/analyzer/render_pipeline_analyzer.dart +++ b/lib/src/analyzer/render_pipeline_analyzer.dart @@ -1,4 +1,5 @@ import 'frame_event_correlator.dart'; +import 'jank_relation.dart'; import '../models/frame_stats.dart'; import '../models/frame_verdict.dart'; import '../models/performance_issue.dart'; @@ -33,6 +34,7 @@ class RenderPipelineAnalyzer { } else { suspected = uiUs > rasterUs ? PipelinePhase.build : PipelinePhase.raster; } + final related = relatedToPhase(suspected, relatedIssues); _lastVerdict = FrameVerdict( frameNumber: frameStats.frameNumber, @@ -45,7 +47,7 @@ class RenderPipelineAnalyzer { rasterThreadTime: frameStats.rasterDuration, suspectedPhase: suspected, reason: _buildBasicReason(frameStats), - relatedIssues: relatedIssues, + relatedIssues: related, isFullMode: false, ); @@ -128,6 +130,7 @@ class RenderPipelineAnalyzer { PipelinePhase.layout: layoutUs, PipelinePhase.paint: paintUs, }); + final related = relatedToPhase(suspected, relatedIssues); // Build reason string final reason = _buildFullReason( @@ -137,7 +140,7 @@ class RenderPipelineAnalyzer { paintUs: paintUs, rasterUs: rasterUs, frameStats: frameStats, - relatedIssues: relatedIssues, + relatedIssues: related, ); _lastVerdict = FrameVerdict( @@ -154,7 +157,7 @@ class RenderPipelineAnalyzer { flushPaintTime: Duration(microseconds: paintUs), suspectedPhase: suspected, reason: reason, - relatedIssues: relatedIssues, + relatedIssues: related, isFullMode: true, ); @@ -184,6 +187,7 @@ class RenderPipelineAnalyzer { PipelinePhase.layout: layoutUs, PipelinePhase.paint: paintUs, }); + final related = relatedToPhase(suspected, relatedIssues); final reason = _buildCorrelatedReason( suspected: suspected, @@ -193,7 +197,7 @@ class RenderPipelineAnalyzer { rasterUs: rasterUs, frameStats: frameStats, correlation: correlation, - relatedIssues: relatedIssues, + relatedIssues: related, ); _lastVerdict = FrameVerdict( @@ -210,7 +214,7 @@ class RenderPipelineAnalyzer { flushPaintTime: Duration(microseconds: paintUs), suspectedPhase: suspected, reason: reason, - relatedIssues: relatedIssues, + relatedIssues: related, isFullMode: true, isCorrelated: true, correlationCoverage: correlation.batchCoverageRatio, diff --git a/lib/src/controller/coverage_report.dart b/lib/src/controller/coverage_report.dart new file mode 100644 index 00000000..767dd75a --- /dev/null +++ b/lib/src/controller/coverage_report.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; + +import '../models/base_detector.dart'; + +/// How much of one built-in check can run. +enum CheckCoverage { + /// Every signal the check reads is available. + on, + + /// A hybrid check without the VM: its structural or frame half runs, the + /// VM half does not. + partial, + + /// The check reads only the VM, and the VM is not connected. + needsVm, + + /// The check needs an opt-in that is off: `profilePlatformChannels` for + /// the platform channel check, a `Sleuth.trackResource` call for the + /// tracked resource check. + optInOff, + + /// Left out of `SleuthConfig.enabledDetectors`. + disabled, +} + +/// The coverage of every built-in check for the current VM link and +/// config. Custom detectors are not counted. +@immutable +class CoverageReport { + const CoverageReport(this.byType); + + /// Builds the report from the enabled types, each enabled type's + /// lifecycle, and the signals the checks depend on. + /// + /// A hybrid check without the VM counts as [CheckCoverage.on] when debug + /// callbacks feed it ([debugFeedsRebuild], [debugFeedsRepaint]). + factory CoverageReport.compute({ + required Set enabled, + required DetectorLifecycle? Function(DetectorType type) lifecycleOf, + required bool vmConnected, + required bool profilePlatformChannels, + required bool resourcesTracked, + bool debugFeedsRebuild = false, + bool debugFeedsRepaint = false, + }) { + CheckCoverage coverageOf(DetectorType type) { + if (!enabled.contains(type)) return CheckCoverage.disabled; + switch (type) { + case DetectorType.platformChannel: + if (!vmConnected) return CheckCoverage.needsVm; + return profilePlatformChannels + ? CheckCoverage.on + : CheckCoverage.optInOff; + case DetectorType.trackedResource: + return resourcesTracked ? CheckCoverage.on : CheckCoverage.optInOff; + default: + break; + } + switch (lifecycleOf(type)) { + case DetectorLifecycle.vmOnly: + return vmConnected ? CheckCoverage.on : CheckCoverage.needsVm; + case DetectorLifecycle.hybrid: + if (vmConnected) return CheckCoverage.on; + final fed = + (type == DetectorType.rebuild && debugFeedsRebuild) || + (type == DetectorType.repaint && debugFeedsRepaint); + return fed ? CheckCoverage.on : CheckCoverage.partial; + case DetectorLifecycle.runtime: + case DetectorLifecycle.structural: + case null: + return CheckCoverage.on; + } + } + + return CoverageReport({ + for (final type in DetectorType.values) + if (type != DetectorType.custom) type: coverageOf(type), + }); + } + + /// Coverage of each built-in check. + final Map byType; + + /// Number of checks with [coverage]. + int count(CheckCoverage coverage) => + byType.values.where((c) => c == coverage).length; + + /// The checks with [coverage], in [DetectorType] order. + List typesWith(CheckCoverage coverage) => [ + for (final e in byType.entries) + if (e.value == coverage) e.key, + ]; + + @override + bool operator ==(Object other) => + other is CoverageReport && mapEquals(byType, other.byType); + + @override + int get hashCode => Object.hashAllUnordered( + byType.entries.map((e) => Object.hash(e.key, e.value)), + ); + + @override + String toString() => + 'CoverageReport(on: ${count(CheckCoverage.on)}, ' + 'partial: ${count(CheckCoverage.partial)}, ' + 'needsVm: ${count(CheckCoverage.needsVm)}, ' + 'optInOff: ${count(CheckCoverage.optInOff)}, ' + 'disabled: ${count(CheckCoverage.disabled)})'; +} diff --git a/lib/src/controller/highlight_lookup.dart b/lib/src/controller/highlight_lookup.dart new file mode 100644 index 00000000..0b71e925 --- /dev/null +++ b/lib/src/controller/highlight_lookup.dart @@ -0,0 +1,13 @@ +/// What a highlight lookup for an issue found. +enum HighlightLookup { + /// A highlight of the issue is selected. + found, + + /// The last scan has no highlight of the issue, so its widget is not on + /// screen. + notFound, + + /// No tree scan has run yet. The lookup runs again after the first scan + /// and reports [found] or [notFound] then. + pending, +} diff --git a/lib/src/controller/run_state.dart b/lib/src/controller/run_state.dart new file mode 100644 index 00000000..0f581ce7 --- /dev/null +++ b/lib/src/controller/run_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +/// Where a Sleuth session is in its start-up. +enum SleuthRunPhase { + /// The first VM connect attempt has not resolved yet. + starting, + + /// Inside the frame-timing warm-up after start, when jank is not judged. + warmingUp, + + /// Past warm-up. + running, +} + +/// The session's start-up phase and the state of its VM link. +@immutable +class SleuthRunState { + const SleuthRunState({ + this.phase = SleuthRunPhase.starting, + this.vmConnected = false, + this.reconnectAttempt, + this.maxReconnectAttempts = 0, + this.stoppedRetrying = false, + }); + + final SleuthRunPhase phase; + + final bool vmConnected; + + /// The background reconnect attempt that is waiting or running, counted + /// from 1, or null when none is. + final int? reconnectAttempt; + + /// How many background reconnect attempts the session makes before it + /// stops. + final int maxReconnectAttempts; + + /// The VM is not connected and the background attempts are used up. + /// A manual reconnect can still succeed. + final bool stoppedRetrying; + + @override + bool operator ==(Object other) => + other is SleuthRunState && + other.phase == phase && + other.vmConnected == vmConnected && + other.reconnectAttempt == reconnectAttempt && + other.maxReconnectAttempts == maxReconnectAttempts && + other.stoppedRetrying == stoppedRetrying; + + @override + int get hashCode => Object.hash( + phase, + vmConnected, + reconnectAttempt, + maxReconnectAttempts, + stoppedRetrying, + ); + + @override + String toString() => + 'SleuthRunState(${phase.name}, vm: $vmConnected, ' + 'attempt: $reconnectAttempt/$maxReconnectAttempts, ' + 'stopped: $stoppedRetrying)'; +} diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index b9336e73..c8aac49f 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -24,7 +24,10 @@ import '../analyzer/frame_event_correlator.dart'; import '../analyzer/render_pipeline_analyzer.dart'; import '../models/ai_chat_adapter.dart'; import '../vm/service_extension_handlers.dart' show kSleuthPackageVersion; +import 'coverage_report.dart'; import 'detector_thresholds.dart'; +import 'highlight_lookup.dart'; +import 'run_state.dart'; import '../debug/debug_instrumentation_config.dart'; import '../debug/debug_instrumentation_coordinator.dart'; import '../debug/debug_snapshot.dart'; @@ -52,6 +55,7 @@ import '../detectors/startup_detector.dart'; import '../../sleuth.dart' show Sleuth; import '../models/allocation_entry.dart'; import '../models/base_detector.dart'; +import '../models/not_useful_mark.dart'; import '../models/gc_event_summary.dart'; import '../models/heap_sample.dart'; import '../models/capture_buffer.dart'; @@ -76,6 +80,7 @@ import '../vm/service_extension_registry.dart'; import '../vm/vm_service_client.dart'; import '../utils/capture_helper.dart'; import '../utils/framework_types.dart' show isScaffoldElement, isTabBarView; +import '../utils/monotonic_clock.dart'; import '../utils/own_work_log.dart'; import '../utils/session_markdown_exporter.dart'; import '../utils/session_uuid.dart'; @@ -109,6 +114,9 @@ class SleuthController { return true; }()); _compileSuppressions(); + // Registered first, so the overlay reads the causes of a verdict when + // it hears of it. + verdictNotifier.addListener(_recordJankCauses); } final SleuthConfig config; @@ -429,9 +437,70 @@ class SleuthController { /// Notifies listeners when a new verdict is available. final ValueNotifier verdictNotifier = ValueNotifier(null); + /// When a verdict last named each issue, by `stableId ?? title`. The + /// overlay tags those issues for [FrameTimingDetector.jankWindow] after + /// that ([heldJankCauses]). Kept here rather than in the card, so closing + /// and reopening the dashboard neither drops a hold nor restarts one. + /// Cleared on a route change, when jank starts a new window. + final Map _jankCauseSeen = {}; + + /// Reads [clockOverrideForTest] in tests, else a monotonic clock, so a + /// wall-clock jump neither ends nor extends a hold. + final DateTime Function() _monotonicNow = monotonicClock(); + DateTime _jankClock() => clockOverrideForTest?.call() ?? _monotonicNow(); + + void _recordJankCauses() { + final verdict = verdictNotifier.value; + if (verdict == null || verdict.relatedIssues.isEmpty) return; + final now = _jankClock(); + for (final issue in verdict.relatedIssues) { + _jankCauseSeen[issue.stableId ?? issue.title] = now; + } + } + + /// The issues a verdict named within [FrameTimingDetector.jankWindow], + /// by `stableId ?? title`, and the time until the first of them leaves + /// (null when none is held). + ({Set keys, Duration? nextExpiry}) heldJankCauses() { + final now = _jankClock(); + final keys = {}; + Duration? nextExpiry; + final expired = []; + for (final MapEntry(:key, :value) in _jankCauseSeen.entries) { + final left = FrameTimingDetector.jankWindow - now.difference(value); + if (left <= Duration.zero) { + expired.add(key); + continue; + } + keys.add(key); + if (nextExpiry == null || left < nextExpiry) nextExpiry = left; + } + expired.forEach(_jankCauseSeen.remove); + return (keys: keys, nextExpiry: nextExpiry); + } + /// Whether VM service is connected (full mode). final ValueNotifier vmConnectedNotifier = ValueNotifier(false); + /// Coverage of the built-in checks. Updated when the VM link changes and + /// when the first resource is tracked. + final ValueNotifier coverageNotifier = ValueNotifier( + const CoverageReport({}), + ); + + /// Start-up phase and VM link state, for the overlay's status line. + final ValueNotifier runStateNotifier = ValueNotifier( + const SleuthRunState(), + ); + + /// Moves [runStateNotifier] out of warm-up. Set only while warming up. + Timer? _warmupTimer; + bool _warmupElapsed = false; + + /// Whether the debug-mode banner was dismissed this session. The + /// controller keeps it so reopening the dashboard does not show it again. + bool debugBannerDismissed = false; + /// Widget highlights for the visual overlay. /// Record bundles a generation counter with the list — the counter increments /// on every content change so that the painter's `shouldRepaint` can compare @@ -511,9 +580,62 @@ class SleuthController { /// Number of issues hidden by the suppression list after the last aggregation. final ValueNotifier suppressedCountNotifier = ValueNotifier(0); - /// Issue waiting to be highlighted after the next tree scan populates - /// the highlights list. Set by the UI when highlights aren't ready yet. - PerformanceIssue? pendingIssueSelection; + /// Most marks [markNotUseful] keeps; a new mark evicts the oldest. + static const int maxNotUsefulMarks = 200; + + /// Session marks by list key, oldest first. + final Map _notUseful = {}; + + /// The issues marked as not useful this session, oldest first. Session + /// only: not written to [overlayUiState] or the state store. + final ValueNotifier> notUsefulNotifier = ValueNotifier( + const [], + ); + + /// Marks [issue] as not useful under its list key, replacing an earlier + /// mark of the same key. + void markNotUseful(PerformanceIssue issue) { + if (_disposed) return; + final key = listKeyFor(issue); + _notUseful.remove(key); + _notUseful[key] = NotUsefulMark.fromIssue( + issue, + key: key, + markedAt: DateTime.now(), + ); + while (_notUseful.length > maxNotUsefulMarks) { + _notUseful.remove(_notUseful.keys.first); + } + notUsefulNotifier.value = List.unmodifiable(_notUseful.values); + } + + /// Removes the mark stored under [key], if any. + void clearNotUseful(String key) { + if (_disposed || _notUseful.remove(key) == null) return; + notUsefulNotifier.value = List.unmodifiable(_notUseful.values); + } + + /// Whether [issue] is marked as not useful. + bool isNotUseful(PerformanceIssue issue) => + _notUseful.containsKey(listKeyFor(issue)); + + /// The marks for an export, each with [NotUsefulMark.live] set from the + /// issues reported now. Null when there is no mark. + List? notUsefulMarksForExport() { + if (_notUseful.isEmpty) return null; + final reported = {for (final i in _latestIssues) listKeyFor(i)}; + return [ + for (final m in _notUseful.values) m.withLive(reported.contains(m.key)), + ]; + } + + /// An issue whose highlight lookup waits for the first tree scan, with + /// the callback that gets the result. + ({PerformanceIssue issue, ValueChanged? onResolved})? + _pendingHighlight; + + /// The issue whose highlight lookup waits for the first tree scan. + PerformanceIssue? get pendingIssueSelection => _pendingHighlight?.issue; // -- Overlay UI state -- @@ -735,60 +857,69 @@ class SleuthController { /// Clear the selected highlight. void clearSelectedHighlight() { selectedHighlightNotifier.value = null; - pendingIssueSelection = null; + _pendingHighlight = null; } - /// Select the best matching highlight for a given issue. - /// Returns true if a match was found. - bool selectHighlightForIssue(PerformanceIssue issue) { - // Eagerly collect highlights if empty — detectors already computed them - // during their last scanTree(), but _collectHighlights() only runs when - // highlightEnabled was true at scan time. On the first checkbox tap, - // highlighting was just enabled so highlights haven't been gathered yet. - if (highlightsNotifier.value.items.isEmpty) { - _collectHighlights(); - } - final highlights = highlightsNotifier.value.items; - if (highlights.isEmpty) return false; + /// Selects the highlight of [issue]: the one measured from the issue's + /// own element when both carry an occurrence identity, else the first + /// with the issue's widget name. A highlight of another widget is never + /// chosen in its place. + /// + /// Before the first tree scan there is nothing to search, so the lookup + /// returns [HighlightLookup.pending] and runs again after that scan, + /// reporting its result to [onResolved]. + HighlightLookup selectHighlightForIssue( + PerformanceIssue issue, { + ValueChanged? onResolved, + }) { + _pendingHighlight = null; + // Detectors computed their highlights in their last scan, but they are + // collected only while highlighting is on, which it may just have + // been switched to. + if (highlightsNotifier.value.items.isEmpty) _collectHighlights(); + if (highlightsNotifier.value.items.isEmpty && scanTickNotifier.value == 0) { + _pendingHighlight = (issue: issue, onResolved: onResolved); + return HighlightLookup.pending; + } + return _selectHighlight(issue); + } - // Try exact match on widgetName first - if (issue.widgetName != null) { - for (final h in highlights) { - if (h.widgetName == issue.widgetName) { - selectedHighlightNotifier.value = h; - highlightEnabledNotifier.value = true; - return true; - } - } - } + /// Runs a lookup that waited for this scan's highlights. + void _resolvePendingHighlight() { + final pending = _pendingHighlight; + if (pending == null) return; + _pendingHighlight = null; + final result = _selectHighlight(pending.issue); + pending.onResolved?.call(result); + } + + HighlightLookup _selectHighlight(PerformanceIssue issue) { + final match = _highlightOf(issue, highlightsNotifier.value.items); + if (match == null) return HighlightLookup.notFound; + selectedHighlightNotifier.value = match; + highlightEnabledNotifier.value = true; + return HighlightLookup.found; + } - // Map issue category to likely detector names - final detectorNames = detectorNamesForCategory(issue.category); - for (final name in detectorNames) { + static WidgetHighlight? _highlightOf( + PerformanceIssue issue, + List highlights, + ) { + final occurrence = issue.occurrenceId; + if (occurrence != null) { + // An issue that names its element takes that element's highlight + // or none: a sibling with the same name belongs to another issue. for (final h in highlights) { - if (h.detectorName == name) { - selectedHighlightNotifier.value = h; - highlightEnabledNotifier.value = true; - return true; - } + if (h.occurrenceId == occurrence) return h; } + return null; } - - return false; - } - - static List detectorNamesForCategory(IssueCategory category) { - return switch (category) { - IssueCategory.layout => ['Layout'], - IssueCategory.build => ['Non-lazy', 'setState', 'Rebuild'], - IssueCategory.paint => ['Painter', 'Repaint'], - IssueCategory.raster => ['GPU'], - IssueCategory.memory => ['Image', 'KeepAlive'], - IssueCategory.channel => [], - IssueCategory.font => [], - IssueCategory.network => [], - IssueCategory.startup => ['Startup'], - }; + final name = issue.widgetName; + if (name == null) return null; + for (final h in highlights) { + if (h.widgetName == name) return h; + } + return null; } /// Whether running in debug mode. @@ -1014,6 +1145,8 @@ class SleuthController { if (!connected && !_disposed) { _scheduleBackgroundReconnect(); } + _refreshCoverage(); + _refreshRunState(); } /// Schedule the next background reconnect attempt with exponential backoff. @@ -1035,10 +1168,12 @@ class SleuthController { // or `flutter attach` started DDS, which keeps the service as its only // client), there is no point retrying forever. if (_backgroundReconnectAttempt >= _backgroundReconnectDelays.length) { + _refreshRunState(); return; } _backgroundReconnectActive = true; + _refreshRunState(); final delay = _backgroundReconnectDelays[_backgroundReconnectAttempt]; _backgroundReconnectTimer?.cancel(); _backgroundReconnectTimer = Timer(delay, () async { @@ -1297,6 +1432,7 @@ class SleuthController { ]; _detectorsReady = true; _reresolveFrameBudget(force: true); + _refreshCoverage(); } // -- Frame budget -- @@ -1490,6 +1626,7 @@ class SleuthController { void initializeDetectorsForTest() { _initializeDetectors(); _installDebugInstrumentation(); + _refreshCoverage(); } /// Inject a detector into the live `_detectors` list so tests can exercise @@ -1541,6 +1678,15 @@ class SleuthController { @visibleForTesting int get backgroundReconnectAttemptForTest => _backgroundReconnectAttempt; + /// Recomputes [runStateNotifier], as the controller does after its own + /// state changes. + @visibleForTesting + void refreshRunStateForTest() => _refreshRunState(); + + /// Whether the warm-up timer behind [runStateNotifier] is running. + @visibleForTesting + bool get warmupTimerActiveForTest => _warmupTimer != null; + /// Whether a background reconnect timer is currently scheduled. @visibleForTesting bool get backgroundReconnectScheduledForTest => @@ -1640,6 +1786,7 @@ class SleuthController { _lastScanContext = context; _runStructuralScans(context); _collectHighlights(); + if (highlightEnabledNotifier.value) _resolvePendingHighlight(); _aggregateIssues(); if (_latestIssues.isEmpty) { _consecutiveCleanScans++; @@ -1751,6 +1898,15 @@ class SleuthController { return _trackedResource; } + /// Tracks [resource] under [name], and refreshes [coverageNotifier] when + /// it is the session's first tracked resource. + void trackResource(String name, Object resource) { + if (!_detectorsReady) return; + final had = _trackedResource.hasTracked; + _trackedResource.track(name, resource); + if (!had && _trackedResource.hasTracked) _refreshCoverage(); + } + /// Capture-pipeline wrapper around /// [StreamResourceDetector.pollAllocationProfileNow]. Triggers an /// allocation-profile poll AND immediately records any newly-emitted @@ -2006,6 +2162,7 @@ class SleuthController { sessionSummary: summary.isNotEmpty ? summary : null, startupMetrics: Sleuth.startupMetrics, routeSessions: routeSessions, + notUsefulMarks: notUsefulMarksForExport(), ); } @@ -2987,6 +3144,7 @@ class SleuthController { // never reach the new session. Raster-dominant frames from a // previous route likewise stop counting. _frameTiming.markRouteEpoch(); + _jankCauseSeen.clear(); if (active != null) { for (final d in _detectors) { if (d is GpuPressureDetector) d.markRouteEpoch(); @@ -3058,11 +3216,7 @@ class SleuthController { if (highlightEnabledNotifier.value) { _collectHighlights(); - // Fulfill pending issue selection now that highlights are populated - if (pendingIssueSelection != null) { - selectHighlightForIssue(pendingIssueSelection!); - pendingIssueSelection = null; - } + _resolvePendingHighlight(); } else if (highlightsNotifier.value.items.isNotEmpty) { _highlightGeneration++; highlightsNotifier.value = (generation: _highlightGeneration, items: []); @@ -3440,6 +3594,7 @@ class SleuthController { detectorName: h.detectorName, detail: h.detail, renderObject: ro, + occurrenceId: h.occurrenceId, ), ); } @@ -3458,10 +3613,17 @@ class SleuthController { highlightsNotifier.value = (generation: _highlightGeneration, items: items); final selected = selectedHighlightNotifier.value; if (selected != null) { + // A selection with an element identity follows that element only, so + // the outline neither jumps to a sibling of the same type nor moves + // to one when the element goes. + final occurrence = selected.occurrenceId; WidgetHighlight? refreshed; for (final h in items) { - if (h.detectorName == selected.detectorName && - h.widgetName == selected.widgetName) { + final same = occurrence != null + ? h.occurrenceId == occurrence + : h.detectorName == selected.detectorName && + h.widgetName == selected.widgetName; + if (same) { refreshed = h; break; } @@ -4754,6 +4916,58 @@ class SleuthController { for (final d in _detectors) { d.vmConnected = connected; } + _refreshCoverage(); + _refreshRunState(); + } + + void _refreshCoverage() { + if (!_detectorsReady || _disposed) return; + final lifecycles = {for (final d in _detectors) d.type: d.lifecycle}; + final debug = kDebugMode ? _debugCoordinator : null; + coverageNotifier.value = CoverageReport.compute( + enabled: config.enabledDetectors, + lifecycleOf: (type) => lifecycles[type], + vmConnected: isVmConnected, + profilePlatformChannels: config.profilePlatformChannels, + resourcesTracked: _trackedResource.hasTracked, + debugFeedsRebuild: debug?.isRebuildInstalled ?? false, + debugFeedsRepaint: debug?.isPaintInstalled ?? false, + ); + } + + void _refreshRunState() { + if (_disposed) return; + final initAt = _initializedAt; + var phase = SleuthRunPhase.starting; + if (initAt != null) { + final remaining = + config.frameTimingWarmupDuration - DateTime.now().difference(initAt); + if (_warmupElapsed || remaining <= Duration.zero) { + phase = SleuthRunPhase.running; + } else { + phase = SleuthRunPhase.warmingUp; + _warmupTimer ??= Timer(remaining, () { + _warmupTimer = null; + _warmupElapsed = true; + _refreshRunState(); + }); + } + } + final connected = isVmConnected; + final attempts = _backgroundReconnectDelays.length; + runStateNotifier.value = SleuthRunState( + phase: phase, + vmConnected: connected, + reconnectAttempt: !connected && _backgroundReconnectActive + ? _backgroundReconnectAttempt + 1 + : null, + maxReconnectAttempts: attempts, + stoppedRetrying: + !connected && + initAt != null && + !_backgroundReconnectActive && + _backgroundReconnectAttempt >= attempts, + ); } String? _currentRouteName() { @@ -5481,6 +5695,8 @@ class SleuthController { _backgroundReconnectTimer?.cancel(); _backgroundReconnectTimer = null; _backgroundReconnectActive = false; + _warmupTimer?.cancel(); + _warmupTimer = null; _overlayContext = null; _lastScanContext = null; _appChildContext = null; @@ -5541,10 +5757,14 @@ class SleuthController { frameStatsNotifier.dispose(); verdictNotifier.dispose(); vmConnectedNotifier.dispose(); + coverageNotifier.dispose(); + runStateNotifier.dispose(); highlightsNotifier.dispose(); highlightEnabledNotifier.dispose(); selectedHighlightNotifier.dispose(); suppressedCountNotifier.dispose(); + _notUseful.clear(); + notUsefulNotifier.dispose(); routeHistoryNotifier.dispose(); overlayUiState.dispose(); aiChatHistories.clear(); diff --git a/lib/src/debug/debug_instrumentation_coordinator.dart b/lib/src/debug/debug_instrumentation_coordinator.dart index 9cbb3786..93f7348c 100644 --- a/lib/src/debug/debug_instrumentation_coordinator.dart +++ b/lib/src/debug/debug_instrumentation_coordinator.dart @@ -117,17 +117,26 @@ class DebugInstrumentationCoordinator { final Map _paintCounts = {}; final Map _ancestorChains = {}; final Map _forcedRebuildsByRoot = {}; + + /// Forced type, then forcing type, then count. Keyed by [Type] so the + /// rebuild hook does no name lookup; [snapshot] names them. + final Map> _forcedRebuildsOf = {}; + bool _forcedTypesCapped = false; bool _rebuildTypesCapped = false; bool _paintTypesCapped = false; int _paintCount = 0; /// The self-dirtied elements whose builds are running, outermost first, - /// as (user type or null, depth). Callbacks arrive in tree pre-order - /// inside a build, so an entry at or below a callback's depth is not its - /// ancestor and is dropped. A forced rebuild is credited to the - /// innermost entry; a framework widget's entry (null type) credits - /// nothing. Cleared when a new frame starts. - final List<({String? type, int depth})> _buildRoots = []; + /// as (user type name or null, widget type, depth). Callbacks arrive in + /// tree pre-order inside a build, so an entry at or below a callback's + /// depth is not its ancestor and is dropped. A forced rebuild is + /// credited to the innermost entry: in [_forcedRebuildsByRoot] when it + /// is a user widget, in [_forcedRebuildsOf] either way. Cleared when a + /// new frame starts. + final List<({String? type, Type widgetType, int depth})> _buildRoots = []; + + /// Most forcing types [_forcedRebuildsOf] keeps per forced type. + static const int _maxForcingTypes = 8; int? _buildRootsFrame; /// Frame (system time stamp in microseconds) in which each @@ -458,6 +467,8 @@ class DebugInstrumentationCoordinator { ? RebuildCountSource.debugCallback : RebuildCountSource.none, forcedRebuildsByRoot: Map.of(_forcedRebuildsByRoot), + forcedRebuildsOf: _namedForcedRebuilds(), + forcedTypesCapped: _forcedTypesCapped, rebuildTypesCapped: _rebuildTypesCapped, paintTypesCapped: _paintTypesCapped, paintOrigins: _originStats(), @@ -483,6 +494,8 @@ class DebugInstrumentationCoordinator { _ancestorChains.clear(); _animationOwnedPaintCounts.clear(); _forcedRebuildsByRoot.clear(); + _forcedRebuildsOf.clear(); + _forcedTypesCapped = false; _originCounts.clear(); _ownedOriginCounts.clear(); _rebuildTypesCapped = false; @@ -865,7 +878,13 @@ class DebugInstrumentationCoordinator { _ownerRebuildFrame[element] = frame; } if (!_isUserWidget(element)) { - if (dirty) _buildRoots.add((type: null, depth: depth)); + if (dirty) { + _buildRoots.add(( + type: null, + widgetType: widget.runtimeType, + depth: depth, + )); + } return; } // First observation of this element = initial build (don't count). @@ -882,14 +901,22 @@ class DebugInstrumentationCoordinator { if (!dirty) { // Rebuilt because its parent's build passed it a new widget: part // of that parent's cost, not a rebuild of its own. - final root = _buildRoots.isEmpty ? null : _buildRoots.last.type; - if (root != null) { - _forcedRebuildsByRoot[root] = (_forcedRebuildsByRoot[root] ?? 0) + 1; + if (_buildRoots.isEmpty) return; + final root = _buildRoots.last; + final rootType = root.type; + if (rootType != null) { + _forcedRebuildsByRoot[rootType] = + (_forcedRebuildsByRoot[rootType] ?? 0) + 1; } + _recordForcedBy(widget.runtimeType, root.widgetType); return; } final typeName = _typeName(widget.runtimeType); - _buildRoots.add((type: typeName, depth: depth)); + _buildRoots.add(( + type: typeName, + widgetType: widget.runtimeType, + depth: depth, + )); if (_rebuildCounts.length >= _maxTrackedTypes && !_rebuildCounts.containsKey(typeName)) { _rebuildTypesCapped = true; @@ -909,6 +936,36 @@ class DebugInstrumentationCoordinator { } } + void _recordForcedBy(Type forced, Type forcing) { + var roots = _forcedRebuildsOf[forced]; + if (roots == null) { + if (_forcedRebuildsOf.length >= _maxTrackedTypes) { + _forcedTypesCapped = true; + return; + } + roots = _forcedRebuildsOf[forced] = {}; + } + final count = roots[forcing]; + if (count == null && roots.length >= _maxForcingTypes) { + _forcedTypesCapped = true; + return; + } + roots[forcing] = (count ?? 0) + 1; + } + + /// [_forcedRebuildsOf] by type name. Types that share a name are summed. + Map> _namedForcedRebuilds() { + final named = >{}; + for (final forced in _forcedRebuildsOf.entries) { + final roots = named.putIfAbsent(_typeName(forced.key), () => {}); + for (final root in forced.value.entries) { + final name = _typeName(root.key); + roots[name] = (roots[name] ?? 0) + root.value; + } + } + return named; + } + /// The current frame's system time stamp in microseconds, or null /// outside a frame. static int? _frameStamp() { diff --git a/lib/src/debug/debug_snapshot.dart b/lib/src/debug/debug_snapshot.dart index ad68614e..43907909 100644 --- a/lib/src/debug/debug_snapshot.dart +++ b/lib/src/debug/debug_snapshot.dart @@ -30,6 +30,8 @@ class DebugSnapshot { this.totalAnimationOwnedPaintCount = 0, this.source = RebuildCountSource.none, this.forcedRebuildsByRoot = const {}, + this.forcedRebuildsOf = const {}, + this.forcedTypesCapped = false, this.rebuildTypesCapped = false, this.paintTypesCapped = false, this.paintOrigins = const {}, @@ -50,6 +52,17 @@ class DebugSnapshot { /// (debug mode). final Map forcedRebuildsByRoot; + /// Why each widget type was rebuilt by a parent: forced type, then the + /// type of the self-dirtied widget whose build forced it, then the count + /// (debug mode). Unlike [forcedRebuildsByRoot] the forcing widget can be + /// a framework one, such as a `ValueListenableBuilder` whose listenable + /// changed. At most 8 forcing types are kept per forced type. + final Map> forcedRebuildsOf; + + /// Some forced types, or forcing types of one, were left out of + /// [forcedRebuildsOf] by its caps. + final bool forcedTypesCapped; + /// Some widget types were left out of [rebuildCounts] by the type cap, /// so a type missing from it is not known to be idle. final bool rebuildTypesCapped; diff --git a/lib/src/detectors/layout_bottleneck_detector.dart b/lib/src/detectors/layout_bottleneck_detector.dart index a5edeba7..3bfabd88 100644 --- a/lib/src/detectors/layout_bottleneck_detector.dart +++ b/lib/src/detectors/layout_bottleneck_detector.dart @@ -152,6 +152,7 @@ class LayoutBottleneckDetector extends BaseDetector WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(element), widgetName: 'Wrap', severity: childCount > _wrapChildThreshold * 2 ? IssueSeverity.critical diff --git a/lib/src/detectors/listview_detector.dart b/lib/src/detectors/listview_detector.dart index dfe0d231..89001ec7 100644 --- a/lib/src/detectors/listview_detector.dart +++ b/lib/src/detectors/listview_detector.dart @@ -266,6 +266,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(scrollElement), widgetName: widgetName, severity: childCount > childThreshold * criticalChildMultiplier ? IssueSeverity.critical @@ -325,6 +326,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(sliverElement), widgetName: widgetName, severity: childCount > childThreshold * criticalChildMultiplier ? IssueSeverity.critical @@ -404,6 +406,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(scrollElement), widgetName: widgetName, severity: severity, detectorName: 'Non-lazy', @@ -498,6 +501,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(sliverElement), widgetName: 'SliverToBoxAdapter', severity: childCount > childThreshold * criticalChildMultiplier ? IssueSeverity.critical @@ -561,6 +565,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(scrollableElement), widgetName: 'SliverFillRemaining', severity: IssueSeverity.warning, detectorName: 'Sliver Misuse', @@ -617,6 +622,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(scrollableElement), widgetName: 'SliverToBoxAdapter', severity: IssueSeverity.warning, detectorName: 'Eager Sliver', @@ -679,6 +685,7 @@ class ListviewDetector extends BaseDetector with DetectorMetadataProvider { WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(scrollElement), widgetName: 'SingleChildScrollView', severity: directChildCount > diff --git a/lib/src/detectors/repaint_boundary_detector.dart b/lib/src/detectors/repaint_boundary_detector.dart index 462fbe7e..ddac973e 100644 --- a/lib/src/detectors/repaint_boundary_detector.dart +++ b/lib/src/detectors/repaint_boundary_detector.dart @@ -179,6 +179,7 @@ class RepaintBoundaryDetector extends BaseDetector WidgetHighlight( rect: rect, renderObject: ro, + occurrenceId: identityHashCode(element), widgetName: typeNameCache.lookup(element.widget), severity: IssueSeverity.warning, detectorName: 'RepaintBoundary', diff --git a/lib/src/detectors/tracked_resource_detector.dart b/lib/src/detectors/tracked_resource_detector.dart index 20765d92..a39c62b1 100644 --- a/lib/src/detectors/tracked_resource_detector.dart +++ b/lib/src/detectors/tracked_resource_detector.dart @@ -182,6 +182,10 @@ class TrackedResourceDetector extends BaseDetector @visibleForTesting int get droppedTargetsCount => _droppedTargets; + /// Whether a resource was ever registered with [track]. + bool get hasTracked => _hasTracked; + bool _hasTracked = false; + /// Diagnostic: count of name buckets evicted by the LRU cap. @visibleForTesting int get evictedNamesCount => _evictedNames; @@ -315,6 +319,7 @@ class TrackedResourceDetector extends BaseDetector ), ); _finalizer.attach(resource, token, detach: token); + _hasTracked = true; _enforceLruCap(); _ensureSweepRunning(); } diff --git a/lib/src/models/frame_verdict.dart b/lib/src/models/frame_verdict.dart index f2a220e9..80dfdc81 100644 --- a/lib/src/models/frame_verdict.dart +++ b/lib/src/models/frame_verdict.dart @@ -77,7 +77,10 @@ class FrameVerdict { /// Human-readable explanation of the verdict. final String reason; - /// Issues detected that relate to this frame. + /// Issues reported at this frame that can explain [suspectedPhase]: the + /// issues of the phase's category, `event_loop_stall` for a start + /// delay, none for a stall or an unknown phase. Jank symptoms are left + /// out. final List relatedIssues; /// Whether this verdict was generated with full VM Timeline data. diff --git a/lib/src/models/not_useful_mark.dart b/lib/src/models/not_useful_mark.dart new file mode 100644 index 00000000..3d205370 --- /dev/null +++ b/lib/src/models/not_useful_mark.dart @@ -0,0 +1,169 @@ +import 'package:flutter/foundation.dart'; + +import 'issue_measurement.dart'; +import 'performance_issue.dart'; + +/// An issue the user marked as not useful in the overlay this session. +/// +/// Marks live in memory for the session only. Exports and the +/// `ext.sleuth.*` payloads carry them, so whoever reads an export can see +/// which issues the user found unhelpful. +@immutable +final class NotUsefulMark { + const NotUsefulMark({ + required this.key, + this.stableId, + required this.title, + required this.category, + required this.severity, + required this.confidence, + this.measurement, + this.route, + required this.markedAt, + this.live = true, + }); + + /// The mark for [issue] under [key], taken at [markedAt]. + factory NotUsefulMark.fromIssue( + PerformanceIssue issue, { + required String key, + required DateTime markedAt, + }) => NotUsefulMark( + key: key, + stableId: issue.stableId, + title: issue.title, + category: issue.category, + severity: issue.severity, + confidence: issue.confidence, + measurement: issue.measurement, + route: issue.sourceRoute ?? issue.routeName, + markedAt: markedAt, + ); + + /// The overlay's list key of the issue: its stable id, or its title + /// when it has none, plus its widget name. + final String key; + + final String? stableId; + + /// The issue's title when it was marked. + final String title; + + final IssueCategory category; + + final IssueSeverity severity; + + final IssueConfidence confidence; + + /// The issue's measurement when it was marked. + final IssueMeasurement? measurement; + + /// The route the issue was reported on. + final String? route; + + final DateTime markedAt; + + /// Whether the issue was still reported when the mark was read. + final bool live; + + /// A copy with [live] set. + NotUsefulMark withLive(bool live) => live == this.live + ? this + : NotUsefulMark( + key: key, + stableId: stableId, + title: title, + category: category, + severity: severity, + confidence: confidence, + measurement: measurement, + route: route, + markedAt: markedAt, + live: live, + ); + + Map toJson() => { + 'key': key, + 'stableId': ?stableId, + 'title': title, + 'category': category.name, + 'severity': severity.name, + 'confidence': confidence.name, + if (measurement case final m? when m.hasFiniteValue) + 'measurement': m.toJson(), + 'route': ?route, + 'markedAt': markedAt.toIso8601String(), + 'live': live, + }; + + /// Reads a mark written by [toJson], or returns null when a required + /// field is missing or not one this release knows. + static NotUsefulMark? fromJson(Object? json) { + if (json is! Map) return null; + final key = json['key']; + final title = json['title']; + final category = _byName(IssueCategory.values, json['category']); + final severity = _byName(IssueSeverity.values, json['severity']); + final confidence = _byName(IssueConfidence.values, json['confidence']); + final markedAt = json['markedAt'] is String + ? DateTime.tryParse(json['markedAt'] as String) + : null; + if (key is! String || + title is! String || + category == null || + severity == null || + confidence == null || + markedAt == null) { + return null; + } + final stableId = json['stableId']; + final route = json['route']; + return NotUsefulMark( + key: key, + stableId: stableId is String ? stableId : null, + title: title, + category: category, + severity: severity, + confidence: confidence, + measurement: IssueMeasurement.fromJson(json['measurement']), + route: route is String ? route : null, + markedAt: markedAt, + live: json['live'] != false, + ); + } + + static T? _byName(List values, Object? name) { + for (final v in values) { + if (v.name == name) return v; + } + return null; + } + + @override + bool operator ==(Object other) => + other is NotUsefulMark && + other.key == key && + other.stableId == stableId && + other.title == title && + other.category == category && + other.severity == severity && + other.confidence == confidence && + other.measurement == measurement && + other.route == route && + other.markedAt == markedAt && + other.live == live; + + @override + int get hashCode => Object.hash( + key, + stableId, + title, + category, + severity, + confidence, + measurement, + route, + markedAt, + live, + ); +} diff --git a/lib/src/models/session_snapshot.dart b/lib/src/models/session_snapshot.dart index b1cb0ae9..6749f4f2 100644 --- a/lib/src/models/session_snapshot.dart +++ b/lib/src/models/session_snapshot.dart @@ -5,6 +5,7 @@ import 'capture_buffer.dart'; import 'frame_stats.dart'; import 'gc_event_summary.dart'; import 'heap_sample.dart'; +import 'not_useful_mark.dart'; import 'performance_issue.dart'; import 'phase_event.dart'; import 'platform_channel_summary.dart'; @@ -35,6 +36,7 @@ class SessionSnapshot { this.sessionSummary, this.startupMetrics, this.routeSessions, + this.notUsefulMarks, }); /// Schema version for forward-compatible parsing. @@ -121,6 +123,11 @@ class SessionSnapshot { /// Null for v1/v2/v3 snapshots or when no route history exists. final List>? routeSessions; + /// Issues the user marked as not useful this session. Like + /// [suppressedCount] it is written whatever the projection, and only + /// when there is a mark. + final List? notUsefulMarks; + /// Serialize to the JSON envelope. /// /// [include] projects the payload to a subset of [SnapshotSection]s — @@ -184,6 +191,8 @@ class SessionSnapshot { heapSamples!.isNotEmpty) 'heapSamples': heapSamples!.map((s) => s.toJson()).toList(), if (suppressedCount > 0) 'suppressedCount': suppressedCount, + if (notUsefulMarks case final marks? when marks.isNotEmpty) + 'notUsefulMarks': [for (final m in marks) m.toJson()], if (inc(SnapshotSection.phaseEvents) && phaseEvents != null && phaseEvents!.isNotEmpty) @@ -309,6 +318,12 @@ class SessionSnapshot { .map((e) => e as Map) .toList() : null, + notUsefulMarks: json['notUsefulMarks'] is List + ? [ + for (final e in json['notUsefulMarks'] as List) + ?NotUsefulMark.fromJson(e), + ] + : null, ); } } diff --git a/lib/src/models/simple_structural_detector.dart b/lib/src/models/simple_structural_detector.dart index 4a495c3a..38ac31be 100644 --- a/lib/src/models/simple_structural_detector.dart +++ b/lib/src/models/simple_structural_detector.dart @@ -190,6 +190,7 @@ abstract class SimpleStructuralDetector extends BaseDetector { WidgetHighlight( rect: offset & ro.size, renderObject: ro, + occurrenceId: identityHashCode(element), widgetName: element.widget.runtimeType.toString(), severity: severity, detectorName: name, diff --git a/lib/src/models/snapshot_sections.dart b/lib/src/models/snapshot_sections.dart index 378feab3..0c6046e6 100644 --- a/lib/src/models/snapshot_sections.dart +++ b/lib/src/models/snapshot_sections.dart @@ -1,8 +1,9 @@ /// Payload-bearing sections of a [SessionSnapshot] JSON envelope that a /// consumer can opt out of via projection. Metadata keys /// (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, -/// `isDebugMode`, `suppressedCount`) are never projectable — they are -/// small and required for parsing, so they always serialize. +/// `isDebugMode`, `suppressedCount`, `notUsefulMarks`) are never +/// projectable. They are small and required for parsing, so they always +/// serialize. /// /// [jsonKey] MUST match the corresponding key in /// `SessionSnapshot.toJson` byte-for-byte; the snapshot-sections drift @@ -41,8 +42,9 @@ enum SnapshotSection { } /// Envelope keys excluded from projection gating — they serialize - /// independent of `include`. Most always emit; `suppressedCount` is - /// conditional (`> 0`) but is still never gated by a section filter. + /// independent of `include`. Most always emit; `suppressedCount` (above + /// 0) and `notUsefulMarks` (non-empty) are conditional but never gated + /// by a section filter. /// The drift test uses this set to skip metadata in the /// "every payload key has a section" check. static const Set alwaysOnKeys = { @@ -52,5 +54,6 @@ enum SnapshotSection { 'isVmConnected', 'isDebugMode', 'suppressedCount', + 'notUsefulMarks', }; } diff --git a/lib/src/models/widget_highlight.dart b/lib/src/models/widget_highlight.dart index 48d5ddbf..fb5b5f75 100644 --- a/lib/src/models/widget_highlight.dart +++ b/lib/src/models/widget_highlight.dart @@ -14,6 +14,7 @@ class WidgetHighlight { required this.detectorName, this.detail, this.renderObject, + this.occurrenceId, }); /// The widget's bounding box in global (screen) coordinates. @@ -35,4 +36,9 @@ class WidgetHighlight { /// [rect] after a scroll without rescanning the tree. Null for highlights /// built without one; those are dropped by a rect-only refresh. final RenderObject? renderObject; + + /// Identity of the element this highlight was measured from, equal to + /// the [PerformanceIssue.occurrenceId] of the issue it belongs to. Null + /// when the detector gives its issues no element identity. Runtime only. + final int? occurrenceId; } diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index b82157a2..65a870ef 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -7,10 +7,11 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import '../../sleuth.dart' show Sleuth; +import '../analyzer/jank_relation.dart'; +import '../controller/highlight_lookup.dart'; import '../controller/sleuth_controller.dart'; import '../models/performance_issue.dart'; import '../models/frame_stats.dart'; -import '../models/frame_verdict.dart'; import '../models/widget_highlight.dart'; import 'issue_card.dart'; import 'ai_chat_page.dart'; @@ -21,6 +22,7 @@ import 'startup_metrics_page.dart'; import '../models/ai_chat_adapter.dart'; import '../utils/ai_session_context.dart'; import '../utils/issue_explanation_builder.dart'; +import '../utils/own_work_log.dart'; import '../vm/connection_mode.dart'; import 'hidden_issues_page.dart'; import 'motion.dart'; @@ -450,8 +452,10 @@ class _FloatingIssuesCardState extends State /// [SemanticsBinding.semanticsEnabled] it sets the toast timing. bool _accessibleNavigation = false; - bool _debugBannerDismissed = false; bool _showHidden = false; + + /// A snapshot copy is running; another tap is ignored until it ends. + bool _copyInFlight = false; bool _showGuide = false; bool _showDetail = false; bool _showStartupDetail = false; @@ -471,9 +475,12 @@ class _FloatingIssuesCardState extends State Map> get _chatHistories => widget.controller.aiChatHistories; - /// Cached jank-correlated issue keys from verdict, updated via listener. + /// Keys of the reported issues that carry the Jank tag. Set _cachedJankKeys = const {}; + /// Fires when the first held jank cause leaves the controller's hold. + Timer? _jankHoldTimer; + double _cardWidth = _defaultCardWidth; static const double _defaultCardWidth = 300; static const double _minCardWidth = 220; @@ -701,6 +708,7 @@ class _FloatingIssuesCardState extends State timer.cancel(); } _newKeyTimers.clear(); + _jankHoldTimer?.cancel(); _preTransitionOffset = null; _preTransitionWidth = null; _preTransitionHeight = null; @@ -1066,7 +1074,15 @@ class _FloatingIssuesCardState extends State } void _onVerdictChanged() { - final newKeys = _matchingIssueKeys(widget.controller.verdictNotifier.value); + final held = widget.controller.heldJankCauses(); + _jankHoldTimer?.cancel(); + final wait = held.nextExpiry; + _jankHoldTimer = wait == null + ? null + : Timer(wait, () { + if (mounted) _onVerdictChanged(); + }); + final newKeys = _matchingIssueKeys(held.keys); if (!setEquals(newKeys, _cachedJankKeys)) { setState(() => _cachedJankKeys = newKeys); } @@ -1199,32 +1215,46 @@ class _FloatingIssuesCardState extends State ..updateAll((key, _) => positions[key]!); } - /// Stable keys from verdict.relatedIssues that match current issuesNotifier. - Set _matchingIssueKeys(FrameVerdict? verdict) { - if (verdict == null || verdict.relatedIssues.isEmpty) return const {}; - final verdictKeys = { - for (final ri in verdict.relatedIssues) ri.stableId ?? ri.title, - }; - final currentKeys = { - for (final issue in widget.controller.issuesNotifier.value) - issue.stableId ?? issue.title, + /// Keys of the reported issues among [held], the issues a jank verdict + /// named within the jank window. Late frames alternate between phases, + /// so a tag that followed only the latest verdict went on and off. + /// + /// Keys are returned only while a jank symptom is reported, so the tag + /// leaves with the jank cards. The reported list is checked, not the + /// visible one: collapsing, hiding or filtering a symptom keeps the tag. + Set _matchingIssueKeys(Set held) { + if (held.isEmpty) return const {}; + final current = widget.controller.issuesNotifier.value; + if (!current.any(isJankSymptom)) return const {}; + return { + for (final issue in current) + if (held.contains(issue.stableId ?? issue.title)) + issue.stableId ?? issue.title, }; - return verdictKeys.intersection(currentKeys); } Future _exportToClipboard() async { - final json = widget.controller.exportSnapshotJson(); + if (_copyInFlight) return; + _copyInFlight = true; try { - await Clipboard.setData(ClipboardData(text: json)); - } catch (e) { - debugPrint('Sleuth: snapshot copy failed: $e'); - if (mounted) { - _toast.show("Couldn't copy snapshot", tone: OverlayToastTone.warning); + // Building and encoding the snapshot is Sleuth's own work, which the + // jank rule leaves out of the next frame's start delay. + ownWorkLog.recordUntilMicrotask(); + final json = widget.controller.exportSnapshotJson(); + try { + await Clipboard.setData(ClipboardData(text: json)); + } catch (e) { + debugPrint('Sleuth: snapshot copy failed: $e'); + if (mounted) { + _toast.show("Couldn't copy snapshot", tone: OverlayToastTone.warning); + } + return; } - return; + if (!mounted) return; + _toast.show('Snapshot copied to clipboard'); + } finally { + _copyInFlight = false; } - if (!mounted) return; - _toast.show('Snapshot copied to clipboard'); } void _onHighlightChanged( @@ -1235,22 +1265,33 @@ class _FloatingIssuesCardState extends State if (checked) { setState(() => _selectedIssueId = issueKey); widget.controller.highlightEnabledNotifier.value = true; - final found = widget.controller.selectHighlightForIssue(issue); - if (!found) { - widget.controller.pendingIssueSelection = issue; - _toast.show( - 'Widget not currently visible. Navigate to the screen where this ' - 'issue occurs.', - tone: OverlayToastTone.warning, - duration: const Duration(seconds: 3), - ); - } + final result = widget.controller.selectHighlightForIssue( + issue, + onResolved: (result) { + if (!mounted || _selectedIssueId != issueKey) return; + if (result == HighlightLookup.notFound) _onHighlightNotFound(); + }, + ); + if (result == HighlightLookup.notFound) _onHighlightNotFound(); } else { setState(() => _selectedIssueId = null); widget.controller.clearSelectedHighlight(); } } + /// Unticks the card, so it does not claim an outline that never comes, + /// and says why. + void _onHighlightNotFound() { + setState(() => _selectedIssueId = null); + widget.controller.clearSelectedHighlight(); + _toast.show( + 'Widget not currently visible. Navigate to the screen where this ' + 'issue occurs.', + tone: OverlayToastTone.warning, + duration: const Duration(seconds: 3), + ); + } + /// Whether an issue can be visually located on screen. static bool _isLocatableIssue(PerformanceIssue issue) { return switch (issue.category) { @@ -1711,10 +1752,11 @@ class _FloatingIssuesCardState extends State ), if (widget.isDebugMode && widget.controller.config.showDebugModeBanner && - !_debugBannerDismissed) + !widget.controller.debugBannerDismissed) _DebugModeBanner( - onDismiss: () => - setState(() => _debugBannerDismissed = true), + onDismiss: () => setState( + () => widget.controller.debugBannerDismissed = true, + ), ), if (Sleuth.startupMetrics != null) _StartupMetricsBanner( diff --git a/lib/src/ui/issue_card.dart b/lib/src/ui/issue_card.dart index 3bd74bfc..2c1f8fd4 100644 --- a/lib/src/ui/issue_card.dart +++ b/lib/src/ui/issue_card.dart @@ -83,8 +83,9 @@ class IssueCard extends StatefulWidget { /// annotations distinguishing attribution quality from timing fidelity. final bool deepInstrumentationActive; - /// When true, shows a "JANK" badge in the collapsed header — this issue - /// appears in the current verdict's relatedIssues. + /// When true, shows a "JANK" badge in the collapsed header: this issue + /// can explain the phase of a jank frame from the last 10 s, and jank + /// is still reported. final bool jankCorrelated; /// When true, applies a temporary amber tint to draw attention to diff --git a/lib/src/utils/source_location_cache.dart b/lib/src/utils/source_location_cache.dart index ae69d394..9434c174 100644 --- a/lib/src/utils/source_location_cache.dart +++ b/lib/src/utils/source_location_cache.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; -/// Bounded cache of widget [Type] → abbreviated "file:line" source locations. +/// Abbreviated "file:line" creation locations of widgets, cached per +/// [Element]. /// /// Uses [InspectorSerializationDelegate.additionalNodeProperties] to access /// creation location data injected by the track-widget-creation kernel @@ -12,37 +13,39 @@ import 'package:flutter/widgets.dart'; /// `ipa`, which writes `TRACK_WIDGET_CREATION=false` into the generated /// xcconfig (Xcode or fastlane archives that reuse it inherit the setting). /// -/// Cache is bounded by [maxEntries] — when full, new types are not cached -/// but existing lookups remain valid. Source locations are stable per widget -/// type, so eviction is unnecessary. Keyed by `widget.runtimeType` itself, -/// so a lookup (hit or miss past the cap) allocates no type-name string. +/// Keyed by element, not by widget type, because a type built in several +/// places, such as a `ValueListenableBuilder` on two screens, has one +/// location per call site. An element keeps its first result, which goes +/// stale only when its parent swaps in the same widget type from another +/// call site. The [Expando]s drop entries together with their elements. class SourceLocationCache { - SourceLocationCache({this.maxEntries = 200}); + SourceLocationCache(); - /// Maximum number of cached widget types. - final int maxEntries; - final Map _cache = {}; + /// Marks an element whose lookup found no location. + static const Object _none = Object(); + + Expando _locations = Expando('Sleuth source location'); + Expando<({String location, String? packageName})> _structured = Expando( + 'Sleuth structured source location', + ); bool? _trackingAvailable; + int _resolved = 0; /// Returns abbreviated "file:line" for the [element]'s widget, or null. /// - /// Results are cached by `widget.runtimeType`. Returns null when the build - /// does not track widget creation (release, `--no-track-widget-creation`, - /// or an iOS build from `flutter build ios` / `ipa` without the setting). + /// Returns null when the build does not track widget creation (release, + /// `--no-track-widget-creation`, or an iOS build from `flutter build ios` + /// / `ipa` without the setting). String? lookup(Element element) { _trackingAvailable ??= WidgetInspectorService.instance .isWidgetCreationTracked(); if (!_trackingAvailable!) return null; - final type = element.widget.runtimeType; - final cached = _cache[type]; - if (cached != null) return cached; - if (_cache.length >= maxEntries) return null; - + final cached = _locations[element]; + if (cached != null) return cached is String ? cached : null; final location = _resolve(element); - if (location != null) { - _cache[type] = location; - } + _locations[element] = location ?? _none; + _resolved++; return location; } @@ -88,26 +91,8 @@ class SourceLocationCache { /// Unlike [lookup] which returns a formatted string, this returns the /// abbreviated path, line number, and extracted package name separately. ({String location, String? packageName})? lookupStructured(Element element) { - _trackingAvailable ??= WidgetInspectorService.instance - .isWidgetCreationTracked(); - if (!_trackingAvailable!) return null; - - final type = element.widget.runtimeType; - final cached = _structuredCache[type]; - if (cached != null) return cached; - if (_cache.length >= maxEntries) return null; - - final location = _resolve(element); - if (location == null) return null; - - // Also populate the string cache for backward compatibility. - _cache[type] = location; - - final result = _resolveStructured(element); - if (result != null) { - _structuredCache[type] = result; - } - return result; + if (lookup(element) == null) return null; + return _structured[element] ??= _resolveStructured(element); } ({String location, String? packageName})? _resolveStructured( @@ -135,9 +120,6 @@ class SourceLocationCache { } } - final Map _structuredCache = - {}; - /// Extracts the package name from a source file path. /// /// - `/path/packages/my_app/lib/screens/home.dart` → `my_app` @@ -160,11 +142,12 @@ class SourceLocationCache { /// Clears cached entries and resets the tracking availability flag. void clear() { - _cache.clear(); - _structuredCache.clear(); + _locations = Expando('Sleuth source location'); + _structured = Expando('Sleuth structured source location'); _trackingAvailable = null; + _resolved = 0; } - /// Number of cached entries. - int get length => _cache.length; + /// Number of elements resolved since construction or the last [clear]. + int get length => _resolved; } diff --git a/lib/src/utils/widget_location.dart b/lib/src/utils/widget_location.dart index e3f48033..e44d5c2f 100644 --- a/lib/src/utils/widget_location.dart +++ b/lib/src/utils/widget_location.dart @@ -5,7 +5,7 @@ import 'type_name_cache.dart'; /// Module-level cache for widget source location lookups. /// -/// Shared across all [buildAncestorChain] calls. Bounded at 200 entries. +/// Shared across all [buildAncestorChain] calls, one entry per element. /// Call [SourceLocationCache.clear] in tests to reset state. final sourceLocationCache = SourceLocationCache(); @@ -107,6 +107,20 @@ String buildAncestorChain( return chain.reversed.join(' > '); } +/// The leaf widget's `file:line` from a [buildAncestorChain] result, or +/// null when the chain is null or its leaf has no location (the build +/// does not track widget creation). +String? leafSourceLocation(String? ancestorChain) { + if (ancestorChain == null) return null; + final separator = ancestorChain.lastIndexOf(' > '); + final leaf = separator < 0 + ? ancestorChain + : ancestorChain.substring(separator + 3); + return _trailingLocation.firstMatch(leaf.trimRight())?.group(1); +} + +final RegExp _trailingLocation = RegExp(r' \(([^()]+:\d+)\)$'); + /// Extracts the package name from the leaf element's source location. /// Returns null when the build does not track widget creation. String? extractPackageNameFromElement(Element element) { diff --git a/lib/src/vm/service_extension_handlers.dart b/lib/src/vm/service_extension_handlers.dart index d1df5769..ec1213f4 100644 --- a/lib/src/vm/service_extension_handlers.dart +++ b/lib/src/vm/service_extension_handlers.dart @@ -280,6 +280,8 @@ FutureOr> extIssuesHandler( 'issues': [for (final i in filtered) i.toJson()], 'route': ?route, 'vmConnected': controller.isVmConnected, + if (controller.notUsefulMarksForExport() case final marks?) + 'notUsefulMarks': [for (final m in marks) m.toJson()], }, ); } diff --git a/packages/sleuth_mcp/CHANGELOG.md b/packages/sleuth_mcp/CHANGELOG.md index fbc80276..26686366 100644 --- a/packages/sleuth_mcp/CHANGELOG.md +++ b/packages/sleuth_mcp/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +- `snapshotMetadataKeys` includes `notUsefulMarks`, the envelope key in + which newer apps list the issues marked Not useful in the overlay. The + sidecar's own projection keeps it like `suppressedCount`. +- The schema copy documents `notUsefulMarks` and the issue `measurement` + shape, and the tool schema describes the `measurement` object of a + verbose issue. + ## 0.9.0 Pins sleuth 0.38.0, accepts 0.37 apps with a warning and refuses 0.36 diff --git a/packages/sleuth_mcp/doc/mcp_schema.json b/packages/sleuth_mcp/doc/mcp_schema.json index 74cbca15..75d224b2 100644 --- a/packages/sleuth_mcp/doc/mcp_schema.json +++ b/packages/sleuth_mcp/doc/mcp_schema.json @@ -79,7 +79,7 @@ "errors": ["arg_invalid_section", "arg_invalid_int", "arg_pagination_unused"], "data": { "_shape_source": "SessionSnapshot.toJson() in lib/src/models/session_snapshot.dart", - "_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount) always serialize.", + "_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount, notUsefulMarks) always serialize.", "_projectedSections": {"type": "List", "required": false, "nullable": false, "presence": "when any projection arg (sections, maxIssueCount or maxRouteCount) was set; holds the SnapshotSection keys in this payload, sorted alphabetically"}, "_projectionLimits": {"type": "Map", "required": false, "nullable": false, "presence": "when maxIssueCount or maxRouteCount was set", "shape": {"maxIssueCount": {"type": "int", "required": false, "nullable": false}, "maxRouteCount": {"type": "int", "required": false, "nullable": false}}}, "_projectionApplied": {"type": "String", "required": false, "nullable": false, "presence": "when any projection arg was set", "values": ["by_app", "by_sidecar_fallback"]}, @@ -92,6 +92,33 @@ "capturedFrames": {"type": "List", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"}, "currentIssues": {"type": "List", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"}, "suppressedCount": {"type": "int", "required": false, "nullable": false, "presence": "only when above 0"}, + "notUsefulMarks": { + "type": "List", + "required": false, + "nullable": false, + "presence": "only when an issue was marked Not useful in the overlay this session", + "doc": "Session-only marks, oldest first, at most 200. Like suppressedCount it is written whatever the projection.", + "item_shape": { + "key": {"type": "String", "required": true, "nullable": false, "doc": "The overlay list key: stableId (else the title), plus |widgetName when the issue names a widget."}, + "stableId": {"type": "String", "required": false, "nullable": false, "presence": "when the issue has one"}, + "title": {"type": "String", "required": true, "nullable": false}, + "category": {"type": "String", "required": true, "nullable": false, "values": ["build", "layout", "paint", "raster", "memory", "channel", "font", "network", "startup"]}, + "severity": {"type": "String", "required": true, "nullable": false, "values": ["ok", "warning", "critical"]}, + "confidence": {"type": "String", "required": true, "nullable": false, "values": ["confirmed", "likely", "possible"]}, + "measurement": {"type": "Map", "required": false, "nullable": false, "presence": "when the issue had a measurement with a finite value", "doc": "The same shape as a currentIssues item's measurement key.", "shape": { + "value": {"type": "num", "required": true, "nullable": false}, + "unit": {"type": "String", "required": true, "nullable": false, "doc": "milliseconds, seconds, percent, bytes, bytesPerSecond, count, perSecond, perMinute or frames. A reader keeps a unit it does not know by name."}, + "warningAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a warning line"}, + "criticalAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a critical line"}, + "windowMs": {"type": "num", "required": false, "nullable": false, "presence": "when the value was counted over a window; a fraction keeps microseconds"}, + "warningStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with warningAt, when a value must exceed it"}, + "criticalStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with criticalAt, when a value must exceed it"} + }}, + "route": {"type": "String", "required": false, "nullable": false, "presence": "when the issue carried a route"}, + "markedAt": {"type": "String (ISO-8601)", "required": true, "nullable": false}, + "live": {"type": "bool", "required": true, "nullable": false, "doc": "Whether the issue was still reported when the payload was built."} + } + }, "recentRequests": {"type": "List", "required": false, "nullable": false, "presence": "when NetworkMonitorDetector is enabled and the request ring buffer is non-empty"}, "heapSamples": {"type": "List", "required": false, "nullable": false, "presence": "when MemoryPressureDetector has at least one sample buffered"}, "phaseEvents": {"type": "List", "required": false, "nullable": false, "presence": "when the controller's rolling timeline-event buffer is non-empty"}, @@ -222,7 +249,8 @@ "data": { "issues": {"type": "List", "required": true, "nullable": false, "item_shape": "PerformanceIssue.toJson()"}, "route": {"type": "String", "required": false, "nullable": false, "presence": "only when the route arg was non-empty"}, - "vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."} + "vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."}, + "notUsefulMarks": {"type": "List", "required": false, "nullable": false, "presence": "only when an issue was marked Not useful in the overlay this session", "doc": "Every mark, whatever the route arg.", "item_shape": "see ext.sleuth.snapshot.data.notUsefulMarks item_shape"} } }, "ext.sleuth.routeHealth": { diff --git a/packages/sleuth_mcp/doc/mcp_schema.md b/packages/sleuth_mcp/doc/mcp_schema.md index e7ce4613..3f7db382 100644 --- a/packages/sleuth_mcp/doc/mcp_schema.md +++ b/packages/sleuth_mcp/doc/mcp_schema.md @@ -108,7 +108,7 @@ The handler returns `SessionSnapshot.toJson()`, defined in `lib/src/models/sessi | `maxIssueCount` | String (int) | keeps the top N already-ranked `currentIssues`. A negative or non-numeric value returns `arg_invalid_int`. Setting it while `sections` leaves out `currentIssues` returns `arg_pagination_unused`. | | `maxRouteCount` | String (int) | keeps the N most recent `routeSessions` by `startedAt`. It returns the same errors as `maxIssueCount`, with `routeSessions` in place of `currentIssues`. | -When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`) always serialize. +When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`, `notUsefulMarks`) always serialize. | `data` key | Type | Required | Presence | |---|---|---|---| @@ -124,6 +124,7 @@ When any projection arg is set, a payload field is present only if its section i | `capturedFrames` | List\ | yes | always, unless sections leaves it out | | `currentIssues` | List\ | yes | always, unless sections leaves it out | | `suppressedCount` | int | no | only when above 0 | +| `notUsefulMarks` | List\ | no | only when an issue was marked Not useful in the overlay this session. Session-only marks, oldest first, at most 200; item shape below. | | `recentRequests` | List\ | no | when NetworkMonitorDetector is enabled and the request ring buffer is non-empty | | `heapSamples` | List\ | no | when MemoryPressureDetector has at least one sample buffered | | `phaseEvents` | List\ | no | when the controller's rolling timeline-event buffer is non-empty | @@ -136,6 +137,33 @@ When any projection arg is set, a payload field is present only if its section i | `startupMetrics` | Map | no | when `Sleuth.init` captured first-frame data | | `routeSessions` | List\ | no | when the route history is not empty | +#### `notUsefulMarks[]` item shape + +| Key | Type | Required | Presence | +|---|---|---|---| +| `key` | String | yes | always; the overlay list key: `stableId` (else the title), plus `\|widgetName` when the issue names a widget | +| `stableId` | String | no | when the issue has one | +| `title` | String | yes | always | +| `category`, `severity`, `confidence` | String | yes | always; the enum names | +| `measurement` | Map | no | when the issue had a measurement with a finite value; the same shape as a `currentIssues` item's `measurement`, below | +| `route` | String | no | when the issue carried a route | +| `markedAt` | String (ISO-8601) | yes | always | +| `live` | bool | yes | always; whether the issue was still reported when the payload was built | + +#### Issue `measurement` sub-shape + +An issue item (`currentIssues[]`, `ext.sleuth.issues` `issues[]`) carries `measurement` when its detector reported a number: the value it measured and its warning and critical lines. The issue's `severity` is the detector's and can disagree with where the value sits against the lines, in either direction. + +| Key | Type | Required | Presence | +|---|---|---|---| +| `value` | num | yes | always | +| `unit` | String | yes | always; `milliseconds`, `seconds`, `percent`, `bytes`, `bytesPerSecond`, `count`, `perSecond`, `perMinute` or `frames`. A reader keeps a unit it does not know by name. | +| `warningAt` | num | no | when the detector has a warning line | +| `criticalAt` | num | no | when the detector has a critical line | +| `windowMs` | num | no | when the value was counted over a window; a fraction keeps microseconds | +| `warningStrict` | bool | no | only with `warningAt`, when a value must exceed it; always `true` when present | +| `criticalStrict` | bool | no | only with `criticalAt`, when a value must exceed it; always `true` when present | + #### `recurrenceTrends.` sub-shape | Key | Type | Required | Presence | @@ -216,6 +244,7 @@ Returns the current aggregated issues. It takes an optional `route` (String). Wi | `issues` | List\ | yes | always; each item is `PerformanceIssue.toJson()` | | `route` | String | no | only when the route arg was non-empty | | `vmConnected` | bool | yes | always; the same flag as `ext.sleuth.diagnose` `data.vmConnected`, which tells a VM-connected `basic` session from one without a VM link | +| `notUsefulMarks` | List\ | no | only when an issue was marked Not useful in the overlay this session; every mark, whatever the `route` arg. The item shape is `ext.sleuth.snapshot` `notUsefulMarks[]`. | ### `ext.sleuth.routeHealth` diff --git a/packages/sleuth_mcp/lib/src/tools/snapshot_sections.dart b/packages/sleuth_mcp/lib/src/tools/snapshot_sections.dart index 2d40c7f3..74418476 100644 --- a/packages/sleuth_mcp/lib/src/tools/snapshot_sections.dart +++ b/packages/sleuth_mcp/lib/src/tools/snapshot_sections.dart @@ -64,4 +64,5 @@ const Set snapshotMetadataKeys = { 'isVmConnected', 'isDebugMode', 'suppressedCount', + 'notUsefulMarks', }; diff --git a/test/analyzer/jank_relation_test.dart b/test/analyzer/jank_relation_test.dart new file mode 100644 index 00000000..8f7e9b8d --- /dev/null +++ b/test/analyzer/jank_relation_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/analyzer/jank_relation.dart'; +import 'package:sleuth/src/analyzer/render_pipeline_analyzer.dart'; +import 'package:sleuth/src/models/frame_stats.dart'; +import 'package:sleuth/src/models/frame_verdict.dart'; +import 'package:sleuth/src/models/performance_issue.dart'; + +import '../helpers/timeline_test_helpers.dart'; + +PerformanceIssue _issue(String stableId, IssueCategory category) => + PerformanceIssue( + stableId: stableId, + severity: IssueSeverity.warning, + category: category, + confidence: IssueConfidence.confirmed, + title: stableId, + detail: '', + fixHint: '', + ); + +final _rebuild = _issue('rebuild_activity', IssueCategory.build); +final _layout = _issue('layout_bottleneck', IssueCategory.layout); +final _paint = _issue('excessive_repaint', IssueCategory.paint); +final _raster = _issue('raster_dominance', IssueCategory.raster); +final _stall = _issue('event_loop_stall', IssueCategory.build); +final _memory = _issue('gc_pressure', IssueCategory.memory); +final _network = _issue('slow_request', IssueCategory.network); +final _jank = _issue('jank_detected', IssueCategory.build); +final _sustained = _issue('sustained_jank', IssueCategory.build); + +final _all = [ + _rebuild, + _layout, + _paint, + _raster, + _stall, + _memory, + _network, + _jank, + _sustained, +]; + +void main() { + group('relatedToPhase', () { + test('each UI or raster phase takes its own category', () { + expect(relatedToPhase(PipelinePhase.build, _all), [_rebuild, _stall]); + expect(relatedToPhase(PipelinePhase.layout, _all), [_layout]); + expect(relatedToPhase(PipelinePhase.paint, _all), [_paint]); + expect(relatedToPhase(PipelinePhase.raster, _all), [_raster]); + }); + + test('a start delay takes event_loop_stall only', () { + expect(relatedToPhase(PipelinePhase.schedulerDelay, _all), [_stall]); + }); + + test('a stall or an unknown phase takes nothing', () { + expect(relatedToPhase(PipelinePhase.pipelineStall, _all), isEmpty); + expect(relatedToPhase(PipelinePhase.unknown, _all), isEmpty); + }); + + test('jank symptoms are never a cause', () { + for (final phase in PipelinePhase.values) { + final related = relatedToPhase(phase, _all); + expect(related, isNot(contains(_jank)), reason: '$phase'); + expect(related, isNot(contains(_sustained)), reason: '$phase'); + } + expect(isJankSymptom(_jank), isTrue); + expect(isJankSymptom(_sustained), isTrue); + expect(isJankSymptom(_stall), isFalse); + }); + }); + + group('verdicts carry the phase filter', () { + FrameStats frame({required int uiMs, required int rasterMs}) => FrameStats( + frameNumber: 1, + uiDuration: Duration(milliseconds: uiMs), + rasterDuration: Duration(milliseconds: rasterMs), + timestamp: DateTime(2026, 1, 1), + frameBudgetMs: 16, + frameBudgetUs: 16667, + ); + + test('a basic-tier verdict tags only build or raster issues', () { + final analyzer = RenderPipelineAnalyzer(); + final build = analyzer.analyzeBasicMode( + frameStats: frame(uiMs: 30, rasterMs: 10), + relatedIssues: _all, + ); + expect(build.suspectedPhase, PipelinePhase.build); + expect(build.relatedIssues, [_rebuild, _stall]); + + final raster = analyzer.analyzeBasicMode( + frameStats: frame(uiMs: 5, rasterMs: 30), + relatedIssues: _all, + ); + expect(raster.suspectedPhase, PipelinePhase.raster); + expect(raster.relatedIssues, [_raster]); + }); + + test('the reason names a related issue of the phase', () { + final verdict = RenderPipelineAnalyzer().analyzeFullMode( + frameStats: frame(uiMs: 5, rasterMs: 30), + timelineData: emptyTimelineData(), + relatedIssues: [_jank, _memory, _raster], + ); + expect(verdict.relatedIssues, [_raster]); + expect(verdict.reason, contains('Related: raster_dominance')); + }); + }); +} diff --git a/test/benchmark/overlay_build_cost_test.dart b/test/benchmark/overlay_build_cost_test.dart new file mode 100644 index 00000000..2816c47a --- /dev/null +++ b/test/benchmark/overlay_build_cost_test.dart @@ -0,0 +1,133 @@ +@Tags(['benchmark']) +library; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/models/performance_issue.dart'; +import 'package:sleuth/src/ui/floating_issues_card.dart'; +import 'package:sleuth/src/ui/issue_card.dart'; + +import '../helpers/overlay_harness.dart'; + +// The overlay builds inside the app's frames, where Sleuth's own-work log +// does not reach, so every extra overlay build adds to the app's +// rebuild_activity share. A change that builds the card more often per +// scan tick than [_budgets] allows fails here. Build time is printed, not +// asserted, so the test does not flake on a busy machine. + +/// Builds of [FloatingIssuesCard] and of all [IssueCard]s over 50 ticks. +typedef _Builds = ({int card, int issueCards}); + +/// Recorded before the 1.0.0 card redesign (Flutter 3.47.6, 800 x 600 +/// test surface). A title change rebuilds only the cards the list shows: 4 +/// collapsed or 2 with three expanded. +const _budgets = { + 'collapsed, no change': (card: 0, issueCards: 0), + 'collapsed, title change': (card: 0, issueCards: 200), + 'three expanded, no change': (card: 1, issueCards: 0), + 'three expanded, title change': (card: 1, issueCards: 100), +}; + +const _ticks = 50; + +/// 20 issues across severities, categories and confidences; every fourth +/// names a widget. +List _issues({String suffix = ''}) => [ + for (var i = 0; i < 20; i++) + PerformanceIssue( + stableId: 'issue_$i', + severity: IssueSeverity.values[i % 3], + category: IssueCategory.values[i % IssueCategory.values.length], + confidence: IssueConfidence.values[i % 3], + title: 'Title $i${i == 0 ? suffix : ''}', + detail: 'Detail for issue $i, long enough to wrap onto a second line.', + fixHint: 'Fix hint $i.', + widgetName: i % 4 == 0 ? 'Widget$i' : null, + ), +]; + +void main() { + /// Counts card builds while [tick] runs [_ticks] times, one scan tick + /// (1 s) apart, and reports the time a tick's frame took. + Future<_Builds> measure( + WidgetTester tester, + SleuthController controller, + String label, + void Function(int tick) tick, + ) async { + var card = 0; + var issueCards = 0; + debugOnRebuildDirtyWidget = (element, _) { + final widget = element.widget; + if (widget is FloatingIssuesCard) { + card++; + } else if (widget is IssueCard) { + issueCards++; + } + }; + addTearDown(() => debugOnRebuildDirtyWidget = null); + final watch = Stopwatch(); + for (var i = 0; i < _ticks; i++) { + tick(i); + controller.scanTickNotifier.value++; + watch.start(); + await tester.pump(const Duration(seconds: 1)); + watch.stop(); + } + debugOnRebuildDirtyWidget = null; + // ignore: avoid_print + print( + ' $label: card $card, issue cards $issueCards builds over $_ticks ' + 'ticks; ${(watch.elapsedMicroseconds / _ticks).toStringAsFixed(0)} µs ' + 'per tick', + ); + return (card: card, issueCards: issueCards); + } + + Future openWithIssues( + WidgetTester tester, { + required bool expandThree, + }) async { + final controller = await pumpOverlay(tester); + controller.seedIssuesForTest(_issues()); + await openDashboard(tester, controller); + if (expandThree) { + final titles = [ + for (final card in tester.widgetList(find.byType(IssueCard))) + card.issue.title, + ].take(3); + for (final title in titles) { + await tester.ensureVisible(find.text(title)); + await tester.tap(find.text(title)); + await tester.pump(); + } + expect(find.byType(IssueCard), findsWidgets); + } + // Lets the new-card accents and the expansion animations finish. + await tester.pump(const Duration(seconds: 3)); + return controller; + } + + for (final expandThree in [false, true]) { + final layout = expandThree ? 'three expanded' : 'collapsed'; + + testWidgets('$layout, no change', (tester) async { + final controller = await openWithIssues(tester, expandThree: expandThree); + final label = '$layout, no change'; + final builds = await measure(tester, controller, label, (_) {}); + expect(builds.card, lessThanOrEqualTo(_budgets[label]!.card)); + expect(builds.issueCards, lessThanOrEqualTo(_budgets[label]!.issueCards)); + }, semanticsEnabled: false); + + testWidgets('$layout, title change', (tester) async { + final controller = await openWithIssues(tester, expandThree: expandThree); + final label = '$layout, title change'; + final builds = await measure(tester, controller, label, (i) { + controller.issuesNotifier.value = _issues(suffix: i.isEven ? ' *' : ''); + }); + expect(builds.card, lessThanOrEqualTo(_budgets[label]!.card)); + expect(builds.issueCards, lessThanOrEqualTo(_budgets[label]!.issueCards)); + }, semanticsEnabled: false); + } +} diff --git a/test/controller/coverage_run_state_test.dart b/test/controller/coverage_run_state_test.dart new file mode 100644 index 00000000..3cbe4540 --- /dev/null +++ b/test/controller/coverage_run_state_test.dart @@ -0,0 +1,241 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/controller/coverage_report.dart'; +import 'package:sleuth/src/controller/run_state.dart'; +import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/models/base_detector.dart'; +import 'package:sleuth/src/vm/vm_service_client.dart'; + +void main() { + group('CoverageReport for the controller', () { + test('default config without the VM', () { + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + final report = controller.coverageNotifier.value; + + expect(report.typesWith(CheckCoverage.needsVm), [ + DetectorType.shaderJank, + DetectorType.heavyCompute, + DetectorType.platformChannel, + DetectorType.memoryPressure, + DetectorType.streamResource, + DetectorType.eventLoopStall, + ]); + expect(report.typesWith(CheckCoverage.partial), [ + DetectorType.repaint, + DetectorType.rebuild, + DetectorType.gpuPressure, + ]); + expect(report.typesWith(CheckCoverage.optInOff), [ + DetectorType.trackedResource, + ]); + expect(report.count(CheckCoverage.on), 11); + expect(report.count(CheckCoverage.disabled), 0); + expect(report.byType, hasLength(DetectorType.values.length - 1)); + }); + + test('default config with the VM', () { + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.simulateVmStateChangeForTest(true); + final report = controller.coverageNotifier.value; + + expect(report.count(CheckCoverage.on), 19); + expect(report.typesWith(CheckCoverage.optInOff), [ + DetectorType.platformChannel, + DetectorType.trackedResource, + ]); + + controller.simulateVmStateChangeForTest(false); + expect(controller.coverageNotifier.value.count(CheckCoverage.needsVm), 6); + }); + + test('a custom enabledDetectors set marks the rest disabled', () { + final controller = SleuthController( + config: const SleuthConfig( + enabledDetectors: { + DetectorType.frameTiming, + DetectorType.memoryPressure, + DetectorType.rebuild, + }, + ), + )..initializeDetectorsForTest(); + addTearDown(controller.dispose); + final report = controller.coverageNotifier.value; + + expect(report.byType[DetectorType.frameTiming], CheckCoverage.on); + expect(report.byType[DetectorType.memoryPressure], CheckCoverage.needsVm); + expect(report.byType[DetectorType.rebuild], CheckCoverage.partial); + expect( + report.count(CheckCoverage.disabled), + DetectorType.values.length - 1 - 3, + ); + }); + + test('opt-ins switched on', () { + final controller = SleuthController( + config: const SleuthConfig(profilePlatformChannels: true), + )..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.simulateVmStateChangeForTest(true); + expect( + controller.coverageNotifier.value.byType[DetectorType.platformChannel], + CheckCoverage.on, + ); + + final notified = []; + controller.coverageNotifier.addListener( + () => notified.add(controller.coverageNotifier.value), + ); + final resource = Object(); + controller.trackResource('cache', resource); + expect(notified, hasLength(1)); + expect( + notified.single.byType[DetectorType.trackedResource], + CheckCoverage.on, + ); + expect(notified.single.count(CheckCoverage.on), 21); + + // Later registrations leave the report alone. + controller.trackResource('cache', Object()); + expect(notified, hasLength(1)); + }); + + test('debug callbacks feed rebuild and repaint without the VM', () { + final controller = SleuthController( + config: const SleuthConfig(enableDebugCallbacks: true), + )..initializeDetectorsForTest(); + addTearDown(controller.dispose); + final report = controller.coverageNotifier.value; + + expect(report.byType[DetectorType.rebuild], CheckCoverage.on); + expect(report.byType[DetectorType.repaint], CheckCoverage.on); + expect(report.byType[DetectorType.gpuPressure], CheckCoverage.partial); + }); + + test('equal reports compare equal', () { + CoverageReport build(bool vm) => CoverageReport.compute( + enabled: {DetectorType.memoryPressure}, + lifecycleOf: (_) => DetectorLifecycle.vmOnly, + vmConnected: vm, + profilePlatformChannels: false, + resourcesTracked: false, + ); + expect(build(true), build(true)); + expect(build(true).hashCode, build(true).hashCode); + expect(build(true), isNot(build(false))); + }); + }); + + group('SleuthRunState', () { + test('starting, then warming up, then running', () { + fakeAsync((async) { + final controller = SleuthController()..initializeDetectorsForTest(); + expect( + controller.runStateNotifier.value.phase, + SleuthRunPhase.starting, + ); + + controller.markInitializedAtForTest(DateTime.now()); + controller.refreshRunStateForTest(); + expect( + controller.runStateNotifier.value.phase, + SleuthRunPhase.warmingUp, + ); + expect(controller.warmupTimerActiveForTest, isTrue); + + async.elapse(controller.config.frameTimingWarmupDuration); + expect(controller.runStateNotifier.value.phase, SleuthRunPhase.running); + expect(controller.warmupTimerActiveForTest, isFalse); + controller.dispose(); + }); + }); + + test('dispose cancels the warm-up timer', () { + fakeAsync((async) { + final controller = SleuthController()..initializeDetectorsForTest(); + controller.markInitializedAtForTest(DateTime.now()); + controller.refreshRunStateForTest(); + expect(controller.warmupTimerActiveForTest, isTrue); + controller.dispose(); + expect(controller.warmupTimerActiveForTest, isFalse); + async.elapse(const Duration(seconds: 10)); + }); + }); + + test('a lost VM counts reconnect attempts until it stops retrying', () { + fakeAsync((async) { + final controller = SleuthController()..initializeDetectorsForTest(); + final fake = _FailingVmClient(); + controller + ..setVmClientForTest(fake) + ..markInitializedAtForTest(DateTime(2026)) + ..markInitializedForTest(); + + controller.onVmConnectionChangedForTest(false); + var state = controller.runStateNotifier.value; + expect(state.vmConnected, isFalse); + expect(state.reconnectAttempt, 1); + expect(state.maxReconnectAttempts, 7); + expect(state.stoppedRetrying, isFalse); + + async.elapse(const Duration(milliseconds: 500)); + async.flushMicrotasks(); + expect(controller.runStateNotifier.value.reconnectAttempt, 2); + + async.elapse(const Duration(seconds: 70)); + async.flushMicrotasks(); + state = controller.runStateNotifier.value; + expect(fake.connectCallCount, 7); + expect(state.reconnectAttempt, isNull); + expect(state.stoppedRetrying, isTrue); + controller.dispose(); + }); + }); + + test('a connected VM clears the attempt count', () { + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.simulateVmStateChangeForTest(true); + final state = controller.runStateNotifier.value; + expect(state.vmConnected, isTrue); + expect(state.reconnectAttempt, isNull); + expect(state.stoppedRetrying, isFalse); + }); + }); + + test('the debug banner dismissal lives for the session', () { + final controller = SleuthController(); + addTearDown(controller.dispose); + expect(controller.debugBannerDismissed, isFalse); + controller.debugBannerDismissed = true; + expect(controller.debugBannerDismissed, isTrue); + }); +} + +/// A VM client whose connects always fail, so the background ladder runs +/// to its end. +class _FailingVmClient extends VmServiceClient { + int connectCallCount = 0; + + @override + bool get isConnected => false; + + @override + bool get isDisposed => false; + + @override + Future connect({ + int maxRetries = 3, + Duration retryDelay = const Duration(milliseconds: 500), + }) async { + connectCallCount++; + return false; + } + + @override + Future reconnect() async => false; + + @override + void dispose() {} +} diff --git a/test/controller/frame_verdict_fallback_test.dart b/test/controller/frame_verdict_fallback_test.dart index 0b74d608..a916c70b 100644 --- a/test/controller/frame_verdict_fallback_test.dart +++ b/test/controller/frame_verdict_fallback_test.dart @@ -105,8 +105,8 @@ void main() { expect(verdict.reason, isNot(contains('Vsync'))); }); - test('relatedIssues are attached from current detector state', () { - // Feed enough janky frames to trigger FrameTimingDetector's own jank issue + test('relatedIssues leave out the jank symptoms themselves', () { + // Enough janky frames for FrameTimingDetector's own jank issues. for (var i = 0; i < 10; i++) { controller.addFrameForTest( makeFrame(uiMs: 40, rasterMs: 8, frameNumber: i + 1), @@ -115,10 +115,16 @@ void main() { final verdict = controller.verdictNotifier.value; expect(verdict, isNotNull); + expect(verdict!.suspectedPhase, PipelinePhase.build); + controller.aggregateIssuesForTest(); + expect( + controller.issuesNotifier.value.map((i) => i.stableId), + contains('sustained_jank'), + ); expect( - verdict!.relatedIssues, - isNotEmpty, - reason: 'Sustained jank should produce FrameTimingDetector issues', + verdict.relatedIssues, + isEmpty, + reason: 'A jank symptom does not explain its own late frames', ); }); diff --git a/test/controller/highlight_behavior_test.dart b/test/controller/highlight_behavior_test.dart index 9e30c4ec..93b36f53 100644 --- a/test/controller/highlight_behavior_test.dart +++ b/test/controller/highlight_behavior_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/controller/highlight_lookup.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/debug/debug_snapshot.dart'; import 'package:sleuth/src/detectors/setstate_scope_detector.dart'; @@ -103,6 +104,179 @@ void main() { }, ); + testWidgets('a pending lookup resolves after the first scan', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollView( + child: Column( + children: List.generate( + 55, + (i) => SizedBox(key: ValueKey(i), width: 10, height: 10), + ), + ), + ), + ), + ); + PerformanceIssue issue(String widgetName) => PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.likely, + title: widgetName, + detail: '', + fixHint: '', + widgetName: widgetName, + ); + controller.highlightEnabledNotifier.value = true; + + final results = []; + expect( + controller.selectHighlightForIssue( + issue('SingleChildScrollView'), + onResolved: results.add, + ), + HighlightLookup.pending, + ); + controller.runTreeScanForTest( + tester.element(find.byType(Directionality)), + ); + expect(results, [HighlightLookup.found]); + expect( + controller.selectedHighlightNotifier.value?.widgetName, + 'SingleChildScrollView', + ); + // The highlight carries the identity of the element its issue was + // reported for. + final reported = controller.latestIssues.singleWhere( + (i) => i.widgetName == 'SingleChildScrollView', + ); + expect(reported.occurrenceId, isNotNull); + expect( + controller.selectedHighlightNotifier.value?.occurrenceId, + reported.occurrenceId, + ); + + // After a scan a widget it did not outline is not found at once, and + // a later scan selects nothing for it. + controller.clearSelectedHighlight(); + expect( + controller.selectHighlightForIssue( + issue('Missing'), + onResolved: results.add, + ), + HighlightLookup.notFound, + ); + controller.runTreeScanForTest( + tester.element(find.byType(Directionality)), + ); + expect(results, [HighlightLookup.found]); + expect(controller.selectedHighlightNotifier.value, isNull); + }); + + testWidgets('a pending lookup without a callback still selects', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SingleChildScrollView( + child: Column( + children: List.generate( + 55, + (i) => SizedBox(key: ValueKey(i), width: 10, height: 10), + ), + ), + ), + ), + ); + controller.highlightEnabledNotifier.value = true; + const issue = PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.likely, + title: 'Non-lazy', + detail: '', + fixHint: '', + widgetName: 'SingleChildScrollView', + ); + expect( + controller.selectHighlightForIssue(issue), + HighlightLookup.pending, + ); + controller.runTreeScanForTest( + tester.element(find.byType(Directionality)), + ); + expect( + controller.selectedHighlightNotifier.value?.widgetName, + 'SingleChildScrollView', + ); + }); + + testWidgets('the selection follows its own element across scans', ( + tester, + ) async { + Widget list(int key) => SingleChildScrollView( + key: ValueKey('list$key'), + child: Column( + children: List.generate( + 55, + (i) => SizedBox(key: ValueKey(i), width: 10, height: 1), + ), + ), + ); + Future pumpLists(List keys) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + for (final k in keys) SizedBox(height: 100, child: list(k)), + ], + ), + ), + ); + void scan() => controller.runTreeScanForTest( + tester.element(find.byType(Directionality)), + ); + + await pumpLists([1, 2]); + controller.highlightEnabledNotifier.value = true; + scan(); + final reported = controller.latestIssues + .where((i) => i.widgetName == 'SingleChildScrollView') + .toList(); + expect(reported, hasLength(2)); + final second = reported.firstWhere( + (i) => + i.occurrenceId == + identityHashCode( + tester.element(find.byKey(const ValueKey('list2'))), + ), + ); + expect(controller.selectHighlightForIssue(second), HighlightLookup.found); + + // A new scan republishes both highlights; the outline stays on the + // second list, not the first one with the same name. + scan(); + expect( + controller.selectedHighlightNotifier.value?.occurrenceId, + second.occurrenceId, + ); + controller.refreshHighlightRects(); + await tester.pump(); + expect( + controller.selectedHighlightNotifier.value?.occurrenceId, + second.occurrenceId, + ); + + // The second list goes: the outline clears instead of moving to the + // first list. + await pumpLists([1]); + scan(); + expect(controller.selectedHighlightNotifier.value, isNull); + }); + testWidgets( 'GpuPressureDetector highlights flow through to highlightsNotifier', (tester) async { @@ -191,6 +365,45 @@ void main() { ); }); + group('own highlight', () { + // Two lists of the same type: only the second one's highlight is in + // the list, as after a scroll dropped the first one's element. + test('an issue with an element identity is never given a same-named ' + 'sibling', () { + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.highlightsNotifier.value = ( + generation: 1, + items: const [ + WidgetHighlight( + rect: Rect.fromLTWH(0, 0, 100, 50), + widgetName: 'ListView', + severity: IssueSeverity.warning, + detectorName: 'Non-lazy', + occurrenceId: 2, + ), + ], + ); + const first = PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.possible, + title: 'Non-lazy List: ListView with 60 children', + detail: '', + fixHint: '', + stableId: 'non_lazy_listview', + widgetName: 'ListView', + occurrenceId: 1, + ); + + expect( + controller.selectHighlightForIssue(first), + HighlightLookup.notFound, + ); + expect(controller.selectedHighlightNotifier.value, isNull); + }); + }); + group('highlight dirty-check', () { late SleuthController controller; @@ -348,14 +561,14 @@ void main() { ); final matched = controller.selectHighlightForIssue(issue); - expect(matched, isTrue); + expect(matched, HighlightLookup.found); expect( controller.selectedHighlightNotifier.value?.widgetName, 'MyWidget', ); }); - test('falls back to detectorName via category mapping', () { + test('never falls back to another widget of the same category', () { controller.highlightsNotifier.value = ( generation: 1, items: [ @@ -379,12 +592,48 @@ void main() { ); final matched = controller.selectHighlightForIssue(issue); - expect(matched, isTrue); + expect(matched, HighlightLookup.notFound); + expect(controller.selectedHighlightNotifier.value, isNull); + expect(controller.pendingIssueSelection, isNull); }); - test('returns false when no highlights available', () { - controller.highlightsNotifier.value = (generation: 0, items: []); + test('an occurrence match beats a name match', () { + controller.highlightsNotifier.value = ( + generation: 1, + items: const [ + WidgetHighlight( + rect: Rect.fromLTWH(0, 0, 10, 10), + widgetName: 'ListView', + severity: IssueSeverity.warning, + detectorName: 'Non-lazy', + occurrenceId: 1, + ), + WidgetHighlight( + rect: Rect.fromLTWH(0, 50, 10, 10), + widgetName: 'ListView', + severity: IssueSeverity.warning, + detectorName: 'Non-lazy', + occurrenceId: 2, + ), + ], + ); + const issue = PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.possible, + title: 'Test', + detail: 'Test', + fixHint: 'Test', + widgetName: 'ListView', + occurrenceId: 2, + ); + expect(controller.selectHighlightForIssue(issue), HighlightLookup.found); + expect(controller.selectedHighlightNotifier.value?.occurrenceId, 2); + }); + + test('before the first scan the lookup is pending', () { + controller.highlightsNotifier.value = (generation: 0, items: []); const issue = PerformanceIssue( severity: IssueSeverity.warning, category: IssueCategory.build, @@ -392,10 +641,22 @@ void main() { title: 'Test', detail: 'Test', fixHint: 'Test', + widgetName: 'MyWidget', ); - final matched = controller.selectHighlightForIssue(issue); - expect(matched, isFalse); + expect( + controller.selectHighlightForIssue(issue), + HighlightLookup.pending, + ); + expect(controller.pendingIssueSelection, same(issue)); + + // After a scan an empty list means the widget is not on screen. + controller.scanTickNotifier.value = 1; + expect( + controller.selectHighlightForIssue(issue), + HighlightLookup.notFound, + ); + expect(controller.pendingIssueSelection, isNull); }); test('sets highlightEnabledNotifier to true on match', () { @@ -460,36 +721,6 @@ void main() { }); }); - group('detectorNamesForCategory', () { - test('layout category maps to Layout', () { - final names = SleuthController.detectorNamesForCategory( - IssueCategory.layout, - ); - expect(names, contains('Layout')); - }); - - test('raster category maps to GPU', () { - final names = SleuthController.detectorNamesForCategory( - IssueCategory.raster, - ); - expect(names, contains('GPU')); - }); - - test('build category maps to expected detector names', () { - final names = SleuthController.detectorNamesForCategory( - IssueCategory.build, - ); - expect(names, containsAll(['Non-lazy', 'setState', 'Rebuild'])); - }); - - test('paint category maps to expected detector names', () { - final names = SleuthController.detectorNamesForCategory( - IssueCategory.paint, - ); - expect(names, containsAll(['Painter', 'Repaint'])); - }); - }); - group('routeName stamping', () { late SleuthController controller; diff --git a/test/controller/not_useful_store_test.dart b/test/controller/not_useful_store_test.dart new file mode 100644 index 00000000..f6dbf62f --- /dev/null +++ b/test/controller/not_useful_store_test.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/models/issue_measurement.dart'; +import 'package:sleuth/src/models/not_useful_mark.dart'; +import 'package:sleuth/src/models/performance_issue.dart'; +import 'package:sleuth/src/models/session_snapshot.dart'; + +PerformanceIssue _issue( + String id, { + String? widgetName, + IssueMeasurement? measurement, + String? route, +}) => PerformanceIssue( + stableId: id, + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.likely, + title: 'Title $id', + detail: '', + fixHint: '', + widgetName: widgetName, + measurement: measurement, + sourceRoute: route, +); + +void main() { + group('Not useful store', () { + late SleuthController controller; + + setUp(() { + controller = SleuthController(); + }); + + tearDown(() => controller.dispose()); + + test('mark and unmark by list key', () { + final issue = _issue('rebuild_activity', widgetName: 'Feed'); + controller.markNotUseful(issue); + expect(controller.isNotUseful(issue), isTrue); + expect( + controller.notUsefulNotifier.value.single.key, + 'rebuild_activity|Feed', + ); + + // The same key on another widget is a different card. + expect( + controller.isNotUseful(_issue('rebuild_activity', widgetName: 'Bar')), + isFalse, + ); + + controller.clearNotUseful('rebuild_activity|Feed'); + expect(controller.isNotUseful(issue), isFalse); + expect(controller.notUsefulNotifier.value, isEmpty); + expect(controller.notUsefulMarksForExport(), isNull); + }); + + test('marking again moves the mark to the newest end', () { + controller + ..markNotUseful(_issue('a')) + ..markNotUseful(_issue('b')) + ..markNotUseful(_issue('a')); + expect(controller.notUsefulNotifier.value.map((m) => m.key), ['b', 'a']); + }); + + test('the cap evicts the oldest mark', () { + for (var i = 0; i <= SleuthController.maxNotUsefulMarks; i++) { + controller.markNotUseful(_issue('id$i')); + } + final keys = controller.notUsefulNotifier.value.map((m) => m.key); + expect(keys, hasLength(SleuthController.maxNotUsefulMarks)); + expect(keys.first, 'id1'); + expect(keys.last, 'id${SleuthController.maxNotUsefulMarks}'); + }); + + test('live follows the reported issues at export', () { + final kept = _issue('kept'); + final gone = _issue('gone'); + controller + ..seedIssuesForTest([kept, gone]) + ..markNotUseful(kept) + ..markNotUseful(gone) + ..seedIssuesForTest([kept]); + final marks = controller.notUsefulMarksForExport()!; + expect( + {for (final m in marks) m.key: m.live}, + {'kept': true, 'gone': false}, + ); + }); + + test('the snapshot carries the marks only when there are some', () { + expect( + controller.exportSnapshot().toJson().containsKey('notUsefulMarks'), + isFalse, + ); + controller.markNotUseful(_issue('a')); + final json = controller.exportSnapshot().toJson(); + expect((json['notUsefulMarks'] as List), hasLength(1)); + }); + }); + + group('NotUsefulMark', () { + test('takes its fields from the issue', () { + const m = IssueMeasurement( + value: 26, + unit: MeasurementUnit.perSecond, + warningAt: 10, + criticalAt: 30, + criticalStrict: true, + ); + final at = DateTime.utc(2026, 10, 19, 12); + final mark = NotUsefulMark.fromIssue( + _issue('rebuild_debug_Feed', measurement: m, route: '/feed'), + key: 'rebuild_debug_Feed', + markedAt: at, + ); + expect(mark.title, 'Title rebuild_debug_Feed'); + expect(mark.measurement, m); + expect(mark.route, '/feed'); + expect(mark.live, isTrue); + }); + + test('JSON round trip with and without optional fields', () { + final full = NotUsefulMark( + key: 'k|W', + stableId: 'k', + title: 't', + category: IssueCategory.paint, + severity: IssueSeverity.critical, + confidence: IssueConfidence.confirmed, + measurement: const IssueMeasurement( + value: 31, + unit: MeasurementUnit.percent, + warningAt: 10, + criticalAt: 30, + strict: true, + ), + route: '/r', + markedAt: DateTime.utc(2026, 10, 19), + live: false, + ); + final bare = NotUsefulMark( + key: 'title only', + title: 'title only', + category: IssueCategory.network, + severity: IssueSeverity.warning, + confidence: IssueConfidence.possible, + markedAt: DateTime.utc(2026, 10, 19), + ); + for (final mark in [full, bare]) { + final back = NotUsefulMark.fromJson( + jsonDecode(jsonEncode(mark.toJson())), + ); + expect(back, mark); + } + expect(bare.toJson().keys, isNot(contains('stableId'))); + expect(bare.toJson().keys, isNot(contains('measurement'))); + expect(bare.toJson().keys, isNot(contains('route'))); + }); + + test('fromJson returns null for a mark it cannot read', () { + final good = NotUsefulMark( + key: 'k', + title: 't', + category: IssueCategory.build, + severity: IssueSeverity.warning, + confidence: IssueConfidence.likely, + markedAt: DateTime.utc(2026), + ).toJson(); + expect(NotUsefulMark.fromJson('not a map'), isNull); + for (final field in ['key', 'title', 'markedAt']) { + expect(NotUsefulMark.fromJson({...good}..remove(field)), isNull); + } + expect(NotUsefulMark.fromJson({...good, 'severity': 'fatal'}), isNull); + expect(NotUsefulMark.fromJson({...good, 'markedAt': 'soon'}), isNull); + }); + + test('a snapshot reads its marks back', () { + final mark = NotUsefulMark( + key: 'k', + title: 't', + category: IssueCategory.build, + severity: IssueSeverity.warning, + confidence: IssueConfidence.likely, + markedAt: DateTime.utc(2026), + ); + final controller = SleuthController(); + addTearDown(controller.dispose); + final json = jsonDecode( + jsonEncode({ + ...controller.exportSnapshot().toJson(), + 'notUsefulMarks': [mark.toJson(), 'garbage'], + }), + ); + final snapshot = SessionSnapshot.fromJson(json as Map); + expect(snapshot.notUsefulMarks, [mark]); + }); + }); +} diff --git a/test/controller/route_session_lifecycle_test.dart b/test/controller/route_session_lifecycle_test.dart index 59d21354..dd60584c 100644 --- a/test/controller/route_session_lifecycle_test.dart +++ b/test/controller/route_session_lifecycle_test.dart @@ -9,6 +9,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/models/base_detector.dart'; import 'package:sleuth/src/models/frame_stats.dart'; +import 'package:sleuth/src/models/frame_verdict.dart'; +import 'package:sleuth/src/models/performance_issue.dart'; import 'package:sleuth/src/models/route_session.dart'; /// Config with only frameTiming so structural detectors don't fire. @@ -144,6 +146,43 @@ void main() { expect(controller.routeHistoryNotifier.value.last.routeName, '/settings'); }); + testWidgets('a route change drops the held jank causes; a rescan keeps ' + 'them', (tester) async { + await tester.pumpWidget(_multiRouteApp()); + await tester.pumpAndSettle(); + controller.scanTreeFullPathForTest(_rootContext(tester)); + controller.verdictNotifier.value = FrameVerdict( + frameNumber: 1, + totalFrameTime: const Duration(milliseconds: 32), + uiThreadTime: const Duration(milliseconds: 20), + rasterThreadTime: const Duration(milliseconds: 12), + suspectedPhase: PipelinePhase.build, + reason: 'Build', + relatedIssues: const [ + PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.confirmed, + title: 'Rebuilds', + detail: 'Detail', + fixHint: 'Fix', + stableId: 'rebuild_activity', + ), + ], + ); + expect(controller.heldJankCauses().keys, {'rebuild_activity'}); + + controller.scanTreeFullPathForTest(_rootContext(tester)); + expect(controller.heldJankCauses().keys, {'rebuild_activity'}); + + tester + .state(find.byType(Navigator)) + .pushNamed('/settings'); + await tester.pumpAndSettle(); + controller.scanTreeFullPathForTest(_rootContext(tester)); + expect(controller.heldJankCauses().keys, isEmpty); + }); + testWidgets('consecutive clean scans reset to 0 on route change', ( tester, ) async { diff --git a/test/debug/forced_rebuilds_of_test.dart b/test/debug/forced_rebuilds_of_test.dart new file mode 100644 index 00000000..406e9ee6 --- /dev/null +++ b/test/debug/forced_rebuilds_of_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/debug/debug_instrumentation_coordinator.dart'; +import 'package:sleuth/src/debug/debug_snapshot.dart'; + +void main() { + setUp(() { + debugOnProfilePaint = null; + debugOnRebuildDirtyWidget = null; + }); + tearDown(() { + debugOnProfilePaint = null; + debugOnRebuildDirtyWidget = null; + }); + + /// Installs a coordinator, runs [drive] and returns one snapshot. + Future record( + WidgetTester tester, + Future Function() drive, { + int maxTrackedTypes = 50, + bool installRebuild = true, + }) async { + var now = DateTime(2026); + final coord = DebugInstrumentationCoordinator( + maxTrackedTypes: maxTrackedTypes, + installRebuild: installRebuild, + clock: () => now, + ); + coord.install(); + await drive(); + now = now.add(const Duration(seconds: 1)); + final snap = coord.snapshot(); + coord.dispose(); + return snap; + } + + testWidgets('a parent setState names the parent', (tester) async { + final parent = GlobalKey<_ParentState>(); + await tester.pumpWidget(_Parent(key: parent)); + + // The first rebuild after install is the first time the coordinator + // sees each element, which it does not count. + final snap = await record(tester, () async { + for (var i = 0; i < 4; i++) { + parent.currentState!.bump(); + await tester.pump(); + } + }); + + expect(snap.rebuildCounts, {'_Parent': 3}); + expect(snap.forcedRebuildsOf, { + '_Leaf': {'_Parent': 3}, + }); + // The existing per-root count is unchanged. + expect(snap.forcedRebuildsByRoot, {'_Parent': 3}); + expect(snap.forcedTypesCapped, isFalse); + }, semanticsEnabled: false); + + testWidgets('a framework root is named but not counted as a rebuild', ( + tester, + ) async { + // The overlay creates the entry's widget, so it is a framework widget + // that marks itself dirty and rebuilds the app's builder output. + var n = 0; + final entry = OverlayEntry(builder: (_) => _Leaf(n)); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay(initialEntries: [entry]), + ), + ); + + final snap = await record(tester, () async { + for (var i = 0; i < 3; i++) { + n++; + entry.markNeedsBuild(); + await tester.pump(); + } + }); + + expect(snap.rebuildCounts, isEmpty); + expect(snap.forcedRebuildsByRoot, isEmpty); + expect(snap.forcedRebuildsOf, { + '_Leaf': {'_OverlayEntryWidget': 2}, + }); + }, semanticsEnabled: false); + + testWidgets('the forced-type cap sets the flag', (tester) async { + final parent = GlobalKey<_TwoLeavesState>(); + await tester.pumpWidget(_TwoLeaves(key: parent)); + + final snap = await record(tester, () async { + for (var i = 0; i < 2; i++) { + parent.currentState!.bump(); + await tester.pump(); + } + }, maxTrackedTypes: 1); + + expect(snap.forcedRebuildsOf.keys, hasLength(1)); + expect(snap.forcedTypesCapped, isTrue); + }, semanticsEnabled: false); + + testWidgets('at most 8 forcing types are kept for one forced type', ( + tester, + ) async { + final keys = >>[]; + final parents = []; + void add() { + final key = GlobalKey<_ParentState>(); + keys.add(key as GlobalKey<_ParentState>); + parents.add(_Parent(key: key)); + } + + add(); + add(); + add(); + add(); + add(); + add(); + add>(); + add>(); + add>(); + await tester.pumpWidget(Column(children: parents)); + + final snap = await record(tester, () async { + for (var i = 0; i < 2; i++) { + for (final key in keys) { + key.currentState!.bump(); + } + await tester.pump(); + } + }); + + expect(snap.forcedRebuildsOf['_Leaf'], hasLength(8)); + expect(snap.forcedTypesCapped, isTrue); + }, semanticsEnabled: false); + + testWidgets('nothing is recorded without the rebuild hook', (tester) async { + final parent = GlobalKey<_ParentState>(); + await tester.pumpWidget(_Parent(key: parent)); + + final snap = await record(tester, () async { + for (var i = 0; i < 2; i++) { + parent.currentState!.bump(); + await tester.pump(); + } + }, installRebuild: false); + + expect(snap.forcedRebuildsOf, isEmpty); + expect(snap.forcedTypesCapped, isFalse); + }, semanticsEnabled: false); +} + +class _Parent extends StatefulWidget { + const _Parent({super.key}); + + @override + State<_Parent> createState() => _ParentState(); +} + +class _ParentState extends State<_Parent> { + int _n = 0; + + void bump() => setState(() => _n++); + + @override + Widget build(BuildContext context) => _Leaf(_n); +} + +class _TwoLeaves extends StatefulWidget { + const _TwoLeaves({super.key}); + + @override + State<_TwoLeaves> createState() => _TwoLeavesState(); +} + +class _TwoLeavesState extends State<_TwoLeaves> { + int _n = 0; + + void bump() => setState(() => _n++); + + @override + Widget build(BuildContext context) => + Column(children: [_Leaf(_n), _OtherLeaf(_n)]); +} + +class _Leaf extends StatelessWidget { + const _Leaf(this.n); + + final int n; + + @override + Widget build(BuildContext context) => SizedBox(width: n.toDouble()); +} + +class _OtherLeaf extends StatelessWidget { + const _OtherLeaf(this.n); + + final int n; + + @override + Widget build(BuildContext context) => SizedBox(height: n.toDouble()); +} diff --git a/test/ui/dashboard_export_test.dart b/test/ui/dashboard_export_test.dart index 3adab1e7..30debdf4 100644 --- a/test/ui/dashboard_export_test.dart +++ b/test/ui/dashboard_export_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/ui/floating_issues_card.dart'; +import 'package:sleuth/src/utils/own_work_log.dart'; void main() { group('FloatingIssuesCard export', () { @@ -57,6 +60,52 @@ void main() { await tester.pump(const Duration(seconds: 3)); }); + testWidgets('the copy is recorded as Sleuth\'s own work', (tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + await tester.pumpWidget(buildCard()); + final before = OwnWorkLog.now(); + + await tester.tap(find.byIcon(Icons.ios_share)); + await tester.pump(); + expect(ownWorkLog.overlapUs(before, OwnWorkLog.now()), greaterThan(0)); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('a second tap while a copy runs is ignored', (tester) async { + var copies = 0; + final release = Completer(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copies++; + await release.future; + } + return null; + }, + ); + await tester.pumpWidget(buildCard()); + await tester.tap(find.byIcon(Icons.ios_share)); + await tester.pump(); + await tester.tap(find.byIcon(Icons.ios_share)); + await tester.pump(); + expect(copies, 1); + + release.complete(); + await tester.pump(); + await tester.pump(); + expect(find.text('Snapshot copied to clipboard'), findsOneWidget); + // The copy ended, so a new tap copies again. + await tester.pump(const Duration(seconds: 3)); + await tester.tap(find.byIcon(Icons.ios_share)); + await tester.pump(); + expect(copies, 2); + await tester.pump(const Duration(seconds: 3)); + }); + testWidgets('feedback banner appears on export tap', (tester) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, diff --git a/test/ui/floating_issues_card_test.dart b/test/ui/floating_issues_card_test.dart index cda62ca7..8f9b1655 100644 --- a/test/ui/floating_issues_card_test.dart +++ b/test/ui/floating_issues_card_test.dart @@ -297,7 +297,7 @@ void main() { expect(find.textContaining(bannerText), findsNothing); }); - testWidgets('new widget instance shows banner again after dismiss', ( + testWidgets('a dismissal lasts for the controller\'s session', ( tester, ) async { await tester.pumpWidget(pumpCard(isDebugMode: true)); @@ -310,11 +310,17 @@ void main() { await tester.pump(); expect(find.textContaining(bannerText), findsNothing); - // Force state disposal by pumping a different widget, then rebuild + // Closing and reopening the dashboard keeps it dismissed: the + // controller holds the dismissal for the session. await tester.pumpWidget(const MaterialApp(home: SizedBox())); await tester.pumpWidget(pumpCard(isDebugMode: true)); + expect(find.textContaining(bannerText), findsNothing); - // Banner should reappear (per-instance, not persisted) + // A new session shows it again. + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pumpWidget( + pumpCard(isDebugMode: true, config: const SleuthConfig()), + ); expect(find.textContaining(bannerText), findsOneWidget); }); }); diff --git a/test/ui/jank_verdict_linking_test.dart b/test/ui/jank_verdict_linking_test.dart index 2e42f396..f59d60c8 100644 --- a/test/ui/jank_verdict_linking_test.dart +++ b/test/ui/jank_verdict_linking_test.dart @@ -26,18 +26,44 @@ PerformanceIssue _testIssue({ ); } -FrameVerdict _testVerdict({List relatedIssues = const []}) { +FrameVerdict _testVerdict({ + List relatedIssues = const [], + int frameNumber = 1, + PipelinePhase phase = PipelinePhase.build, +}) { return FrameVerdict( - frameNumber: 1, + frameNumber: frameNumber, totalFrameTime: const Duration(milliseconds: 32), uiThreadTime: const Duration(milliseconds: 20), rasterThreadTime: const Duration(milliseconds: 12), - suspectedPhase: PipelinePhase.build, + suspectedPhase: phase, reason: 'Sustained jank: 4 severe frames', relatedIssues: relatedIssues, ); } +Future _pumpCard( + WidgetTester tester, + List issues, { + FrameVerdict? verdict, +}) async { + // Holds are timed on the controller's clock; follow the test's time. + SleuthController.clockOverrideForTest = () => tester.binding.clock.now(); + addTearDown(() => SleuthController.clockOverrideForTest = null); + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.verdictNotifier.value = verdict; + controller.issuesNotifier.value = issues; + await tester.pumpWidget(_cardApp(controller)); + return controller; +} + +Widget _cardApp(SleuthController controller) => MaterialApp( + home: Scaffold( + body: FloatingIssuesCard(controller: controller, onClose: () {}), + ), +); + Widget _buildCard( PerformanceIssue issue, { bool jankCorrelated = false, @@ -78,7 +104,8 @@ void main() { controller.verdictNotifier.value = _testVerdict( relatedIssues: [issue1], ); // only issue1 - controller.issuesNotifier.value = [issue1, issue2]; // both + final jank = _testIssue(title: 'Jank', stableId: 'jank_detected'); + controller.issuesNotifier.value = [issue1, issue2, jank]; await tester.pumpWidget( MaterialApp( @@ -93,6 +120,156 @@ void main() { controller.dispose(); }); + + testWidgets('badge clears when the jank symptom leaves the reported list', ( + tester, + ) async { + final cause = _testIssue(title: 'Rebuilds', stableId: 'rebuild_activity'); + final jank = _testIssue(title: 'Jank', stableId: 'jank_detected'); + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.verdictNotifier.value = _testVerdict(relatedIssues: [cause]); + controller.issuesNotifier.value = [cause, jank]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FloatingIssuesCard(controller: controller, onClose: () {}), + ), + ), + ); + expect(find.text('JANK'), findsOneWidget); + + // The verdict notifier keeps the last jank frame's verdict. + controller.issuesNotifier.value = [cause]; + await tester.pump(); + expect(find.text('JANK'), findsNothing); + }); + + testWidgets('badge stays while the symptom is collapsed under its cause', ( + tester, + ) async { + final stall = _testIssue( + title: 'Event loop stall', + stableId: 'event_loop_stall', + severity: IssueSeverity.critical, + ); + final sustained = PerformanceIssue( + stableId: 'sustained_jank', + severity: IssueSeverity.critical, + category: IssueCategory.build, + confidence: IssueConfidence.confirmed, + title: 'Sustained Jank', + detail: 'Detail', + fixHint: 'Fix', + rootCauseIds: const ['event_loop_stall'], + ); + final controller = SleuthController()..initializeDetectorsForTest(); + addTearDown(controller.dispose); + controller.verdictNotifier.value = FrameVerdict( + frameNumber: 1, + totalFrameTime: const Duration(milliseconds: 40), + uiThreadTime: const Duration(milliseconds: 4), + rasterThreadTime: const Duration(milliseconds: 3), + suspectedPhase: PipelinePhase.schedulerDelay, + reason: 'Start delay', + relatedIssues: [stall], + ); + controller.issuesNotifier.value = [stall, sustained]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FloatingIssuesCard(controller: controller, onClose: () {}), + ), + ), + ); + // The symptom card is folded into its cause, and the cause keeps + // the tag. + expect(find.text('Sustained Jank'), findsNothing); + expect(find.text('JANK'), findsOneWidget); + }); + + testWidgets('the badge stays through a verdict that names other issues, ' + 'for the jank window', (tester) async { + final cause = _testIssue(title: 'Rebuilds', stableId: 'rebuild_activity'); + final jank = _testIssue(title: 'Jank', stableId: 'jank_detected'); + final controller = await _pumpCard(tester, [ + cause, + jank, + ], verdict: _testVerdict(relatedIssues: [cause])); + expect(find.text('JANK'), findsOneWidget); + + // A raster frame names no build issue. + controller.verdictNotifier.value = _testVerdict( + frameNumber: 2, + phase: PipelinePhase.raster, + ); + await tester.pump(const Duration(seconds: 9)); + expect(find.text('JANK'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + expect(find.text('JANK'), findsNothing); + }); + + testWidgets('closing and reopening the dashboard keeps what is left of ' + 'each hold', (tester) async { + final cause = _testIssue(title: 'Rebuilds', stableId: 'rebuild_activity'); + final jank = _testIssue( + title: 'Jank detected', + stableId: 'jank_detected', + ); + final controller = await _pumpCard(tester, [ + cause, + jank, + ], verdict: _testVerdict(relatedIssues: [cause])); + bool causeTagged() => tester + .widget( + find.byWidgetPredicate( + (w) => w is IssueCard && w.issue.stableId == 'rebuild_activity', + ), + ) + .jankCorrelated; + expect(causeTagged(), isTrue); + + // A raster frame names no build issue; then the dashboard closes. + controller.verdictNotifier.value = _testVerdict( + frameNumber: 2, + phase: PipelinePhase.raster, + ); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 3)); + + await tester.pumpWidget(_cardApp(controller)); + expect(causeTagged(), isTrue); + + // The hold ends 10 s after the verdict, not 10 s after reopening. + await tester.pump(const Duration(seconds: 4)); + expect(causeTagged(), isFalse); + }); + + testWidgets('a later verdict naming the issue restarts its hold', ( + tester, + ) async { + final cause = _testIssue(title: 'Rebuilds', stableId: 'rebuild_activity'); + final jank = _testIssue(title: 'Jank', stableId: 'jank_detected'); + final controller = await _pumpCard(tester, [ + cause, + jank, + ], verdict: _testVerdict(relatedIssues: [cause])); + + await tester.pump(const Duration(seconds: 6)); + controller.verdictNotifier.value = _testVerdict( + frameNumber: 2, + relatedIssues: [cause], + ); + await tester.pump(const Duration(seconds: 9)); + expect(find.text('JANK'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + expect(find.text('JANK'), findsNothing); + }); }); group('Edge cases', () { diff --git a/test/ui/overlay_hide_filter_test.dart b/test/ui/overlay_hide_filter_test.dart index 4f61a85c..832a2d3e 100644 --- a/test/ui/overlay_hide_filter_test.dart +++ b/test/ui/overlay_hide_filter_test.dart @@ -218,6 +218,28 @@ void main() { await drainToasts(tester); }); + testWidgets('a widget that is not on screen unticks the card', ( + tester, + ) async { + controller.issuesNotifier.value = [ + _issue('lay', category: IssueCategory.layout), + ]; + // A scan already ran and outlined nothing. + controller.scanTickNotifier.value = 1; + await pumpCard(tester); + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + expect( + find.textContaining('Widget not currently visible'), + findsOneWidget, + ); + expect(tester.widget(find.byType(Checkbox)).value, isFalse); + expect(controller.pendingIssueSelection, isNull); + expect(controller.selectedHighlightNotifier.value, isNull); + await drainToasts(tester); + }); + testWidgets('losing the highlighted issue clears the highlight', ( tester, ) async { diff --git a/test/ui/overlay_ux_improvements_test.dart b/test/ui/overlay_ux_improvements_test.dart index 1d8340b9..752fcde7 100644 --- a/test/ui/overlay_ux_improvements_test.dart +++ b/test/ui/overlay_ux_improvements_test.dart @@ -234,6 +234,8 @@ void main() { category: IssueCategory.build, ); controller.issuesNotifier.value = [issue]; + // A scan already ran and outlined nothing. + controller.scanTickNotifier.value = 1; await tester.pumpWidget(buildCard()); @@ -263,6 +265,8 @@ void main() { category: IssueCategory.build, ); controller.issuesNotifier.value = [issue]; + // A scan already ran and outlined nothing. + controller.scanTickNotifier.value = 1; await tester.pumpWidget(buildCard()); diff --git a/test/utils/ai_context_builder_test.dart b/test/utils/ai_context_builder_test.dart index 6d9cb6a8..8e429c54 100644 --- a/test/utils/ai_context_builder_test.dart +++ b/test/utils/ai_context_builder_test.dart @@ -594,7 +594,12 @@ void main() { flushPaintDurations: [800], rasterDurations: [3000], ), - relatedIssues: [makeIssue(title: 'Hidden secret title')], + relatedIssues: [ + makeIssue( + title: 'Hidden secret title', + category: IssueCategory.build, + ), + ], ); expect(verdict.reason, contains('Related: Hidden secret title')); diff --git a/test/utils/source_location_cache_test.dart b/test/utils/source_location_cache_test.dart index 33548294..66e0fe76 100644 --- a/test/utils/source_location_cache_test.dart +++ b/test/utils/source_location_cache_test.dart @@ -60,16 +60,6 @@ void main() { cache.clear(); expect(cache.length, 0); }); - - test('maxEntries defaults to 200', () { - final cache = SourceLocationCache(); - expect(cache.maxEntries, 200); - }); - - test('custom maxEntries respected', () { - final cache = SourceLocationCache(maxEntries: 10); - expect(cache.maxEntries, 10); - }); }); group('SourceLocationCache with widget tree', () { @@ -87,7 +77,7 @@ void main() { expect(location, contains('source_location_cache_test.dart:')); }); - testWidgets('lookup caches by widget type', (tester) async { + testWidgets('a second lookup of an element uses the cache', (tester) async { final cache = SourceLocationCache(); await tester.pumpWidget(const _TestWidget()); @@ -97,17 +87,21 @@ void main() { final second = cache.lookup(element); expect(first, isNotNull); - expect(second, first); // Same cached value + expect(second, first); expect(cache.length, 1); }); - testWidgets('two widgets of the same type share one entry', (tester) async { + // One widget type built in two places, as a `ValueListenableBuilder` + // is on two demo screens: each element reports its own call site. + testWidgets('one type from two call sites keeps both locations', ( + tester, + ) async { final cache = SourceLocationCache(); await tester.pumpWidget( const Directionality( textDirection: TextDirection.ltr, - child: Column(children: [_TestWidget(), _TestWidget()]), + child: Column(children: [_firstCallSite, _secondCallSite]), ), ); @@ -117,57 +111,25 @@ void main() { final second = cache.lookup(elements[1]); expect(first, isNotNull); - expect(second, first); - expect(cache.length, 1); - expect(cache.lookupStructured(elements[1])?.location, first); - }); - - testWidgets('bounded at maxEntries', (tester) async { - final cache = SourceLocationCache(maxEntries: 1); - - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: Column(children: [_TestWidget(), _OtherTestWidget()]), - ), - ); - - final element1 = tester.element(find.byType(_TestWidget)); - final element2 = tester.element(find.byType(_OtherTestWidget)); - - // First lookup fills the single slot. - final loc1 = cache.lookup(element1); - expect(loc1, isNotNull); - expect(cache.length, 1); - - // Second type cannot be cached — returns null. - final loc2 = cache.lookup(element2); - expect(loc2, isNull); - expect(cache.length, 1); + expect(second, isNotNull); + expect(second, isNot(first)); + expect(cache.length, 2); + expect(cache.lookupStructured(elements[1])?.location, second); }); - testWidgets('clear allows re-caching', (tester) async { - final cache = SourceLocationCache(maxEntries: 1); - - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: Column(children: [_TestWidget(), _OtherTestWidget()]), - ), - ); + testWidgets('clear forgets resolved elements', (tester) async { + final cache = SourceLocationCache(); - final element1 = tester.element(find.byType(_TestWidget)); - final element2 = tester.element(find.byType(_OtherTestWidget)); + await tester.pumpWidget(const _TestWidget()); - cache.lookup(element1); + final element = tester.element(find.byType(_TestWidget)); + final location = cache.lookup(element); expect(cache.length, 1); cache.clear(); expect(cache.length, 0); - // Now the other type can be cached. - final loc = cache.lookup(element2); - expect(loc, isNotNull); + expect(cache.lookup(element), location); expect(cache.length, 1); }); @@ -258,10 +220,6 @@ class _TestWidget extends StatelessWidget { Widget build(BuildContext context) => const SizedBox.shrink(key: Key('test')); } -class _OtherTestWidget extends StatelessWidget { - const _OtherTestWidget(); - - @override - Widget build(BuildContext context) => - const SizedBox.shrink(key: Key('other')); -} +// One widget type created at two call sites, on separate lines. +const Widget _firstCallSite = _TestWidget(); +const Widget _secondCallSite = _TestWidget(); diff --git a/test/utils/widget_location_test.dart b/test/utils/widget_location_test.dart index 1254df0f..a3673a5d 100644 --- a/test/utils/widget_location_test.dart +++ b/test/utils/widget_location_test.dart @@ -9,6 +9,41 @@ import 'package:sleuth/src/utils/widget_location.dart'; void main() { setUp(() => sourceLocationCache.clear()); + group('leafSourceLocation', () { + testWidgets('reads the leaf location back from a real chain', ( + tester, + ) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: TestParentWidget(child: TestLeafWidget()), + ), + ); + final element = tester.element(find.byType(TestLeafWidget)); + // Without widget creation tracking there is no location to read, and + // the test would pass on the null branch alone. + expect( + WidgetInspectorService.instance.isWidgetCreationTracked(), + isTrue, + reason: 'flutter test tracks widget creation by default', + ); + final location = leafSourceLocation(buildAncestorChain(element)); + expect(location, matches(RegExp(r'widget_location_test\.dart:\d+$'))); + expect(location, sourceLocationCache.lookup(element)); + }); + + test('no chain, no location on the leaf, or a malformed tail', () { + expect(leafSourceLocation(null), isNull); + expect(leafSourceLocation('Page (lib/page.dart:3) > Leaf'), isNull); + expect(leafSourceLocation('Leaf (not a location)'), isNull); + expect(leafSourceLocation('Leaf (lib/a.dart:12)'), 'lib/a.dart:12'); + expect( + leafSourceLocation('Page > Column > Leaf (lib/a.dart:12)'), + 'lib/a.dart:12', + ); + }); + }); + group('buildAncestorChain with source location', () { testWidgets('appends file:line to leaf widget', (tester) async { await tester.pumpWidget( diff --git a/test/validation/mcp_schema_audit_test.dart b/test/validation/mcp_schema_audit_test.dart index a0f5cf51..f2bb321f 100644 --- a/test/validation/mcp_schema_audit_test.dart +++ b/test/validation/mcp_schema_audit_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/sleuth.dart' show Sleuth, StartupMetrics; import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/detectors/platform_channel_detector.dart'; import 'package:sleuth/src/models/base_detector.dart'; import 'package:sleuth/src/models/frame_stats.dart'; import 'package:sleuth/src/models/heap_sample.dart'; @@ -443,10 +444,33 @@ void main() { as Map)['item_shape'] as Map; + Map notUsefulMarkShape() => + (dataShape('ext.sleuth.snapshot')['notUsefulMarks'] + as Map)['item_shape'] + as Map; + + Map measurementShape() => + (notUsefulMarkShape()['measurement'] as Map)['shape'] + as Map; + + /// An issue as PlatformChannelDetector emits it: 25 calls in one second + /// against a 20/s line, so it carries a measurement. + PerformanceIssue measuredIssue() { + var now = DateTime(2026); + final detector = PlatformChannelDetector(clock: () => now); + addTearDown(detector.dispose); + detector.processTimelineData(platformChannelData(channelEventCount: 25)); + now = now.add(const Duration(seconds: 2)); + detector.processTimelineData(emptyTimelineData()); + return detector.issues.single; + } + /// String references in the schema, resolved to the shapes they name. Map> refs() => { 'see ext.sleuth.snapshot.data.routeSessions item_shape': routeSessionShape(), + 'see ext.sleuth.snapshot.data.notUsefulMarks item_shape': + notUsefulMarkShape(), 'same as ext.sleuth.explain.data.explanation': explanationShape(), }; @@ -709,6 +733,54 @@ void main() { }, ); + test('a detector measurement matches the documented shape', () { + final issue = measuredIssue(); + final json = jsonDecode(jsonEncode(issue.toJson())) as Map; + expect(json, contains('measurement')); + _expectMatchesShape( + measurementShape(), + (json['measurement'] as Map).cast(), + path: 'issue.measurement', + ); + }); + + test('notUsefulMarks emits once an issue is marked and matches the ' + 'documented shape', () async { + final issue = measuredIssue(); + final c = _newController()..seedIssuesForTest([issue]); + final before = await extSnapshotHandler(c, const {}); + expect( + (before['data'] as Map).containsKey('notUsefulMarks'), + isFalse, + ); + + c.markNotUseful(issue); + for (final args in const [ + {}, + {'sections': 'currentIssues'}, + ]) { + final env = await extSnapshotHandler(c, args); + final data = env['data'] as Map; + final marks = (data['notUsefulMarks'] as List).cast(); + expect(marks.single['live'], isTrue); + expect(marks.single['measurement'], isNotNull); + _expectMatchesShape( + args.isEmpty + ? dataShape('ext.sleuth.snapshot') + : _projectedSnapshotShape(dataShape('ext.sleuth.snapshot'), data), + data, + path: 'snapshot.data$args', + ); + } + + // The issue leaves the reported list; its mark stays, not live. + c.seedIssuesForTest(const []); + final env = await extSnapshotHandler(c, const {}); + final marks = ((env['data'] as Map)['notUsefulMarks'] as List) + .cast(); + expect(marks.single['live'], isFalse); + }); + test('recentRequests emits once the network buffer is non-empty', () async { // Precondition triad (see exportSnapshot): _initialized AND // _networkMonitor.isEnabled AND records.isNotEmpty. Drive each. @@ -1116,6 +1188,7 @@ void main() { // Keys explicitly exercised in this group (presence tests above): const exercisedHere = { 'suppressedCount', + 'notUsefulMarks', 'startupMetrics', 'recentRequests', 'heapSamples', @@ -1282,6 +1355,36 @@ void main() { ); } }); + + test('notUsefulMarks sits beside the issues once one is marked', () async { + final issue = measuredIssue(); + final c = _newController()..seedIssuesForTest([issue]); + expect( + ((await extIssuesHandler(c, const {}))['data'] as Map).containsKey( + 'notUsefulMarks', + ), + isFalse, + ); + c.markNotUseful(issue); + for (final args in const [ + {}, + {'route': '/elsewhere'}, + ]) { + final env = await extIssuesHandler(c, args); + final data = env['data'] as Map; + expect((data['notUsefulMarks'] as List), hasLength(1)); + final skipped = _expectMatchesShape( + dataShape('ext.sleuth.issues'), + data, + path: 'issues.data$args', + refs: refs(), + ); + expect( + skipped, + isNot(contains(startsWith('issues.data$args.notUseful'))), + ); + } + }); }); group('ext.sleuth.routeHealth', () {