Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect when tap target is covered #61

Merged
merged 27 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3e45cc3
Add optimized search for interactable widget positions
danielmolnar Jun 21, 2024
ac9a157
Add test "Finds widgets after dragging down and up"
danielmolnar Jun 21, 2024
7f6dfb8
Add print warning
danielmolnar Jun 21, 2024
a79007f
Add CustomTappableArea
danielmolnar Jun 22, 2024
e4c8bb4
Add test "Partially covered, finds tappable area"
danielmolnar Jun 22, 2024
6a00a46
Add MeasureSize PokeTestWidget
danielmolnar Jun 22, 2024
bc1de17
Adjust test to setup
danielmolnar Jun 22, 2024
a6d7389
Add test Poke test widget throws without defined tappable spots
danielmolnar Jun 22, 2024
3cce03c
Add docs to PokeTestWidget
danielmolnar Jun 22, 2024
6657c76
Improve test naming, constellation
danielmolnar Jun 24, 2024
c919bf1
Make captureConsoleOutput public
danielmolnar Jun 20, 2024
ef71574
Add test "Warn about using and finding alternative tappable area."
danielmolnar Jun 24, 2024
7df7e4f
Add todo
danielmolnar Jun 24, 2024
1f18195
Refactor act
danielmolnar Jun 24, 2024
f5df08d
Return HitTestFailure if no position is found
danielmolnar Jun 24, 2024
4ca1113
Fix test not running on flutter 3.10
danielmolnar Jun 24, 2024
9faf92c
Improve naming
danielmolnar Jun 24, 2024
7b4ca4d
Use positioned.fill instead of measure size
danielmolnar Jun 25, 2024
717b842
Create a useful error message when an InkWell covers the tap target
passsy Jun 25, 2024
1d93a41
Report useful error when tap target has 0x0 pixels in size
passsy Jun 26, 2024
531121f
Revert "Return HitTestFailure if no position is found"
danielmolnar Jun 26, 2024
b42d443
Improve getting the location of a widget in code using public APIs :t…
passsy Jun 27, 2024
8210279
Merge branch 'detect-pokable-positions' into tap-text-in-elevated-button
passsy Jun 27, 2024
3eff69e
Merge remote-tracking branch 'origin/main' into tap-text-in-elevated-…
passsy Jun 27, 2024
dc63f28
Delete MeasureSize
passsy Jun 27, 2024
f28a83c
Cleanup
passsy Jun 27, 2024
ac9b6d3
Print the actual widget, not the selector in tap error messages
passsy Jun 27, 2024
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
236 changes: 206 additions & 30 deletions lib/src/act/act.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'package:dartx/dartx.dart';
import 'dart:io';

import 'package:dartx/dartx_io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -302,15 +305,13 @@ class Act {

_detectAbsorbPointer(hitTargetElements.first, snapshot);
_detectIgnorePointer(target, snapshot);

final Element commonAncestor = findCommonAncestor(
[hitTargetElements.first, snapshot.discoveredElement!],
);
_detectSizeZero(target, snapshot);
_detectCoverWidget(target, snapshot, hitTargetElements);

throw TestFailure(
"Widget '${snapshot.selector.toStringBreadcrumb()}' is covered by '${hitTargetElements.first.widget.toStringShort()}' and can't be tapped.\n"
"The common ancestor of both widgets is:\n"
"${commonAncestor.toStringDeep()}",
"Selector '${snapshot.selector.toStringBreadcrumb()}' can not be tapped at position $position where the RenderObject $target was found.\n"
"Sorry, that we can't tell you more.\n"
"Please create an issue at https://github.com/passsy/spot with an example so that we can provide a useful error message for anyone else running in a similar issue.",
);
}

