Skip to content

Commit 338b508

Browse files
Created a FilmStrip golden builder for screenshots over time
1 parent 68561cd commit 338b508

File tree

5 files changed

+305
-141
lines changed

5 files changed

+305
-141
lines changed

lib/flutter_test_goldens.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export 'src/film_strip.dart';
12
export 'src/golden_camera.dart';

lib/src/film_strip.dart

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import 'dart:math';
2+
import 'dart:typed_data';
3+
import 'dart:ui';
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:flutter_test_goldens/src/golden_camera.dart';
8+
9+
/// A golden builder that takes screenshots over a period of time and
10+
/// stitches them together into a single golden file with a given
11+
/// [FilmStripLayout].
12+
class FilmStrip {
13+
FilmStrip(this._tester);
14+
15+
final WidgetTester _tester;
16+
17+
_FilmStripSetup? _setup;
18+
final _steps = <Object>[];
19+
20+
/// Setup the scene before taking any photos.
21+
///
22+
/// If you only need to provide a widget tree, without taking other [WidgetTester]
23+
/// actions, consider using [setupWithPump] for convenience.
24+
FilmStrip setup(FilmStripSetupDelegate delegate) {
25+
if (_setup != null) {
26+
throw Exception("FilmStrip was already set up, but tried to call setup() again.");
27+
}
28+
29+
_setup = _FilmStripSetup(delegate);
30+
31+
return this;
32+
}
33+
34+
/// Setup the scene before taking any photos, by pumping a widget tree.
35+
///
36+
/// If you need to take additional actions, beyond a single pump, use [setup] instead.
37+
FilmStrip setupWithPump(FilmStripSetupWithPumpFactory factory) {
38+
if (_setup != null) {
39+
throw Exception("FilmStrip was already set up, but tried to call setupWithPump() again.");
40+
}
41+
42+
_setup = _FilmStripSetup((tester) async {
43+
final widgetTree = factory();
44+
await _tester.pumpWidget(widgetTree);
45+
});
46+
47+
return this;
48+
}
49+
50+
/// Take a golden photo screenshot of the current Flutter UI, given the
51+
/// setup and any modifications since then.
52+
FilmStrip takePhoto(Finder photoBoundsFinder, String description) {
53+
if (_setup == null) {
54+
throw Exception("Can't take a photo before setup. Please call setup() or setupWithPump()");
55+
}
56+
57+
_steps.add(_FilmStripPhotoRequest(photoBoundsFinder, description));
58+
59+
return this;
60+
}
61+
62+
/// Change the scene in this [FilmStrip] to prepare to take another photo.
63+
FilmStrip modifyScene(FilmStripModifySceneDelegate delegate) {
64+
if (_setup == null) {
65+
throw Exception("Can't modify the scene before setup. Please call setup() or setupWithPump()");
66+
}
67+
68+
_steps.add(_FilmStripModifySceneAction(delegate));
69+
70+
return this;
71+
}
72+
73+
Future<void> renderOrCompareGolden(String goldenName, FilmStripLayout layout) async {
74+
if (_setup == null) {
75+
throw Exception(
76+
"Can't render or compare golden file without a setup action. Please call setup() or setupWithPump().");
77+
}
78+
79+
final camera = GoldenCamera(_tester);
80+
final scratchPad = <Object, dynamic>{};
81+
82+
// Setup the scene.
83+
await _setup!.setupDelegate(_tester);
84+
85+
// Take photos and modify scene over time.
86+
for (final step in _steps) {
87+
if (step is _FilmStripModifySceneAction) {
88+
await step.delegate(_tester, scratchPad);
89+
continue;
90+
}
91+
92+
if (step is _FilmStripPhotoRequest) {
93+
expect(step.photoBoundsFinder, findsOne);
94+
95+
final renderObject = step.photoBoundsFinder.evaluate().first.findRenderObject();
96+
expect(
97+
renderObject,
98+
isNotNull,
99+
reason:
100+
"Failed to find a render object for photo '${step.description}', using finder '${step.photoBoundsFinder}'",
101+
);
102+
103+
await camera.takePhoto(step.photoBoundsFinder, step.description);
104+
105+
continue;
106+
}
107+
108+
throw Exception("Tried to run a step when rendering a FilmStrip, but we don't recognize this step type: $step");
109+
}
110+
111+
// Lay out photos in a row.
112+
final photos = camera.photos;
113+
// TODO: cleanup the modeling of these photos vs renderable photos once things are working
114+
final renderablePhotos = <GoldenPhoto, (Uint8List, GlobalKey)>{};
115+
await _tester.runAsync(() async {
116+
for (final photo in photos) {
117+
final byteData = await photo.pixels.toByteData(format: ImageByteFormat.png);
118+
renderablePhotos[photo] = (byteData!.buffer.asUint8List(), GlobalKey());
119+
}
120+
});
121+
122+
await _layoutPhotos(photos, renderablePhotos, layout);
123+
124+
await _tester.runAsync(() async {
125+
// Without this delay, the screenshot loading is spotty. However, with
126+
// this delay, we seem to always get screenshots displayed in the widget tree.
127+
await Future.delayed(const Duration(milliseconds: 1));
128+
});
129+
130+
await _tester.pumpAndSettle();
131+
132+
await expectLater(find.byType(MaterialApp), matchesGoldenFile("$goldenName.png"));
133+
}
134+
135+
Future<void> _layoutPhotos(
136+
List<GoldenPhoto> photos,
137+
Map<GoldenPhoto, (Uint8List, GlobalKey)> renderablePhotos,
138+
FilmStripLayout layout,
139+
) async {
140+
// Layout the final strip within an OverflowBox to let it be whatever
141+
// size it wants. Then check the content render object for final dimensions.
142+
// Set the window size to match.
143+
144+
late final Size filmStripSize;
145+
late final Axis filmStripDirection;
146+
switch (layout) {
147+
case FilmStripLayout.row:
148+
filmStripSize = Size(
149+
photos.fold(0, (width, photo) => width + photo.pixels.width.toDouble()),
150+
photos.fold(0, (maxHeight, photo) => max(maxHeight, photo.pixels.height.toDouble())),
151+
);
152+
filmStripDirection = Axis.horizontal;
153+
154+
case FilmStripLayout.column:
155+
filmStripSize = Size(
156+
photos.fold(0, (maxWidth, photo) => max(maxWidth, photo.pixels.width.toDouble())),
157+
photos.fold(0, (height, photo) => height + photo.pixels.height.toDouble()),
158+
);
159+
filmStripDirection = Axis.vertical;
160+
}
161+
162+
_tester.view //
163+
..physicalSize = filmStripSize
164+
..devicePixelRatio = 1.0;
165+
166+
await _tester.pumpWidget(
167+
MaterialApp(
168+
home: Scaffold(
169+
backgroundColor: Color(0xFF222222),
170+
body: Center(
171+
child: Flex(
172+
direction: filmStripDirection,
173+
children: [
174+
for (final entry in renderablePhotos.entries) //
175+
Image.memory(
176+
key: entry.value.$2,
177+
entry.value.$1,
178+
width: entry.key.pixels.width.toDouble(),
179+
height: entry.key.pixels.height.toDouble(),
180+
),
181+
],
182+
),
183+
),
184+
),
185+
debugShowCheckedModeBanner: false,
186+
),
187+
);
188+
189+
await _tester.runAsync(() async {
190+
for (final entry in renderablePhotos.entries) {
191+
await precacheImage(
192+
MemoryImage(entry.value.$1),
193+
_tester.element(find.byKey(entry.value.$2)),
194+
);
195+
}
196+
});
197+
}
198+
}
199+
200+
class _FilmStripSetup {
201+
const _FilmStripSetup(this.setupDelegate);
202+
203+
final FilmStripSetupDelegate setupDelegate;
204+
}
205+
206+
typedef FilmStripSetupDelegate = Future<void> Function(WidgetTester tester);
207+
208+
typedef FilmStripSetupWithPumpFactory = Widget Function();
209+
210+
class _FilmStripPhotoRequest {
211+
const _FilmStripPhotoRequest(this.photoBoundsFinder, this.description);
212+
213+
final Finder photoBoundsFinder;
214+
final String description;
215+
}
216+
217+
class _FilmStripModifySceneAction {
218+
const _FilmStripModifySceneAction(this.delegate);
219+
220+
final FilmStripModifySceneDelegate delegate;
221+
}
222+
223+
typedef FilmStripModifySceneDelegate = Future<void> Function(WidgetTester tester, Map<Object, dynamic> scratchPad);
224+
225+
enum FilmStripLayout {
226+
row,
227+
column;
228+
}

