Skip to content

v0.20.0

Latest

Choose a tag to compare

@passsy passsy released this 11 Aug 12:23
· 1 commit to main since this release
v0.20.0
032dbdc

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, getDiagnosticProp and withDiagnosticProp now cache debugFillProperties. ~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 failing act.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 #150

    final 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, TapCoveredReason and TapUnknownReason. Also add TapInspection, TapFailureReason, TapWidgetInfo, TapHitTestInfo, TapHitSample, TapSamples and TapBlocker.

    TapInspection.samples reports 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 samples instead of up front. An inspection describes the tree of the frame it was created in, so reading samples after a pump throws instead of reporting what a different tree does.

  • New: act.tap() throws a TapFailure that carries the TapInspection explaining the failure, so the reason can be asserted without matching on the message. TapFailure extends TestFailure, existing expectations keep working. #150

    await expectLater(
      () => act.tap(spot<ElevatedButton>()),
      throwsA(
        isA<TapFailure>().having(
          (it) => it.inspection.tapFailure?.reason,
          'reason',
          isA<TapCoveredReason>(),
        ),
      ),
    );
  • Fix: act.tap() now finds an AbsorbPointer anywhere above the target. It previously only looked directly below the widget #150

  • Fix: act.tap() now reports the outermost AbsorbPointer or IgnorePointer above 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 pumpAndSettle could have been a pump. 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 adds Timeline.renderedFrameCount and TimelineEvent.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 expect or 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 a Scrollable as dragStart, so keyed or otherwise untyped scrollable selectors drag from that scrollable directly. #133 (thx @trejdych)

Selectors and queries

  • New: spotAtPosition and WidgetSelector.atPosition to query widgets on the hit-test path for a global screen position. #28
  • New: WidgetSelector.isPresent() and isAbsent() return bool without failing the test, and countWidgets() returns the number of matching widgets. Use them to branch test logic on the presence, absence or quantity of a widget. #30
    if (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 on WidgetSelector, alongside the existing getWidgetProp, getElementProp, getStateProp and getRenderObjectProp readers. #30
    final message = spot<Tooltip>().getDiagnosticProp<String>('message');
  • New: WidgetSelector<AnyText>.whereIsEditable() and whereIsNotEditable() filter text matches by whether they come from an editable text input.
    spotText('username').whereIsEditable().existsOnce();
    spotText('Username').whereIsNotEditable().existsOnce();
  • New: WidgetSnapshot.queryStats reports how much work the query engine performed to evaluate a selector, useful to debug slow queries. #148
  • Fix: A WidgetMatcher now 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-op WidgetTypeFilter<Widget> at the root.

Text matching

  • New: Text matching ignores invisible and special whitespace, so tests can use regular characters. spotText, spotTextWhere, whereText, withText and hasText strip 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, the U+FFFC WidgetSpan placeholder) and line breaks are kept. #138 (thx @MichaelTamm)
    spotText('foobar').existsOnce();  // matches Text('foo\u{200B}bar')
    spotText('foo bar').existsOnce(); // matches Text('foo\u{00A0}bar')
    To match exact characters, pass raw: true to spotText/spotTextWhere, or use whereRawText/withRawText/hasRawText on WidgetSelector<AnyText>. Also exposes AnyText.normalizeVisibleText, AnyText.extractText, and AnyTextContent (raw/normalized).
  • Deprecated: spotText(text, exact: true) is now spotText(text, whole: true) — the flag controls whole-string vs. substring matching, not character handling. exact still works. #138

Screenshots

  • New: ScreenshotAnnotator.cacheKey (default => null) allows caching of annotations #160
  • Fix: Export ScreenshotAnnotator, which has already been a parameter of takeScreenshot(annotators: ...)

Fonts

  • Fix: loadAppFonts() now also registers a package's own fonts under packages/<self>/MyFont, so fonts referenced via package: '<self>' render instead of falling back to Ahem. #141