Expand Down Expand Up @@ -428,7 +429,7 @@ class Act {
if (childElement?.widget is AbsorbPointer) {
final absorbPointer = childElement!.widget as AbsorbPointer;
if (absorbPointer.absorbing) {
final location = getCreationLocation(childElement) ??
final location = childElement.debugWidgetLocation?.file.path ??
childElement.debugGetCreatorChain(100);
throw TestFailure(
"Widget '${snapshot.selector.toStringBreadcrumb()}' is wrapped in AbsorbPointer and doesn't receive taps.\n"
Expand Down Expand Up @@ -456,14 +457,144 @@ class Act {
},
);
if (ignorePointer != null) {
final location = getCreationLocation(ignorePointer) ??
final location = ignorePointer.debugWidgetLocation?.file.path ??
targetElement.debugGetCreatorChain(100);
throw TestFailure(
"Widget '${snapshot.selector.toStringBreadcrumb()}' is wrapped in IgnorePointer and doesn't receive taps. "
"Widget '${snapshot.selector.toStringBreadcrumb()}' is wrapped in IgnorePointer and doesn't receive taps.\n"
"The IgnorePointer is located at $location",
);
}
}

/// Detects when the widget is 0x0 pixels in size and throws a `TestFailure`
/// containing the widget that forces it to be 0x0 pixels.
void _detectSizeZero(RenderObject target, WidgetSnapshot<Widget> snapshot) {
final renderObject = snapshot.discoveredElement?.renderObject;
if (renderObject == null) {
return;
}
final renderBox = renderObject as RenderBox;
final size = renderBox.size;
if (size == Size.zero) {
final parents = snapshot.discoveredElement?.parents.toList() ?? [];
final parentsWithSizes = parents.map(
(element) {
final renderObject = element.renderObject;
if (renderObject is RenderBox?) {
return (renderObject?.size, element);
}
return (null, element);
},
).toList();
final Element shrinker =
parentsWithSizes.reversed.firstWhere((it) => it.$1 == Size.zero).$2;

throw TestFailure(
"${snapshot.discoveredElement!.toStringShort()} can't be tapped because it has size ${Size.zero}.\n"
"${shrinker.toStringShort()} forces ${snapshot.discoveredElement!.toStringShort()} to have the size ${Size.zero}.\n"
"${shrinker.toStringShort()} ${shrinker.debugWidgetLocation?.file.path}",
);
}
}

void _detectCoverWidget(
RenderObject target,
WidgetSnapshot<Widget> snapshot,
List<Element> hitTargetElements,
) {
final cover = hitTargetElements.first;
final Element commonAncestor = findCommonAncestor(
[hitTargetElements.first, snapshot.discoveredElement!],
);
final coverChain = cover
.debugGetDiagnosticChain()
.takeWhile((e) => e != commonAncestor)
.toList();
if (coverChain.isEmpty) {
// no widget is covering the target,
// target is child of the cover
return;
}

final targetChain = snapshot.discoveredElement!
.debugGetDiagnosticChain()
.takeWhile((e) => e != commonAncestor)
.toList();

final commonAncestorChain = commonAncestor.debugGetDiagnosticChain();
final usefulParents = commonAncestorChain.drop(1).where((e) {
return e.debugWidgetLocation?.isUserCode ?? false;
}).toList();

// TODO find not only the first Widget constructor call, but actually the first widget class in the user code
final firstUsefulParent =
usefulParents.firstOrNull ?? commonAncestorChain.first;

final usefulToTarget =
targetChain.takeWhile((e) => e != firstUsefulParent).toList();

final receiverColumn =
"(Cover - Received tap event)\n${coverChain.joinToString(separator: '\n', transform: (it) => it.toStringShort())}";
final targetColumn =
"(Target for tap, below Cover)\n${usefulToTarget.joinToString(separator: '\n', transform: (it) => it.toStringShort())}";

// create a string with two columns (max width 40), one for the receiver and one for the target
String createColumns(String receiver, String target) {
final receiverLines = receiver.split('\n');
final targetLines = target.split('\n');
final lines = receiverLines.length > targetLines.length
? receiverLines
: targetLines;
const columnWidth = 40;
const columnSeparator = ' ';
final buffer = StringBuffer();
const empty = ' │';
for (int i = 0; i < lines.length; i++) {
final receiverLine =
receiverLines.length > i ? receiverLines[i] : empty;
final targetLine = targetLines.length > i ? targetLines[i] : empty;
buffer.write(
receiverLine.characters
.take(columnWidth)
.toString()
.padRight(columnWidth),
);
buffer.write(columnSeparator);
buffer.write(
targetLine.characters
.take(columnWidth)
.toString()
.padRight(columnWidth),
);
buffer.writeln();
}
return buffer.toString().trimRight();
}

final diagram = """
${createColumns(receiverColumn, targetColumn)}
│ ┌──────────────────────────────────────┘
${commonAncestor.toStringShort().trimRight()} (${commonAncestor.debugWidgetLocation?.file.path})
${usefulParents.takeWhile((it) => it != firstUsefulParent).joinToString(separator: '\n', transform: (it) => it.toStringShort()).trimRight()}
${firstUsefulParent.toStringShort()} (${firstUsefulParent.debugWidgetLocation?.file.path})
""";

throw TestFailure(
"Selector '${snapshot.selector.toStringBreadcrumb()}' can not be tapped directly, because another widget (${cover.toStringShort()}) inside ${firstUsefulParent.toStringShort()} is completely covering it and consumes all tap events.\n"
"\n"
"Try tapping the ${firstUsefulParent.toStringShort()} which contains '${snapshot.selector}' instead.\n\n"
"Example:\n"
" // BAD: Taps the Text inside ElevatedButton\n"
" WidgetSelector<AnyText> selector = spot<ElevatedButton>().spotText('Tap me');\n"
" await act.tap(selector);\n"
"\n"
" // GOOD: Taps the ElevatedButton which contains text 'Tap me'\n"
" WidgetSelector<ElevatedButton> selector = spot<ElevatedButton>().withChild(spotText('Tap me'));\n"
" await act.tap(selector);\n"
"\n"
"${diagram.removeEmptyLines()}\n",
);
}
}

/// Contains the result of hit testing an entire [RenderObject] in [_findPokablePositions]
Expand Down Expand Up @@ -536,24 +667,69 @@ T _alwaysPropagateDevicePointerEvents<T>(T Function() block) {
}
}

