Skip to content

Commit

Permalink
takeScreenshot() (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
passsy authored Jul 24, 2023
1 parent 1695993 commit c088903
Show file tree
Hide file tree
Showing 7 changed files with 599 additions and 19 deletions.
1 change: 1 addition & 0 deletions lib/spot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export 'package:checks/checks.dart'
it;

export 'package:spot/src/act/act.dart';
export 'package:spot/src/screenshot/screenshot.dart';
export 'package:spot/src/spot/default_selectors.dart';
export 'package:spot/src/spot/effective/effective_text.dart';
export 'package:spot/src/spot/finder_interop.dart';
Expand Down
217 changes: 217 additions & 0 deletions lib/src/screenshot/screenshot.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import 'dart:core';
import 'dart:core' as core;
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:dartx/dartx_io.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nanoid2/nanoid2.dart';
import 'package:spot/spot.dart';
import 'package:spot/src/screenshot/screenshot.dart' as self
show takeScreenshot;
import 'package:stack_trace/stack_trace.dart';

export 'package:stack_trace/stack_trace.dart' show Frame;

/// A screenshot taken from a widget test.
///
/// May also be just a single widget, not the entire screen
class Screenshot {
Screenshot({
required this.file,
this.initiator,
});

/// The file where the screenshot was saved to
final File file;

/// Call stack of the code that initiated the screenshot
final Frame? initiator;
}

/// Takes a screenshot of the entire screen or a single widget.
///
/// Provide a [selector], [snapshot] or [element] to take a screenshot of.
/// When the screenshot is taken from a larger than just your widget, wrap your
/// widget with a [RepaintBoundary] to indicate where the screenshot should be
/// taken.
///
/// Use [name] to make it easier to identify the screenshot in the file system.
/// By default, a random name is generated prefixed with the test file name and
/// line number.
Future<Screenshot> takeScreenshot({
Element? element,
SingleWidgetSnapshot? snapshot,
SingleWidgetSelector? selector,
String? name,
}) async {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance;
final Frame? frame = _caller();

// Element that is currently active in the widget tree, to take a screenshot of
final Element liveElement = () {
if (selector != null) {
// taking a fresh snapshot guarantees an element that is currently in the
// tree and can be screenshotted
final snapshot = selector.snapshot().existsOnce();
return snapshot.element;
}

if (snapshot != null) {
if (!snapshot.element.mounted) {
throw StateError(
'Can not take a screenshot of snapshot $snapshot, because it is not mounted anymore. '
'Only Elements that are currently mounted can be screenshotted.',
);
}
if (snapshot.widget != snapshot.element.widget) {
throw StateError(
'Can not take a screenshot of snapshot $snapshot, because the Element has been updated since the snapshot was taken. '
'This happens when the widget tree is rebuilt.',
);
}
return snapshot.element;
}

if (element != null) {
if (!element.mounted) {
throw StateError(
'Can not take a screenshot of Element $element, because it is not mounted anymore. '
'Only Elements that are currently mounted can be screenshotted.',
);
}
return element;
}

// fallback to screenshotting the entire app
// Deprecated, but as of today there is no multi window support for widget tests
// ignore: deprecated_member_use
return binding.renderViewElement!;
}();

late final Uint8List bytes;
await binding.runAsync(() async {
final image = await _captureImage(liveElement);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
return 'Could not take screenshot';
}
bytes = byteData.buffer.asUint8List();
image.dispose();
});

final spotTempDir = Directory.systemTemp.directory('spot');
if (!spotTempDir.existsSync()) {
spotTempDir.createSync();
}
String callerFileName() {
final file = frame?.uri.pathSegments.last.replaceFirst('.dart', '');
final line = frame?.line;
if (file != null && line != null) {
return '$file:$line';
}
if (file != null) {
return file;
}
return 'unknown';
}

final String screenshotFileName = () {
final String n;
if (name != null) {
// escape /
n = Uri.encodeQueryComponent(name);
} else {
n = callerFileName();
}

// always append a unique id to avoid name collisions
final uniqueId = nanoid(length: 5);
return '$n-$uniqueId.png';
}();
final file = spotTempDir.file(screenshotFileName);
file.writeAsBytesSync(bytes);
// ignore: avoid_print
core.print(
'Screenshot file://${file.path}\n'
' taken at ${frame?.member} ${frame?.uri}:${frame?.line}:${frame?.column}',
);
return Screenshot(file: file, initiator: frame);
}

extension SelectorScreenshotExtension<W extends Widget>
on SingleWidgetSelector<W> {
/// Takes as screenshot of the widget that can be found by this selector.
Future<Screenshot> takeScreenshot({String? name}) {
return self.takeScreenshot(selector: this, name: name);
}
}

extension SnapshotScreenshotExtension<W extends Widget>
on SingleWidgetSnapshot<W> {
/// Takes as screenshot of the widget that was captured in this snapshot.
///
/// The snapshot must have been taken at the same frame
Future<Screenshot> takeScreenshot({String? name}) {
return self.takeScreenshot(snapshot: this, name: name);
}
}

