From e048f5ac788c65a447dcb83decef973da855779b Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 12:41:06 -0700 Subject: [PATCH] fix(mobile): bound search result rendering work (#8418) Global search eagerly mounted every result and parsed entire messages even for two-line previews. Large markdown hits could block the UI while changing categories, updating results, or reopening search. Build result rows lazily and bound long message previews to a literal prefix of at most 512 UTF-16 units without splitting surrogate pairs. Short messages retain rich formatting and mentions. Long snippets show markdown punctuation literally; opening a result still uses the original message. Truncated markdown is never passed to the rich renderer. ### Validation - Added regressions for lazy message/channel/people rows, scrolling to the final result, and a >100 KB markdown hit with a surrogate pair at the preview boundary. All 23 focused search tests pass; full mobile analysis is clean. The original large-hit test consumed a CPU core for over 30 seconds before being terminated. - Native iOS simulator build passed. Diagnostic Debug/JIT captures attributed a 1,033 ms search frame and a separate 12,972 ms result-update callback to widget/markdown work. A verified populated-results run of the final candidate completed category switching, scrolling, and closing, with a longest UI callback of 70 ms. Separate instrumented Debug/JIT runs have uncontrolled host load, warm-up, and live data; they are not release-performance benchmarks. - Full local gate passed through desktop builds/checks, then failed two unchanged native subprocess PID/timeout tests; both passed in isolation. The remaining web build and mobile suite passed. - [Codex review](https://github.com/block/buzz/pull/8418#issuecomment-6243734066) completed on the current head with no findings. [CI](https://github.com/block/buzz/actions/runs/39082758977) passed, including mobile checks, all three test shards, Android, the iOS release build, and native simulator build/tests. ### Preview examples Synthetic widget captures with app fonts, without real community data or native iOS glass controls. | Long message before | Bounded literal preview after | | --- | --- | | ![Before](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-before.png) | ![After](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-preview.png) | --------- Signed-off-by: Tom Brow --- mobile/lib/features/search/search_page.dart | 278 +++++++++--------- .../search/search_page/message_tile.dart | 103 ++++--- .../search/search_page_lazy_test.dart | 257 ++++++++++++++++ .../features/search/search_page_test.dart | 9 +- 4 files changed, 474 insertions(+), 173 deletions(-) create mode 100644 mobile/test/features/search/search_page_lazy_test.dart diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 69397c477f..fe5b5a74d9 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -328,41 +328,49 @@ class _SearchBody extends ConsumerWidget { ); } - return ListView( + return CustomScrollView( key: const Key('search-results-list'), - padding: EdgeInsets.only( - bottom: - Grid.xl + - MediaQuery.paddingOf(context).bottom + - MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ - if (showChannels && state.channelResults.isNotEmpty) - _ChannelsSection( - channels: state.channelResults, - onResultSelected: recordResultSelection, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), - if (showPeople && state.userResults.isNotEmpty) - _PeopleSection( - users: state.userResults, - onResultSelected: recordResultSelection, - ), - if (showMessages && state.messageResults.isNotEmpty) - _MessagesSection( - hits: state.messageResults, - currentPubkey: currentPubkey, - onResultSelected: recordResultSelection, - ), - if (state.isLoading) - const Padding( - padding: EdgeInsets.all(Grid.sm), - child: Center( - child: BuzzLoadingIndicator( - size: 36, - semanticLabel: 'Loading more search results', - ), - ), + sliver: SliverMainAxisGroup( + slivers: [ + if (showChannels && state.channelResults.isNotEmpty) + _ChannelsSection( + channels: state.channelResults, + onResultSelected: recordResultSelection, + ), + if (showPeople && state.userResults.isNotEmpty) + _PeopleSection( + users: state.userResults, + onResultSelected: recordResultSelection, + ), + if (showMessages && state.messageResults.isNotEmpty) + _MessagesSection( + hits: state.messageResults, + currentPubkey: currentPubkey, + onResultSelected: recordResultSelection, + ), + if (state.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center( + child: BuzzLoadingIndicator( + size: 36, + semanticLabel: 'Loading more search results', + ), + ), + ), + ), + ], ), + ), ], ); } @@ -472,57 +480,57 @@ class _ChannelsSection extends ConsumerWidget { }); @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Channels'), - for (final channel in channels) - ListTile( - key: ValueKey('search-channel-row-${channel.id}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: Icon( - channelIcon(channel), - key: ValueKey('search-channel-leading-${channel.id}'), - size: 20, - ), - title: Text( - channel.name, - key: ValueKey('search-channel-title-${channel.id}'), - style: contentListTitleTextStyle, - ), - subtitle: channel.isMember - ? Text( - '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ) - : null, - trailing: !channel.isMember && !channel.isDm - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half + 2, - vertical: 3, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - 'Open', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, - ), - ), - ) - : null, - onTap: () { - onResultSelected(); - _openSearchChannel(context, channel); - }, + return SliverList.builder( + itemCount: channels.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'Channels'); + final channel = channels[index - 1]; + return ListTile( + key: ValueKey('search-channel-row-${channel.id}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + leading: Icon( + channelIcon(channel), + key: ValueKey('search-channel-leading-${channel.id}'), + size: 20, ), - ], + title: Text( + channel.name, + key: ValueKey('search-channel-title-${channel.id}'), + style: contentListTitleTextStyle, + ), + subtitle: channel.isMember + ? Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + : null, + trailing: !channel.isMember && !channel.isDm + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + 2, + vertical: 3, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'Open', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + onTap: () { + onResultSelected(); + _openSearchChannel(context, channel); + }, + ); + }, ); } } @@ -545,46 +553,46 @@ class _PeopleSection extends ConsumerWidget { }, fallbackNames: {for (final user in users) user.pubkey: user.label}, ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'People'), - for (final user in users) - ListTile( - key: ValueKey('search-person-row-${user.pubkey}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: AvatarImage( - key: ValueKey('search-person-leading-${user.pubkey}'), - imageUrl: user.avatarUrl, - radius: 20, - fallback: Text(user.initial), - isAgent: user.isAgent, - ), - title: Text( - names.labelFor(user.pubkey), - key: ValueKey('search-person-title-${user.pubkey}'), - style: contentListTitleTextStyle, - ), - subtitle: Text( - user.secondaryLabel, - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - onTap: () async { - onResultSelected(); - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [user.pubkey]); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); - }, + return SliverList.builder( + itemCount: users.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'People'); + final user = users[index - 1]; + return ListTile( + key: ValueKey('search-person-row-${user.pubkey}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + leading: AvatarImage( + key: ValueKey('search-person-leading-${user.pubkey}'), + imageUrl: user.avatarUrl, + radius: 20, + fallback: Text(user.initial), + isAgent: user.isAgent, ), - ], + title: Text( + names.labelFor(user.pubkey), + key: ValueKey('search-person-title-${user.pubkey}'), + style: contentListTitleTextStyle, + ), + subtitle: Text( + user.secondaryLabel, + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + onTap: () async { + onResultSelected(); + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [user.pubkey]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ); + }, ); } } @@ -616,20 +624,20 @@ class _MessagesSection extends HookConsumerWidget { return null; }, [preloadPubkeysKey]); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Messages'), - for (final hit in hits) - _MessageTile( - hit: hit, - authorProfile: profiles[hit.pubkey.toLowerCase()], - userCache: profiles, - channel: channels.where((c) => c.id == hit.channelId).firstOrNull, - currentPubkey: currentPubkey, - onResultSelected: onResultSelected, - ), - ], + return SliverList.builder( + itemCount: hits.length + 1, + itemBuilder: (context, index) { + if (index == 0) return const _SectionLabel(label: 'Messages'); + final hit = hits[index - 1]; + return _MessageTile( + hit: hit, + authorProfile: profiles[hit.pubkey.toLowerCase()], + userCache: profiles, + channel: channels.where((c) => c.id == hit.channelId).firstOrNull, + currentPubkey: currentPubkey, + onResultSelected: onResultSelected, + ); + }, ); } } diff --git a/mobile/lib/features/search/search_page/message_tile.dart b/mobile/lib/features/search/search_page/message_tile.dart index ca2fcf5ba6..e902096b4d 100644 --- a/mobile/lib/features/search/search_page/message_tile.dart +++ b/mobile/lib/features/search/search_page/message_tile.dart @@ -1,5 +1,16 @@ part of '../search_page.dart'; +// Two-line search snippets must not parse an entire document. Long messages +// use literal text so cutting a code fence or link cannot create a new embed. +const _richSearchPreviewLimit = 512; + +String _literalSearchPreview(String content) { + var end = _richSearchPreviewLimit; + final last = content.codeUnitAt(end - 1); + if (last >= 0xD800 && last <= 0xDBFF) end--; + return '${content.substring(0, end)}…'; +} + class _MessageTile extends ConsumerWidget { final SearchHit hit; final UserProfile? authorProfile; @@ -19,7 +30,9 @@ class _MessageTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mentionedPubkeys = mentionedPubkeysFromTags(hit.tags); + final mentionedPubkeys = hit.content.length > _richSearchPreviewLimit + ? {} + : mentionedPubkeysFromTags(hit.tags); // A hit is labelled within its channel, as it would be there. final hitChannelId = channel?.id ?? hit.channelId; final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys}; @@ -40,28 +53,6 @@ class _MessageTile extends ConsumerWidget { final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; final isDm = channel?.isDm ?? false; - final profileMentionNames = { - for (final pubkey in mentionedPubkeysFromTags(hit.tags)) - if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) - pubkey: userCache[pubkey]!.displayName!.trim(), - }; - final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); - final knownAgentPubkeys = channel == null - ? ref.watch(knownAgentPubkeysProvider) - : ref.watch(agentMentionPubkeysProvider(channel!.id)); - final agentMentionPubkeys = agentPubkeysWithProfileOwners( - knownAgentPubkeys: knownAgentPubkeys, - profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, - ], - ); - final mentionNames = mentionNamesWithDirectoryLabels( - mentionPubkeys: mentionPubkeys, - profileMentionNames: profileMentionNames, - directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), - agentMentionPubkeys: agentMentionPubkeys, - ); return ListTile( key: ValueKey('search-message-row-${hit.eventId}'), @@ -130,18 +121,7 @@ class _MessageTile extends ConsumerWidget { ], ), const SizedBox(height: Grid.half), - MessageContent( - key: ValueKey('search-message-body-${hit.eventId}'), - content: hit.content, - mentionNames: mentionNames, - mentionLabels: labels, - agentMentionPubkeys: agentMentionPubkeys, - tags: hit.tags, - maxLines: 2, - baseStyle: activityPreviewTextStyle.copyWith( - color: context.colors.onSurface, - ), - ), + _buildPreview(context, ref, labels), ], ), onTap: () { @@ -151,6 +131,59 @@ class _MessageTile extends ConsumerWidget { ); } + Widget _buildPreview( + BuildContext context, + WidgetRef ref, + Map labels, + ) { + if (hit.content.length > _richSearchPreviewLimit) { + return Text( + _literalSearchPreview(hit.content), + key: ValueKey('search-message-body-${hit.eventId}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, + ), + ); + } + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(hit.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); + + return MessageContent( + key: ValueKey('search-message-body-${hit.eventId}'), + content: hit.content, + mentionNames: mentionNames, + mentionLabels: labels, + agentMentionPubkeys: agentMentionPubkeys, + tags: hit.tags, + maxLines: 2, + baseStyle: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, + ), + ); + } + void _navigateToHit( BuildContext context, WidgetRef ref, diff --git a/mobile/test/features/search/search_page_lazy_test.dart b/mobile/test/features/search/search_page_lazy_test.dart new file mode 100644 index 0000000000..56f88b7466 --- /dev/null +++ b/mobile/test/features/search/search_page_lazy_test.dart @@ -0,0 +1,257 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/search/recent_searches_provider.dart'; +import 'package:buzz/features/search/search_page.dart'; +import 'package:buzz/features/search/search_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets( + 'message results build only near the viewport and remain scrollable', + (tester) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + messageResults: [ + for (var index = 0; index < 20; index++) + SearchHit( + eventId: 'message-$index', + content: 'Result **$index** with a preview', + kind: 9, + pubkey: 'author-pubkey', + createdAt: 1, + score: 1, + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + const UserProfile(pubkey: 'author-pubkey'), + ), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MessageContent).evaluate().length, lessThan(10)); + final last = find.byKey(const ValueKey('search-message-row-message-19')); + expect(last, findsNothing); + await tester.scrollUntilVisible( + last, + 400, + scrollable: find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + expect(last.hitTestable(), findsOneWidget); + expect(find.byType(MessageContent).evaluate().length, lessThan(10)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'long message previews bound text without parsing partial markdown', + (tester) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + messageResults: [ + SearchHit( + eventId: 'long', + content: + '${'x' * 511}😀${'**[long](https://example.com)**' * 4000}', + kind: 9, + pubkey: 'author-pubkey', + createdAt: 1, + score: 1, + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + const UserProfile(pubkey: 'author-pubkey'), + ), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MessageContent), findsNothing); + final preview = tester.widget( + find.byKey(const ValueKey('search-message-body-long')), + ); + expect(preview.data, '${'x' * 511}…'); + expect(preview.maxLines, 2); + expect(preview.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }, + ); + + for (final category in ['Channels', 'People']) { + testWidgets('$category results are lazy and reachable after filtering', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final state = SearchState( + query: 'result', + channelResults: [ + for (var index = 0; index < 30; index++) + Channel( + id: 'channel-$index', + name: 'Result $index', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 1, + ), + ], + userResults: [ + for (var index = 0; index < 30; index++) + DirectoryUser( + pubkey: 'person-$index', + displayName: 'Person $index', + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(category).first); + await tester.pumpAndSettle(); + final key = category == 'Channels' + ? 'search-channel-row-channel-29' + : 'search-person-row-person-29'; + final last = find.byKey(ValueKey(key)); + expect(last, findsNothing); + await tester.scrollUntilVisible( + last, + 400, + scrollable: find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + expect(last.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +} + +class _FakeSearchNotifier extends SearchNotifier { + _FakeSearchNotifier(this.initialState); + + final SearchState initialState; + + @override + SearchState build() => initialState; + + @override + void search(String query) { + state = SearchState(query: query.trim()); + } + + @override + void clear() { + state = const SearchState.initial(); + } +} + +class _FakeRecentSearchesNotifier extends RecentSearchesNotifier { + _FakeRecentSearchesNotifier(this.initialSearches); + + final List initialSearches; + List get searches => state; + + @override + List build() => initialSearches; + + @override + void record(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + state = [ + trimmed, + ...state.where((item) => item.toLowerCase() != trimmed.toLowerCase()), + ]; + } + + @override + void clear() { + state = const []; + } +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'test', displayName: 'Test'); +} + +class _FakeChannelsNotifier extends ChannelsNotifier { + @override + Future> build() async => const []; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + _FakeUserCacheNotifier(this.profile); + + final UserProfile profile; + + @override + Map build() => {profile.pubkey: profile}; +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 5c8de7380a..9810165914 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -444,10 +444,13 @@ void main() { ); await tester.pumpAndSettle(); - final results = tester.widget( - find.byKey(const Key('search-results-list')), + final resultsPadding = tester.widget( + find.descendant( + of: find.byKey(const Key('search-results-list')), + matching: find.byType(SliverPadding), + ), ); - final padding = results.padding! as EdgeInsets; + final padding = resultsPadding.padding as EdgeInsets; expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); });