From 04795012c688af75d7462f0050c6276cc9afc136 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 12:58:25 +0700 Subject: [PATCH 1/6] feat: overlay data for the 1.0.0 card - FrameVerdict.relatedIssues keeps only the issues that can explain the verdict's phase: the phase's category, event_loop_stall for a start delay, none for a stall. Jank symptoms are never listed, and the JANK tag shows only while jank_detected or sustained_jank is reported. - An internal coverage report and run state for the coming status lines: which checks run, need the VM or an opt-in, the warm-up phase, and the reconnect attempt. The debug banner dismissal moves to the controller for the session. - The highlight lookup returns found, not found or pending. It matches the issue's own element first (WidgetHighlight.occurrenceId), then the widget name, and no longer outlines another widget of the same category. A not-found card unticks instead of outlining later. - Not useful marks: a session store in the controller, NotUsefulMark, and an always-on notUsefulMarks key on the snapshot and ext.sleuth.issues. The schema documents it and the issue measurement shape, with presence tests driven by a real detector emission. The sidecar lists the key in snapshotMetadataKeys. - DebugSnapshot.forcedRebuildsOf records which widget's build forced each parent-driven rebuild, framework widgets included. - leafSourceLocation reads the leaf file:line of an ancestor chain. The snapshot copy counts as Sleuth's own work and ignores a second tap. No detection decision changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 +++ doc/mcp_schema.json | 32 ++- doc/mcp_schema.md | 31 +- lib/sleuth.dart | 3 +- lib/src/analyzer/jank_relation.dart | 39 +++ .../analyzer/render_pipeline_analyzer.dart | 14 +- lib/src/controller/coverage_report.dart | 110 +++++++ lib/src/controller/highlight_lookup.dart | 13 + lib/src/controller/run_state.dart | 65 +++++ lib/src/controller/sleuth_controller.dart | 272 ++++++++++++++---- .../debug_instrumentation_coordinator.dart | 67 ++++- lib/src/debug/debug_snapshot.dart | 13 + .../detectors/layout_bottleneck_detector.dart | 1 + lib/src/detectors/listview_detector.dart | 7 + .../detectors/repaint_boundary_detector.dart | 1 + .../detectors/tracked_resource_detector.dart | 5 + lib/src/models/frame_verdict.dart | 5 +- lib/src/models/not_useful_mark.dart | 169 +++++++++++ lib/src/models/session_snapshot.dart | 15 + .../models/simple_structural_detector.dart | 1 + lib/src/models/snapshot_sections.dart | 11 +- lib/src/models/widget_highlight.dart | 6 + lib/src/ui/floating_issues_card.dart | 81 ++++-- lib/src/ui/issue_card.dart | 5 +- lib/src/utils/widget_location.dart | 14 + lib/src/vm/service_extension_handlers.dart | 2 + packages/sleuth_mcp/CHANGELOG.md | 9 + packages/sleuth_mcp/doc/mcp_schema.json | 32 ++- packages/sleuth_mcp/doc/mcp_schema.md | 31 +- .../lib/src/tools/snapshot_sections.dart | 1 + test/analyzer/jank_relation_test.dart | 110 +++++++ test/controller/coverage_run_state_test.dart | 241 ++++++++++++++++ .../frame_verdict_fallback_test.dart | 16 +- test/controller/highlight_behavior_test.dart | 164 ++++++++--- test/controller/not_useful_store_test.dart | 200 +++++++++++++ test/debug/forced_rebuilds_of_test.dart | 209 ++++++++++++++ test/ui/dashboard_export_test.dart | 49 ++++ test/ui/floating_issues_card_test.dart | 12 +- test/ui/jank_verdict_linking_test.dart | 72 ++++- test/ui/overlay_hide_filter_test.dart | 22 ++ test/ui/overlay_ux_improvements_test.dart | 4 + test/utils/ai_context_builder_test.dart | 7 +- test/utils/widget_location_test.dart | 35 +++ test/validation/mcp_schema_audit_test.dart | 103 +++++++ 44 files changed, 2174 insertions(+), 157 deletions(-) create mode 100644 lib/src/analyzer/jank_relation.dart create mode 100644 lib/src/controller/coverage_report.dart create mode 100644 lib/src/controller/highlight_lookup.dart create mode 100644 lib/src/controller/run_state.dart create mode 100644 lib/src/models/not_useful_mark.dart create mode 100644 test/analyzer/jank_relation_test.dart create mode 100644 test/controller/coverage_run_state_test.dart create mode 100644 test/controller/not_useful_store_test.dart create mode 100644 test/debug/forced_rebuilds_of_test.dart 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..4c5e2949 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'; @@ -432,6 +436,25 @@ class SleuthController { /// 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 +534,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 +811,65 @@ 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; + pending.onResolved?.call(_selectHighlight(pending.issue)); + } - // Map issue category to likely detector names - final detectorNames = detectorNamesForCategory(issue.category); - for (final name in detectorNames) { + 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; + } + + static WidgetHighlight? _highlightOf( + PerformanceIssue issue, + List highlights, + ) { + final occurrence = issue.occurrenceId; + if (occurrence != null) { for (final h in highlights) { - if (h.detectorName == name) { - selectedHighlightNotifier.value = h; - highlightEnabledNotifier.value = true; - return true; - } + if (h.occurrenceId == occurrence) return h; } } - - 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 +1095,8 @@ class SleuthController { if (!connected && !_disposed) { _scheduleBackgroundReconnect(); } + _refreshCoverage(); + _refreshRunState(); } /// Schedule the next background reconnect attempt with exponential backoff. @@ -1035,10 +1118,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 +1382,7 @@ class SleuthController { ]; _detectorsReady = true; _reresolveFrameBudget(force: true); + _refreshCoverage(); } // -- Frame budget -- @@ -1490,6 +1576,7 @@ class SleuthController { void initializeDetectorsForTest() { _initializeDetectors(); _installDebugInstrumentation(); + _refreshCoverage(); } /// Inject a detector into the live `_detectors` list so tests can exercise @@ -1541,6 +1628,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 +1736,7 @@ class SleuthController { _lastScanContext = context; _runStructuralScans(context); _collectHighlights(); + if (highlightEnabledNotifier.value) _resolvePendingHighlight(); _aggregateIssues(); if (_latestIssues.isEmpty) { _consecutiveCleanScans++; @@ -1751,6 +1848,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 +2112,7 @@ class SleuthController { sessionSummary: summary.isNotEmpty ? summary : null, startupMetrics: Sleuth.startupMetrics, routeSessions: routeSessions, + notUsefulMarks: notUsefulMarksForExport(), ); } @@ -3058,11 +3165,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 +3543,7 @@ class SleuthController { detectorName: h.detectorName, detail: h.detail, renderObject: ro, + occurrenceId: h.occurrenceId, ), ); } @@ -4754,6 +4858,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 +5637,8 @@ class SleuthController { _backgroundReconnectTimer?.cancel(); _backgroundReconnectTimer = null; _backgroundReconnectActive = false; + _warmupTimer?.cancel(); + _warmupTimer = null; _overlayContext = null; _lastScanContext = null; _appChildContext = null; @@ -5541,10 +5699,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..3d29d26d 100644 --- a/lib/src/debug/debug_instrumentation_coordinator.dart +++ b/lib/src/debug/debug_instrumentation_coordinator.dart @@ -117,17 +117,23 @@ class DebugInstrumentationCoordinator { final Map _paintCounts = {}; final Map _ancestorChains = {}; final Map _forcedRebuildsByRoot = {}; + 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 or null, framework 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, and in [_forcedRebuildsOf] either way; a framework + /// entry's name is looked up only then. Cleared when a new frame starts. + final List<({String? type, Type? frameworkType, 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 +464,11 @@ class DebugInstrumentationCoordinator { ? RebuildCountSource.debugCallback : RebuildCountSource.none, forcedRebuildsByRoot: Map.of(_forcedRebuildsByRoot), + forcedRebuildsOf: { + for (final e in _forcedRebuildsOf.entries) + e.key: Map.of(e.value), + }, + 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, + frameworkType: widget.runtimeType, + depth: depth, + )); + } return; } // First observation of this element = initial build (don't count). @@ -882,14 +901,23 @@ 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; + } + final forcing = + rootType ?? + (root.frameworkType == null ? null : _typeName(root.frameworkType!)); + if (forcing != null) { + _recordForcedBy(_typeName(widget.runtimeType), forcing); } return; } final typeName = _typeName(widget.runtimeType); - _buildRoots.add((type: typeName, depth: depth)); + _buildRoots.add((type: typeName, frameworkType: null, depth: depth)); if (_rebuildCounts.length >= _maxTrackedTypes && !_rebuildCounts.containsKey(typeName)) { _rebuildTypesCapped = true; @@ -909,6 +937,23 @@ class DebugInstrumentationCoordinator { } } + void _recordForcedBy(String forced, String 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; + } + /// 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..54b16519 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -7,6 +7,8 @@ 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'; @@ -21,6 +23,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 +453,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; @@ -1200,31 +1205,45 @@ class _FloatingIssuesCardState extends State } /// Stable keys from verdict.relatedIssues that match current issuesNotifier. + /// + /// The verdict stays after the jank ends, so keys are returned only while + /// a jank symptom is reported. The reported list is checked, not the + /// visible one: collapsing, hiding or filtering a symptom keeps the tag. Set _matchingIssueKeys(FrameVerdict? verdict) { if (verdict == null || verdict.relatedIssues.isEmpty) return const {}; + final current = widget.controller.issuesNotifier.value; + if (!current.any(isJankSymptom)) 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, + for (final issue in current) 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 +1254,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 +1741,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..798df60f 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 latest jank frame's phase, 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/widget_location.dart b/lib/src/utils/widget_location.dart index e3f48033..a9828bea 100644 --- a/lib/src/utils/widget_location.dart +++ b/lib/src/utils/widget_location.dart @@ -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/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..4f0510d1 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,77 @@ 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( 'GpuPressureDetector highlights flow through to highlightsNotifier', (tester) async { @@ -348,14 +420,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 +451,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 +500,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 +580,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/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..59aaa315 100644 --- a/test/ui/jank_verdict_linking_test.dart +++ b/test/ui/jank_verdict_linking_test.dart @@ -78,7 +78,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 +94,75 @@ 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); + }); }); 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/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', () { From 469c06dce9a60a826d4c70c119e46525c95d4356 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 12:58:25 +0700 Subject: [PATCH 2/6] test: overlay build-cost benchmark with a recorded budget The overlay builds inside the app's frames, outside Sleuth's own-work log, so extra card builds raise the app's rebuild_activity share. The benchmark pumps 20 issues, collapsed and with three expanded, through 50 scan ticks without a change and 50 with a title change, and fails when FloatingIssuesCard or IssueCard builds more often than the counts recorded before the card redesign. Build time is printed, not asserted. Co-Authored-By: Claude Opus 5.5 --- test/benchmark/overlay_build_cost_test.dart | 133 ++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 test/benchmark/overlay_build_cost_test.dart 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); + } +} From c6ce3b9977a2e5dc881e1f5bb1a2da036119e21b Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 13:10:20 +0700 Subject: [PATCH 3/6] fix: highlights keep their element; forced-rebuild map costs no lookups - The selected highlight follows its own element when the scan or a rect refresh republishes highlights. Before, it moved to the first widget of the same name and detector, and to a sibling when its element left; now it clears instead. - A pending lookup selects after the first scan even without a result callback. The null-aware call skipped the lookup itself. - The debug rebuild hook keys the forced-rebuild map by Type and names the types once per snapshot. With 200 forced rebuilds a frame, frame time is within measurement noise of the hook before the map (median 1,366 vs 1,347 us over five interleaved runs). Co-Authored-By: Claude Opus 5.5 --- lib/src/controller/sleuth_controller.dart | 14 ++- .../debug_instrumentation_coordinator.dart | 54 ++++++---- test/controller/highlight_behavior_test.dart | 102 ++++++++++++++++++ 3 files changed, 146 insertions(+), 24 deletions(-) diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 4c5e2949..9eaa237c 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -843,7 +843,8 @@ class SleuthController { final pending = _pendingHighlight; if (pending == null) return; _pendingHighlight = null; - pending.onResolved?.call(_selectHighlight(pending.issue)); + final result = _selectHighlight(pending.issue); + pending.onResolved?.call(result); } HighlightLookup _selectHighlight(PerformanceIssue issue) { @@ -3562,10 +3563,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; } diff --git a/lib/src/debug/debug_instrumentation_coordinator.dart b/lib/src/debug/debug_instrumentation_coordinator.dart index 3d29d26d..93f7348c 100644 --- a/lib/src/debug/debug_instrumentation_coordinator.dart +++ b/lib/src/debug/debug_instrumentation_coordinator.dart @@ -117,20 +117,23 @@ class DebugInstrumentationCoordinator { final Map _paintCounts = {}; final Map _ancestorChains = {}; final Map _forcedRebuildsByRoot = {}; - final Map> _forcedRebuildsOf = {}; + + /// 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, framework 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, and in [_forcedRebuildsOf] either way; a framework - /// entry's name is looked up only then. Cleared when a new frame starts. - final List<({String? type, Type? frameworkType, 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; @@ -464,10 +467,7 @@ class DebugInstrumentationCoordinator { ? RebuildCountSource.debugCallback : RebuildCountSource.none, forcedRebuildsByRoot: Map.of(_forcedRebuildsByRoot), - forcedRebuildsOf: { - for (final e in _forcedRebuildsOf.entries) - e.key: Map.of(e.value), - }, + forcedRebuildsOf: _namedForcedRebuilds(), forcedTypesCapped: _forcedTypesCapped, rebuildTypesCapped: _rebuildTypesCapped, paintTypesCapped: _paintTypesCapped, @@ -881,7 +881,7 @@ class DebugInstrumentationCoordinator { if (dirty) { _buildRoots.add(( type: null, - frameworkType: widget.runtimeType, + widgetType: widget.runtimeType, depth: depth, )); } @@ -908,16 +908,15 @@ class DebugInstrumentationCoordinator { _forcedRebuildsByRoot[rootType] = (_forcedRebuildsByRoot[rootType] ?? 0) + 1; } - final forcing = - rootType ?? - (root.frameworkType == null ? null : _typeName(root.frameworkType!)); - if (forcing != null) { - _recordForcedBy(_typeName(widget.runtimeType), forcing); - } + _recordForcedBy(widget.runtimeType, root.widgetType); return; } final typeName = _typeName(widget.runtimeType); - _buildRoots.add((type: typeName, frameworkType: null, depth: depth)); + _buildRoots.add(( + type: typeName, + widgetType: widget.runtimeType, + depth: depth, + )); if (_rebuildCounts.length >= _maxTrackedTypes && !_rebuildCounts.containsKey(typeName)) { _rebuildTypesCapped = true; @@ -937,7 +936,7 @@ class DebugInstrumentationCoordinator { } } - void _recordForcedBy(String forced, String forcing) { + void _recordForcedBy(Type forced, Type forcing) { var roots = _forcedRebuildsOf[forced]; if (roots == null) { if (_forcedRebuildsOf.length >= _maxTrackedTypes) { @@ -954,6 +953,19 @@ class DebugInstrumentationCoordinator { 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/test/controller/highlight_behavior_test.dart b/test/controller/highlight_behavior_test.dart index 4f0510d1..61d4473d 100644 --- a/test/controller/highlight_behavior_test.dart +++ b/test/controller/highlight_behavior_test.dart @@ -175,6 +175,108 @@ void main() { 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 { From 075ebb2d1a9e2408457af432ea1afda1b5b29fcb Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Sat, 24 Oct 2026 00:35:03 +0700 Subject: [PATCH 4/6] fix: a widget's source location is its own call site SourceLocationCache kept one location per widget type, so a type built in several places reported the first place it resolved. On an iPhone the Rebuild Hotspot screen's ValueListenableBuilder showed high_level_setstate_demo.dart:75, and the card's Where row and its Copy gave that line. The cache now keeps one entry per element in an Expando. Each element reports its own call site, and entries go with their elements. Co-Authored-By: Claude Opus 5.5 --- lib/src/utils/source_location_cache.dart | 77 ++++++++------------ lib/src/utils/widget_location.dart | 2 +- test/utils/source_location_cache_test.dart | 84 ++++++---------------- 3 files changed, 52 insertions(+), 111 deletions(-) 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 a9828bea..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(); 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(); From b54208052ad6bec3a05552641e6b3233e24a5f99 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Sat, 24 Oct 2026 02:00:58 +0700 Subject: [PATCH 5/6] fix: an issue that names its element never takes a sibling's outline The first lookup fell back to the widget name when the issue's own element had no highlight, for instance after a scroll dropped it. With two ListViews, the crosshair on the first list's card then outlined the second list, and the outline stayed on it after the first came back. An issue with an occurrence id now takes that element's highlight or none, as the re-acquire path already does. Co-Authored-By: Claude Opus 5.5 --- lib/src/controller/sleuth_controller.dart | 3 ++ test/controller/highlight_behavior_test.dart | 39 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 9eaa237c..853d8412 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -861,9 +861,12 @@ class SleuthController { ) { 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.occurrenceId == occurrence) return h; } + return null; } final name = issue.widgetName; if (name == null) return null; diff --git a/test/controller/highlight_behavior_test.dart b/test/controller/highlight_behavior_test.dart index 61d4473d..93b36f53 100644 --- a/test/controller/highlight_behavior_test.dart +++ b/test/controller/highlight_behavior_test.dart @@ -365,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; From 6135a87042c92dd803a005005d59b7172f33dcbe Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 26 Oct 2026 22:44:45 +0700 Subject: [PATCH 6/6] fix: the Jank tag holds for the jank window instead of blinking The card tagged only the issues the latest verdict named. Late frames alternate between phases, and a raster or start-delay frame names no build issue, so on a busy screen the tag went on and off about once a second (23 changes in 30 s on FPS Stress, Galaxy A07). The controller now notes when a verdict last named each issue, and heldJankCauses returns those named within the 10 s jank window jank_detected judges, with the time until the first one leaves. The card tags the held issues while a jank symptom is reported and arms one timer for the next expiry. The hold lives in the controller, so closing and reopening the dashboard neither drops it nor restarts it, and a route change clears it, as it starts a new jank window. Co-Authored-By: Claude Opus 5.5 --- lib/src/controller/sleuth_controller.dart | 47 ++++++++ lib/src/ui/floating_issues_card.dart | 39 +++--- lib/src/ui/issue_card.dart | 4 +- .../route_session_lifecycle_test.dart | 39 ++++++ test/ui/jank_verdict_linking_test.dart | 113 +++++++++++++++++- 5 files changed, 223 insertions(+), 19 deletions(-) diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 853d8412..c8aac49f 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -80,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'; @@ -113,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; @@ -433,6 +437,48 @@ 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); @@ -3098,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(); diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index 54b16519..65a870ef 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -12,7 +12,6 @@ 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'; @@ -476,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; @@ -706,6 +708,7 @@ class _FloatingIssuesCardState extends State timer.cancel(); } _newKeyTimers.clear(); + _jankHoldTimer?.cancel(); _preTransitionOffset = null; _preTransitionWidth = null; _preTransitionHeight = null; @@ -1071,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); } @@ -1204,22 +1215,22 @@ class _FloatingIssuesCardState extends State ..updateAll((key, _) => positions[key]!); } - /// Stable keys from verdict.relatedIssues that match current issuesNotifier. + /// 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. /// - /// The verdict stays after the jank ends, so keys are returned only while - /// a jank symptom is reported. The reported list is checked, not the + /// 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(FrameVerdict? verdict) { - if (verdict == null || verdict.relatedIssues.isEmpty) return const {}; + Set _matchingIssueKeys(Set held) { + if (held.isEmpty) return const {}; final current = widget.controller.issuesNotifier.value; if (!current.any(isJankSymptom)) return const {}; - final verdictKeys = { - for (final ri in verdict.relatedIssues) ri.stableId ?? ri.title, - }; - final currentKeys = { - for (final issue in current) issue.stableId ?? issue.title, + return { + for (final issue in current) + if (held.contains(issue.stableId ?? issue.title)) + issue.stableId ?? issue.title, }; - return verdictKeys.intersection(currentKeys); } Future _exportToClipboard() async { diff --git a/lib/src/ui/issue_card.dart b/lib/src/ui/issue_card.dart index 798df60f..2c1f8fd4 100644 --- a/lib/src/ui/issue_card.dart +++ b/lib/src/ui/issue_card.dart @@ -84,8 +84,8 @@ class IssueCard extends StatefulWidget { final bool deepInstrumentationActive; /// When true, shows a "JANK" badge in the collapsed header: this issue - /// can explain the latest jank frame's phase, and jank is still - /// reported. + /// 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/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/ui/jank_verdict_linking_test.dart b/test/ui/jank_verdict_linking_test.dart index 59aaa315..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, @@ -163,6 +189,87 @@ void main() { 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', () {