Performance
This release is mostly about speed. Two changes carry it:
- Improvement: Relative queries (
withParent,withChild, chained selectors) are up to 200x faster on big widget trees. Relationships are now resolved by walking up the tree instead of searching the subtree of every parent. #148 - Improvement: A frame's screenshot is rasterized once and reused by every assertion in that frame, instead of photographing the same unchanged screen over and over. Annotations are reused the same way, per test. On one app's suite, capture time went from 9.7s to 1.9s over 261 screenshots. #160
The rest:
- Improvement:
hasDiagnosticProp,getDiagnosticPropandwithDiagnosticPropnow cachedebugFillProperties. ~1.5x faster #159 - Improvement: The source location of a widget is resolved once per widget instead of on every lookup, which speeds up
act.tapAt()timeline events and the diagnostics behind failingact.tap()calls. #154 - Fix: Assertions like
spotKey(key).existsOnce()were extremely slow (tens of seconds) when no match was found in a large widget tree. The error output is now limited and match-all selectors are no longer suggested as "less specific" matches. #119
Tap
-
New:
act.inspectTap()reports whether a widget can be tapped and why not, as a value instead of a thrown error #150final inspection = act.inspectTap(spot<ElevatedButton>()); expect(inspection.canTap, isFalse); // the button is behind a full-screen overlay expect( inspection.tapFailure?.tapCoveredReason.primaryCover?.widget, isA<ColoredBox>(), );
Available reasons:
TapNotFoundReason,TapMultipleWidgetsFoundReason,TapNoRenderObjectReason,TapNonRenderBoxReason,TapOutsideViewportReason,TapAbsorbedReason,TapIgnoredReason,TapOffstageReason,TapZeroSizeReason,TapCoveredReasonandTapUnknownReason. Also addTapInspection,TapFailureReason,TapWidgetInfo,TapHitTestInfo,TapHitSample,TapSamplesandTapBlocker.TapInspection.samplesreports how much of the widget reacts to pointer events and what is in the way, for tappable widgets too. A widget that is tappable but only partially reachable has no failure to assert on, so assert on the samples.final samples = act.inspectTap(spot<ElevatedButton>()).samples!; print('${samples.hittablePercent}% of the button reacts to taps'); for (final blocker in samples.blockers) { print('${blocker.receiver.widgetName} covers ${blocker.percent}%'); }
Sampling hit tests the whole widget on a grid, which costs far more than the rest of the inspection, so it happens on the first read of
samplesinstead of up front. An inspection describes the tree of the frame it was created in, so readingsamplesafter a pump throws instead of reporting what a different tree does. -
New:
act.tap()throws aTapFailurethat carries theTapInspectionexplaining the failure, so the reason can be asserted without matching on the message.TapFailureextendsTestFailure, existing expectations keep working. #150await expectLater( () => act.tap(spot<ElevatedButton>()), throwsA( isA<TapFailure>().having( (it) => it.inspection.tapFailure?.reason, 'reason', isA<TapCoveredReason>(), ), ), );
-
Fix:
act.tap()now finds anAbsorbPointeranywhere above the target. It previously only looked directly below the widget #150 -
Fix:
act.tap()now reports the outermostAbsorbPointerorIgnorePointerabove the target instead of the closest one #150 -
New:
act.tap()explains offstage widgets instead of reporting an unknown reason #150
Timeline
- New: The timeline counts every frame the test rendered, not just the ones something was recorded in, and the report shows the total. Fewer frames is a faster test, so it is worth seeing which
pumpAndSettlecould have been apump. Frames are labelled with their real number, and the stretches between recorded frames appear as a gap showing how many frames went by and how long they took on both clocks. Gaps hold nothing to select, so the arrow keys step straight over them. Also addsTimeline.renderedFrameCountandTimelineEvent.renderedFrameNumber. - New: Whatever failed the test is now the last event of the timeline, in a frame of its own, and the HTML report opens on it. Previously only spot's own assertions reported their failure, so a plain
expector an exception from the widget under test left the report ending at the last thing that worked. The event carries the real error message, a stack trace with the test framework folded out, a capture of the screen as the test left it, and the line that threw. - Fix: A run that reports nothing now deletes the report an earlier run of the same test wrote. The old report used to stay on disk, so the link printed by the earlier run kept opening it and showed the source, events and captures of a run that no longer existed, which reads as the timeline being stale rather than absent.
- Fix: Restore screenshots and interactivity in the hot-restart timeline.
Scrolling
- Improvement:
act.dragUntilVisible()can now use any selector that resolves to aScrollableasdragStart, so keyed or otherwise untyped scrollable selectors drag from that scrollable directly. #133 (thx @trejdych)
Selectors and queries
- New:
spotAtPositionandWidgetSelector.atPositionto query widgets on the hit-test path for a global screen position. #28 - New:
WidgetSelector.isPresent()andisAbsent()returnboolwithout failing the test, andcountWidgets()returns the number of matching widgets. Use them to branch test logic on the presence, absence or quantity of a widget. #30if (spot<Tooltip>().withMessage('Open navigation menu').isPresent()) { // ... } if (spot<Tooltip>().withMessage('Close menu').isAbsent()) { // ... } final buttonCount = spot<ElevatedButton>().countWidgets(); final hasTwoButtons = spot<ElevatedButton>().countWidgets() == 2; final hasAtLeastTwoButtons = spot<ElevatedButton>().countWidgets() >= 2;
- New:
getDiagnosticProp<T>('name')is now also available onWidgetSelector, alongside the existinggetWidgetProp,getElementProp,getStatePropandgetRenderObjectPropreaders. #30final message = spot<Tooltip>().getDiagnosticProp<String>('message');
- New:
WidgetSelector<AnyText>.whereIsEditable()andwhereIsNotEditable()filter text matches by whether they come from an editable text input.spotText('username').whereIsEditable().existsOnce(); spotText('Username').whereIsNotEditable().existsOnce();
- New:
WidgetSnapshot.queryStatsreports how much work the query engine performed to evaluate a selector, useful to debug slow queries. #148 - Fix: A
WidgetMatchernow always reports the widget of the frame it matched, not the current widget in the tree #159 - Improvement: Untyped selectors (
spot,spotKey,spotWidget,spotElement,spotTexts) no longer add a no-opWidgetTypeFilter<Widget>at the root.
Text matching
- New: Text matching ignores invisible and special whitespace, so tests can use regular characters.
spotText,spotTextWhere,whereText,withTextandhasTextstrip invisible characters (zero width space, soft hyphen, word joiner, BOM) and fold every Unicode space separator (Zs, e.g. non-breaking space) to a regular space. Meaningful characters (zero width joiner, bidi controls, theU+FFFCWidgetSpan placeholder) and line breaks are kept. #138 (thx @MichaelTamm)To match exact characters, passspotText('foobar').existsOnce(); // matches Text('foo\u{200B}bar') spotText('foo bar').existsOnce(); // matches Text('foo\u{00A0}bar')
raw: truetospotText/spotTextWhere, or usewhereRawText/withRawText/hasRawTextonWidgetSelector<AnyText>. Also exposesAnyText.normalizeVisibleText,AnyText.extractText, andAnyTextContent(raw/normalized). - Deprecated:
spotText(text, exact: true)is nowspotText(text, whole: true)— the flag controls whole-string vs. substring matching, not character handling.exactstill works. #138
Screenshots
- New:
ScreenshotAnnotator.cacheKey(default =>null) allows caching of annotations #160 - Fix: Export
ScreenshotAnnotator, which has already been a parameter oftakeScreenshot(annotators: ...)
Fonts
- Fix:
loadAppFonts()now also registers a package's own fonts underpackages/<self>/MyFont, so fonts referenced viapackage: '<self>'render instead of falling back to Ahem. #141