lib/src/golden_camera.dart

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import 'package:flutter_test/flutter_test.dart';
55

66
/// A camera for taking golden screenshots and storing them for later reference.
77
class GoldenCamera {
8+
GoldenCamera(this._tester);
9+
10+
final WidgetTester _tester;
11+
812
List<GoldenPhoto> get photos => List.from(_photos);
913
final _photos = <GoldenPhoto>[];
1014

@@ -13,11 +17,40 @@ class GoldenCamera {
1317
Future<void> takePhoto(Finder finder, String description) async {
1418
expect(finder, findsOne);
1519

16-
final repaintBoundary = finder.evaluate().first.renderObject! as RenderRepaintBoundary;
17-
final pixels = await repaintBoundary.toImage(pixelRatio: 1.0);
20+
final renderObject = finder.evaluate().first.findRenderObject();
21+
late final Image photo;
22+
if (renderObject!.isRepaintBoundary) {
23+
// The render object that we want to screenshot is already a repaint boundary,
24+
// so we can directly request an image from it.
25+
final repaintBoundary = finder.evaluate().first.renderObject! as RenderRepaintBoundary;
26+
photo = await repaintBoundary.toImage(pixelRatio: 1.0);
27+
} else {
28+
// The render object that we want to screenshot is NOT a repaint boundary, so we need
29+
// to screenshot the entire UI and then extract the region belonging to this widget.
30+
if (renderObject is! RenderBox) {
31+
throw Exception(
32+
"Can't take screenshot because the root of the widget tree isn't a RenderBox. It's a ${renderObject.runtimeType}",
33+
);
34+
}
35+
36+
// TODO: Try the following approach. It probably doesn't work because we're
37+
// using a TestRecordingPaintingContext with a non-test version of Canvas.
38+
// But maybe it will work out.
39+
final pictureRecorder = PictureRecorder();
40+
final canvas = Canvas(pictureRecorder);
41+
final screenSize = renderObject.size;
42+
43+
final paintingContext = TestRecordingPaintingContext(canvas);
44+
renderObject.paint(paintingContext, Offset.zero);
45+
46+
photo = await pictureRecorder.endRecording().toImage(
47+
screenSize.width.round(),
48+
screenSize.height.round(),
49+
);
50+
}
1851

1952
_photos.add(
20-
GoldenPhoto(description, pixels),
53+
GoldenPhoto(description, photo),
2154
);
2255
}
2356
}

test_goldens/button.png

39 Bytes
Loading

0 commit comments

Comments
 (0)