Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## 0.3.0

Internals rewritten; the window class is now `RebuildWindow` and print mode
is `--dart-define=PRINT_REBUILDS=true`. The rebuild tracking core
(the window, the matchers, the failure tables and baseline files) was
written again from scratch. The guard (`package:rebuildcop/guard.dart` and
`dart run rebuildcop:check`) is unchanged.

### Breaking changes

- The counting window class is renamed to `RebuildWindow`. Its members are
the same: `RebuildWindow.open`, `RebuildWindow.record`, `close()`,
`countOf`, `countNamed`, `countWhere`, `countUnder`, `mountsOf`,
`presentTypes`, `countsByType`, `describeBusiest`, `counterDelta`,
`scrollDelta`, `RebuildWindow.printCounts` and
`RebuildWindow.updateBaselines`. There is no alias for the old name:
replace it with `RebuildWindow` in tests, including return types such as
`Future<RebuildWindow>` in test helpers.
- Print mode is turned on with `--dart-define=PRINT_REBUILDS=true`; the
0.2.0 define is no longer read. Printed lines start with `[rebuildcop]`.
- The entries of `rebuilds` and `mounts` are `BuildRecord`s, and their
`ancestors` are `AncestorSnapshot`s, both exported. Code that only read
their fields (`widget`, `widgetType`, `typeName`, `isMount`, `ancestors`)
needs no change.
- A matcher given something other than a window says
`is not a RebuildWindow`.

### Unchanged

- Every matcher (`rebuiltAtMost`, `mountedAtMost`, `subtreeRebuiltAtMost`,
`interactionHappened`, `matchesRebuildBaseline`) and `allowAbsent`, with
the failure messages shown in the README and FAQ.
- Baseline files: the same JSON format (one object per key, one whole-number
budget per type name, keys sorted, two-space indent) and the same paths,
so files written by 0.2.0 and 0.1.x are read as they are.
- `UPDATE_REBUILD_BUDGETS`, `expectRebuildBudgetIsTestOnly` and
`dart run rebuildcop:check`.

## 0.2.0

Renamed from `imaji_rebuild_budget`. The API is the same; only the package
Expand Down Expand Up @@ -42,7 +80,7 @@ widget type was never built now fail
by name, rejects two tracked entries with the same name, and does not write
a baseline in update mode while one of its types is absent.
- `interactionHappened(rebuilt:)` says when the type was never there at all.
- `RebuildCounter.open` and `RebuildCounter.record` throw a `StateError`
- Opening a window (`open` and `record`) throws a `StateError`
unless the widgets binding is a `TestWidgetsFlutterBinding`, so a window
can no longer be opened in a debug run of the app, for example from a
debug menu. `LiveTestWidgetsFlutterBinding` and `integration_test` are
Expand All @@ -53,7 +91,7 @@ widget type was never built now fail

- `allowAbsent` on `rebuiltAtMost`, `mountedAtMost`, `subtreeRebuiltAtMost`
and `matchesRebuildBaseline`.
- `RebuildCounter.presentTypes`: the widget types that had an element in the
- `presentTypes` on the window: the widget types that had an element in the
tree during the window, collected by the walks that already run at `open()`
and `close()`.
- `package:rebuildcop/guard.dart` with
Expand Down Expand Up @@ -88,17 +126,17 @@ Documentation only; no API or behaviour change.

First release.