/// Workaround to the the location of a widget in code
///
/// This method is a workaround to call `_getCreationLocation()` which is private
String? getCreationLocation(Element element) {
final debugCreator = element.renderObject?.debugCreator;
if (debugCreator is! DebugCreator) {
return null;
/// Grants access to the location of a Widget via [WidgetInspectorService]
extension WidgetLocationExt on Element {
/// Returns where the widget was created in code
WidgetLocation? get debugWidgetLocation {
try {
final delegate = InspectorSerializationDelegate(
service: WidgetInspectorService.instance,
);
final json = toDiagnosticsNode().toJsonMap(delegate);
final creationLocation =
json['creationLocation'] as Map<String, Object?>?;
final file = creationLocation!['file'] as String?;
final line = creationLocation['line'] as int?;
final column = creationLocation['column'] as int?;
final String location1 = '$file:$line:$column';
final createdByLocalProject = json['createdByLocalProject'] as bool?;

return WidgetLocation(
file: File(location1),
createdByLocalProject: createdByLocalProject,
);
} catch (e) {
return null;
}
}
}

/// The location on the users filesystem where a Widget constructor was called
class WidgetLocation {
/// The pointer to the file
final File file;

/// True when the [WidgetInspectorService] reports that the location is
/// - not within an external package
/// - not within the dart or flutter sdk
final bool? createdByLocalProject;

/// Creates a new [WidgetLocation]
WidgetLocation({
required this.file,
required this.createdByLocalProject,
});

/// Returns true, when the location is relevant for error messages, because
/// it is within the users project
bool get isUserCode {
if (file.path.contains('packages/flutter/')) {
return false;
}
if (createdByLocalProject != null) {
return createdByLocalProject!;
}
return true;
}

@override
String toString() {
return 'WidgetLocation{userCode: $isUserCode, ${file.name}';
}
}

extension on String {
String removeEmptyLines() {
return split('\n').where((line) => line.trim().isNotEmpty).join('\n');
}
final block =
debugTransformDebugCreator([DiagnosticsDebugCreator(debugCreator)]).first
as DiagnosticsBlock;
final description = block.getChildren().first as ErrorDescription;
final location = description.value.first.toString();
// _Location .toString() looks something like this:
// IgnorePointer IgnorePointer:file:///Users/pascalwelsch/Projects/passsy/spot/test/act/act_test.dart:142:18

final matches = RegExp('.*(file:///.*)').allMatches(location);
final filePath = matches.first.group(1);

return filePath;
}
Loading
Loading