extension ElementScreenshotExtension on Element {
/// Takes as screenshot of this element
///
/// The element must be mounted
Future<Screenshot> takeScreenshot({String? name}) {
return self.takeScreenshot(element: this, name: name);
}
}

/// Render the closest [RepaintBoundary] of the [element] into an image.
///
/// See also:
///
/// * [OffsetLayer.toImage] which is the actual method being called.
Future<ui.Image> _captureImage(Element element) async {
assert(element.renderObject != null);
RenderObject renderObject = element.renderObject!;
while (!renderObject.isRepaintBoundary) {
renderObject = renderObject.parent! as RenderObject;
}
assert(!renderObject.debugNeedsPaint);

final OffsetLayer layer = renderObject.debugLayer! as OffsetLayer;
final ui.Image image = await layer.toImage(renderObject.paintBounds);

if (element.renderObject is RenderBox) {
final expectedSize = (element.renderObject as RenderBox?)!.size;
if (expectedSize.width != image.width ||
expectedSize.height != image.height) {
// ignore: avoid_print
print(
'Warning: The screenshot captured of ${element.toStringShort()} is '
'larger (${image.width}, ${image.height}) than '
'${element.toStringShort()} (${expectedSize.width}, ${expectedSize.height}) itself.\n'
'Wrap the ${element.toStringShort()} in a RepaintBoundary to be able to capture only that layer. ',
);
}
}

return image;
}

/// Returns the frame in the call stack that is most useful for identifying for
/// humans
Frame? _caller({StackTrace? stack}) {
final trace = stack != null ? Trace.parse(stack.toString()) : Trace.current();
final relevantLines = trace.frames.where((line) {
if (line.isCore) return false;
final url = line.uri.toString();
if (url.contains('package:spot')) return false;
return true;
}).toList();
final Frame? bestGuess = relevantLines.firstOrNull;
return bestGuess;
}
45 changes: 30 additions & 15 deletions lib/src/spot/selectors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,23 @@ class WidgetSelector<W extends Widget> with Selectors<W> {

/// A collection of [discovered] elements that match [selector]
class MultiWidgetSnapshot<W extends Widget> {
MultiWidgetSnapshot({
required this.selector,
required this.discovered,
required this.debugCandidates,
required this.scope,
}) : _widgets = Map.fromEntries(
discovered.map((e) => MapEntry(e, e.element.widget as W)),
);

/// The widgets at the point when the snapshot was taken
///
/// [Element] is a mutable object that might have changed since the snapshot
/// was taken. This is a reference to the widget that was found at the time
/// the snapshot was taken. This allows to compare the widget with the current
/// widget in the tree.
final Map<WidgetTreeNode, W> _widgets;

final WidgetSelector<W> selector;

final ScopedWidgetTreeSnapshot scope;
Expand All @@ -861,19 +878,11 @@ class MultiWidgetSnapshot<W extends Widget> {
/// The parent nodes from where the node has been found
// final List<MultiWidgetSnapshot> parents;

List<W> get discoveredWidgets =>
discovered.map((e) => e.element.widget as W).toList();
List<W> get discoveredWidgets => _widgets.values.toList();

List<Element> get discoveredElements =>
discovered.map((e) => e.element).toList();

MultiWidgetSnapshot({
required this.selector,
required this.discovered,
required this.debugCandidates,
required this.scope,
});

@override
String toString() {
return 'SpotSnapshot of $selector (${discoveredElements.length} matches)}';
Expand Down Expand Up @@ -915,7 +924,15 @@ class SingleWidgetSnapshot<W extends Widget> implements WidgetMatcher<W> {
required this.selector,
required this.discovered,
required this.debugCandidates,
});
}) : _widget = discovered?.element.widget;

/// The widget at the point when the snapshot was taken
///
/// [Element] is a mutable object that might have changed since the snapshot
/// was taken. This is a reference to the widget that was found at the time
/// the snapshot was taken. This allows to compare the widget with the current
/// widget in the tree.
final Widget? _widget;

@override
final WidgetSelector<W> selector;
Expand All @@ -930,20 +947,18 @@ class SingleWidgetSnapshot<W extends Widget> implements WidgetMatcher<W> {

@override
String toString() {
return 'SingleSpotSnapshot of $selector (${discovered == null ? 'no' : '1'} match)}';
return 'SingleSpotSnapshot{widget: $_widget, selector: $selector, element: ${discovered?.element}}';
}

@override
Element get element {
return discovered!.element;
}
Element get element => discovered!.element;

W? get discoveredWidget => element.widget as W?;

Element? get discoveredElement => element;

@override
W get widget => discovered!.element.widget as W;
W get widget => _widget! as W;
}

extension SelectorToSnapshot<W extends Widget> on WidgetSelector<W> {
Expand Down
4 changes: 4 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ dependencies:
sdk: flutter
integration_test:
sdk: flutter
nanoid2: ^2.0.0
test: ^1.24.0
test_api: '>=0.5.0 <0.7.0'
stack_trace: ^1.11.0

dev_dependencies:
image: ^4.0.0
lint: ^2.1.0
Loading

0 comments on commit c088903

Please sign in to comment.