- `RebuildCounter`: a counting window over Flutter's
- The counting window over Flutter's
`debugOnRebuildDirtyWidget` hook. Windows can overlap, keep and restore a
previously installed hook, report a hook replaced mid-window, and fail a
test that leaves one open. Mounts and rebuilds are counted apart.
`RebuildCounter.record` wraps an interaction in a window.
`record` wraps an interaction in a window.
- Matchers: `rebuiltAtMost` (by type, by name, or total),
`subtreeRebuiltAtMost` (by finder), `mountedAtMost`.
- `interactionHappened`: proof that a window's interaction ran (a scroll
offset moved, a fetch count or named counter changed, a type rebuilt).
- Failure output lists the busiest widget types with the budget beside each.
- Print mode (`--dart-define=REBUILD_COUNTS=true`) prints every window's
- Print mode (a `--dart-define` flag) prints every window's
per-type counts and every measurement beside its budget.
- `matchesRebuildBaseline`: golden-style JSON budget files, rewritten with
`--dart-define=UPDATE_REBUILD_BUDGETS=true`.
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ void main() =>

### Coming from imaji_rebuild_budget

This package was renamed from `imaji_rebuild_budget`; the API is the same.
Replace the dev dependency with `rebuildcop`, change
`package:imaji_rebuild_budget/` imports to `package:rebuildcop/`, and run
`dart run rebuildcop:check`. See the
This package was renamed from `imaji_rebuild_budget`. Replace the dev
dependency with `rebuildcop`, change `package:imaji_rebuild_budget/` imports
to `package:rebuildcop/`, and run `dart run rebuildcop:check`. Since 0.3.0
the window class is `RebuildWindow` and print mode is `PRINT_REBUILDS`. See
the
[CHANGELOG](https://github.com/saimskywalker/rebuildcop/blob/main/CHANGELOG.md).

## Quick start
Expand All @@ -76,7 +77,7 @@ void main() {
testWidgets('scrolling the feed never rebuilds the tab bar', (tester) async {
await tester.pumpWidget(const MyApp());

final window = RebuildCounter.open(label: 'scroll the feed');
final window = RebuildWindow.open(label: 'scroll the feed');
await tester.drag(find.byType(ListView), const Offset(0, -600));
await tester.pumpAndSettle();
window.close();
Expand All @@ -88,7 +89,7 @@ void main() {
```

Run `flutter test`. To pick a budget, measure first: run with
`--dart-define=REBUILD_COUNTS=true` and every window prints its per-type
`--dart-define=PRINT_REBUILDS=true` and every window prints its per-type
counts.

## Example output
Expand All @@ -98,7 +99,7 @@ product list once a second. Three ticks, budget zero:

```text
Expected: ProductTile rebuilt at most 0 times in the window
Actual: RebuildCounter:<RebuildCounter("three clock ticks", closed, 42 rebuilds, 0 mounts)>
Actual: RebuildWindow:<RebuildWindow("three clock ticks", closed, 42 rebuilds, 0 mounts)>
Which: rebuilt 15 times, 15 over budget.
42 rebuilds across 5 widget types, busiest first:
18 Text
Expand Down Expand Up @@ -132,7 +133,7 @@ counted, a framework limitation; budget the widget that builds them.

## Documentation

- [API: `RebuildCounter` and every matcher](https://github.com/saimskywalker/rebuildcop/blob/main/doc/api.md)
- [API: `RebuildWindow` and every matcher](https://github.com/saimskywalker/rebuildcop/blob/main/doc/api.md)
- [Measuring budgets (print mode) and baseline files](https://github.com/saimskywalker/rebuildcop/blob/main/doc/budgets.md)
- [Examples: setState, keys, Riverpod, Provider, Bloc](https://github.com/saimskywalker/rebuildcop/blob/main/doc/examples.md)
- [FAQ, known gaps, supported versions and comparison](https://github.com/saimskywalker/rebuildcop/blob/main/doc/faq.md)
Expand Down
13 changes: 7 additions & 6 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ Everything is exported from `package:rebuildcop/rebuildcop.dart`,
except the [guard](#guard-keeping-the-package-out-of-the-app), which has a
library of its own.

## `RebuildCounter`, the counting window
## `RebuildWindow`, the counting window

| Member | What it does |
|---|---|
| `RebuildCounter.open({label, fetchCount, counters})` | Starts counting. Call it after the widget is pumped, right before the interaction. Throws a `StateError` outside `flutter test` (no `TestWidgetsFlutterBinding`). |
| `RebuildCounter.record(() async { ... }, {...})` | Opens, runs the interaction, closes, even when it throws. |
| `RebuildWindow.open({label, fetchCount, counters})` | Starts counting. Call it after the widget is pumped, right before the interaction. Throws a `StateError` outside `flutter test` (no `TestWidgetsFlutterBinding`). |
| `RebuildWindow.record(() async { ... }, {...})` | Opens, runs the interaction, closes, even when it throws. |
| `close()` | Stops counting and snapshots scroll offsets and counters. |
| `countOf(Type)` / `countNamed('_Private')` / `countWhere(test)` | Rebuilds of an exact type, a type by name, or widgets matching a predicate. |
| `countUnder(Finder, {of})` | Rebuilds of a subtree, optionally of one type inside it. |
| `presentTypes` | Widget types that had an element in the tree during the window. A type budget for anything else fails. |
| `mountsOf(Type)`, `mounts`, `rebuilds`, `total` | Raw records. A *mount* is an element built for the first time inside the window; a *rebuild* is an element that already existed building again. |
| `BuildRecord` | One entry of `mounts` or `rebuilds`: `widget`, `widgetType`, `typeName`, `isMount`, `element`, and `ancestors` (the enclosing widgets at build time, nearest first, as `AncestorSnapshot`s). |
| `countsByType()`, `describeBusiest({top, budgets, under})` | Per-type counts and the failure table, for your own `reason:` strings. |
| `counterDelta(name)`, `scrollDelta(Finder)` | How much a counter or a scroll offset changed between `open` and `close`. |
| `RebuildCounter.printCounts` | Print mode; defaults to `--dart-define=REBUILD_COUNTS=true`. |
| `RebuildCounter.updateBaselines` | Baseline update mode; defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`. |
| `RebuildWindow.printCounts` | Print mode; defaults to `--dart-define=PRINT_REBUILDS=true`. |
| `RebuildWindow.updateBaselines` | Baseline update mode; defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`. |

Windows may overlap and close in any order. A hook installed before the first
window opens keeps receiving every call and is put back afterwards. If
Expand Down Expand Up @@ -62,7 +63,7 @@ when the window opens and when it closes:

```dart
final api = FakeInboxApi();
final window = RebuildCounter.open(fetchCount: () => api.calls);
final window = RebuildWindow.open(fetchCount: () => api.calls);
for (var i = 0; i < 5; i++) {
await tester.pump(const Duration(seconds: 15)); // the poll interval
}
Expand Down
6 changes: 3 additions & 3 deletions doc/budgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ Never guess a budget. Run the interaction, read the count, write that number
down:

```sh
flutter test --dart-define=REBUILD_COUNTS=true test/feed_test.dart
flutter test --dart-define=PRINT_REBUILDS=true test/feed_test.dart
```

```
[rebuild counts] window "three clock ticks": 42 rebuilds, 0 mounts
[rebuildcop] window "three clock ticks": 42 rebuilds, 0 mounts
18 Text
15 ProductTile
3 ClockText
3 ProductList
3 TickingShopSlow
[rebuild counts] ProductTile rebuilt in "three clock ticks": measured 15, budget 0 (OVER)
[rebuildcop] ProductTile rebuilt in "three clock ticks": measured 15, budget 0 (OVER)
```

Every window prints its per-type counts when it closes, and every budget
Expand Down
6 changes: 3 additions & 3 deletions doc/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ or Windows runner. No device, emulator, simulator or `integration_test`. The
hook only fires in debug builds, which is what `flutter test` always runs.

**Can I show rebuild counts in a debug build of the app?**
No. `RebuildCounter.open` throws a `StateError` unless the widgets binding is
No. `RebuildWindow.open` throws a `StateError` unless the widgets binding is
a `TestWidgetsFlutterBinding`, the binding `testWidgets` sets up:

```text
Expand Down Expand Up @@ -114,7 +114,7 @@ proof that the interaction ran.

**Why do I have to call `close()`?**
So the counts cover exactly the interaction. Frames pumped later (settling
animations, teardown) cannot change them. Use `RebuildCounter.record` if you
animations, teardown) cannot change them. Use `RebuildWindow.record` if you
prefer a closure.

**Does it work with `flutter test --platform chrome`?**
Expand Down Expand Up @@ -142,7 +142,7 @@ By hand in `pubspec.yaml`:

```yaml
dev_dependencies:
rebuildcop: ^0.2.0
rebuildcop: ^0.3.0
```

To try unreleased changes from `main` instead of a published version (it can
Expand Down
4 changes: 2 additions & 2 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ telling the two apart. Run them with:
```sh
cd example
flutter test
flutter test --dart-define=REBUILD_COUNTS=true # print every measured count
flutter test --dart-define=PRINT_REBUILDS=true # print every measured count
```

| File | Over-rebuild | Fix |
Expand All @@ -25,7 +25,7 @@ testWidgets('AFTER: ref.watch(cartProvider.select(...)) leaves it alone',
(tester) async {
await tester.pumpWidget(/* the screen */);

final window = RebuildCounter.open(label: 'apply a coupon');
final window = RebuildWindow.open(label: 'apply a coupon');
container.read(cartProvider.notifier).applyCoupon('SPRING');
await tester.pump();
window.close();
Expand Down
4 changes: 2 additions & 2 deletions example/test/bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rebuildcop/rebuildcop.dart';
import 'package:rebuildcop_example/bloc/download.dart';

Future<RebuildCounter> fiveProgressTicks(
Future<RebuildWindow> fiveProgressTicks(
WidgetTester tester, {
required bool selective,
}) async {
Expand All @@ -25,7 +25,7 @@ Future<RebuildCounter> fiveProgressTicks(
);

var ticks = 0;
final window = RebuildCounter.open(
final window = RebuildWindow.open(
label: 'five progress ticks',
counters: {'ticks': () => ticks},
);
Expand Down
4 changes: 2 additions & 2 deletions example/test/provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:rebuildcop/rebuildcop.dart';
import 'package:rebuildcop_example/provider/reader_settings.dart';
import 'package:provider/provider.dart';

Future<RebuildCounter> toggleTheme(WidgetTester tester, Widget label) async {
Future<RebuildWindow> toggleTheme(WidgetTester tester, Widget label) async {
final settings = ReaderSettings();
addTearDown(settings.dispose);
await tester.pumpWidget(
Expand All @@ -17,7 +17,7 @@ Future<RebuildCounter> toggleTheme(WidgetTester tester, Widget label) async {
),
);

final window = RebuildCounter.open(label: 'toggle dark mode');
final window = RebuildWindow.open(label: 'toggle dark mode');
settings.toggleDarkMode();
await tester.pump();
window.close();
Expand Down
6 changes: 3 additions & 3 deletions example/test/riverpod_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rebuildcop/rebuildcop.dart';
import 'package:rebuildcop_example/riverpod/cart.dart';

Future<RebuildCounter> applyCoupon(WidgetTester tester, Widget badge) async {
Future<RebuildWindow> applyCoupon(WidgetTester tester, Widget badge) async {
await tester.pumpWidget(
ProviderScope(
child: Directionality(
Expand All @@ -17,7 +17,7 @@ Future<RebuildCounter> applyCoupon(WidgetTester tester, Widget badge) async {
tester.element(find.byWidget(badge)),
);

final window = RebuildCounter.open(label: 'apply a coupon');
final window = RebuildWindow.open(label: 'apply a coupon');
container.read(cartProvider.notifier).applyCoupon('SPRING');
await tester.pump();
window.close();
Expand Down Expand Up @@ -57,7 +57,7 @@ void main() {
tester.element(find.byType(CartBadgeFast)),
);

final window = RebuildCounter.open();
final window = RebuildWindow.open();
container.read(cartProvider.notifier).add('Desk');
await tester.pump();
window.close();
Expand Down
10 changes: 5 additions & 5 deletions example/test/set_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Widget _host(Widget child) =>
Directionality(textDirection: TextDirection.ltr, child: child);

/// Lets the clock tick three times, one frame per tick.
Future<RebuildCounter> _threeTicks(WidgetTester tester) async {
final window = RebuildCounter.open(label: 'three clock ticks');
Future<RebuildWindow> _threeTicks(WidgetTester tester) async {
final window = RebuildWindow.open(label: 'three clock ticks');
for (var i = 0; i < 3; i++) {
await tester.pump(const Duration(seconds: 1));
}
Expand Down Expand Up @@ -43,16 +43,16 @@ void main() {
expect(window, interactionHappened(rebuilt: ClockText));
expect(window, rebuiltAtMost(0, of: ProductTile));
expect(window, rebuiltAtMost(0, of: ProductList));
// Measured with --dart-define=REBUILD_COUNTS=true: per tick, the
// Measured with --dart-define=PRINT_REBUILDS=true: per tick, the
// clock, its ClockText and that label's Text.
expect(window, subtreeRebuiltAtMost(9, under: find.byType(LiveClock)));
});
});

group('re-sorting a keyed list', () {
Future<RebuildCounter> reverse(WidgetTester tester, bool stable) async {
Future<RebuildWindow> reverse(WidgetTester tester, bool stable) async {
await tester.pumpWidget(_host(SortableResults(stableKeys: stable)));
final window = RebuildCounter.open(label: 'reverse the results');
final window = RebuildWindow.open(label: 'reverse the results');
tester
.state<SortableResultsState>(find.byType(SortableResults))
.reverse();
Expand Down
33 changes: 9 additions & 24 deletions lib/rebuildcop.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
/// Rebuild budgets for Flutter widget tests.
///
/// Open a [RebuildCounter] window around an interaction, close it, and
/// assert that a widget type or a whole subtree rebuilt no more than a
/// measured budget:
/// Open a `RebuildWindow` around an interaction, close it, and assert on it
/// with `rebuiltAtMost`, `subtreeRebuiltAtMost`, `mountedAtMost`,
/// `interactionHappened` and `matchesRebuildBaseline`.
///
/// ```dart
/// final window = RebuildCounter.open();
/// await tester.tap(find.byIcon(Icons.add));
/// await tester.pump();
/// window.close();
///
/// expect(window, interactionHappened(rebuilt: CounterText));
/// expect(window, rebuiltAtMost(0, of: ProductList));
/// ```
///
/// It listens to Flutter's own [debugOnRebuildDirtyWidget] hook, so it runs
/// headless in plain `flutter test` and the code under test carries no
/// probes.
/// Import this library from tests only: it depends on `flutter_test`.
library;

import 'package:flutter/widgets.dart' show debugOnRebuildDirtyWidget;

import 'src/rebuild_counter.dart';

export 'src/baseline.dart' show matchesRebuildBaseline;
export 'src/interaction.dart' show interactionHappened;
export 'src/matchers.dart'
export 'src/tally/build_record.dart' show AncestorSnapshot, BuildRecord;
export 'src/tally/rebuild_window.dart' show RebuildWindow;
export 'src/verdict/baseline/baseline_matcher.dart' show matchesRebuildBaseline;
export 'src/verdict/budgets.dart'
show mountedAtMost, rebuiltAtMost, subtreeRebuiltAtMost;
export 'src/rebuild_counter.dart' show RebuildCounter, RebuildRecord;
export 'src/verdict/proof.dart' show interactionHappened;
Loading
Loading