Files
sharedinbox/test/widget/email_detail_screen_test.dart
T
8ea5237991 fix(detail): auto-dismiss "Load remote images" snack bar (#548)
## Summary

- The "Load remote images" snack bar in single-mail view (and the analogous thread view) never disappeared on its own — the user had to interact with it.
- Flutter's `SnackBar` defaults to `persist: true` whenever an `action` is provided (see `flutter/lib/src/material/snack_bar.dart`: `persist = persist ?? action != null`), which short-circuits the duration-based dismiss timer in `ScaffoldMessengerState.build`:

  ```dart
  _snackBarTimer = Timer(snackBar.duration, () {
    if (snackBar.persist) return;          // <-- here
    hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
  });
  ```

  So the explicit `duration: 3s` was set, but the "View" action made the snack bar persistent and the timer's callback returned early.
- Pass `persist: false` explicitly on both snack bars so the 3-second timer fires and the snack bar slides away on its own, while the "View" action button still works to navigate to the trusted-senders settings.

## Test plan

- [x] Added widget regression test in `test/widget/email_detail_screen_test.dart` (`Load remote images snack bar auto-dismisses after 3 seconds`).
- [x] Added analogous test in `test/widget/thread_detail_screen_test.dart`.
- [x] `task test-widget` — all 174 widget tests pass.
- [x] `scripts/run_unit_tests.sh` — all 552 unit tests pass.
- [x] `fvm dart analyze --fatal-infos` on changed files — no issues.
- [x] `fvm dart format` — no diffs.
- [ ] Manual: open a single mail with HTML body from an untrusted sender; tap "Load remote images"; verify the snack bar appears, images load, and the snack bar disappears after ~3 seconds while the "View" action button still navigates to `/accounts/trusted-senders` when tapped.

Closes #484

Co-authored-by: Agentloop Bot <agentloop-bot@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/548
2026-06-08 21:59:49 +02:00

648 lines
21 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fake PathProviderPlatform so _downloadRaw resolves getTemporaryDirectory
// via pure microtasks instead of calling xdg-user-dir.
class _FakePathProviderPlatform extends PathProviderPlatform {
@override
Future<String?> getTemporaryPath() async => '/tmp';
}
// IOOverrides subclass that stubs File creation so _downloadRaw completes
// without real dart:io — writeAsString becomes a no-op microtask.
base class _FakeIOOverrides extends IOOverrides {
@override
File createFile(String path) => _FakeFile(path);
}
// Fake File whose writeAsString is a no-op so _downloadRaw completes without
// real I/O. Other methods are unused and left to Fake's noSuchMethod handler.
class _FakeFile extends Fake implements File {
_FakeFile(this._path);
final String _path;
@override
String get path => _path;
@override
Future<File> writeAsString(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) async =>
this;
}
// Shared overrides for email detail tests.
List<Override> _overrides({required EmailBody body, Email? email}) => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
),
];
void main() {
group('EmailDetailScreen', () {
testWidgets('shows loading spinner before data arrives', (tester) async {
// Use a Completer-backed repo so data never arrives during this test.
final neverRepo = _NeverEmailRepository();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(neverRepo),
],
),
);
// One pump to build the widget tree; future not resolved yet.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows subject in email header section', (tester) async {
final email = testEmail(subject: 'Project update');
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'See attached slides.',
attachments: [],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
),
);
await tester.pumpAndSettle();
// Subject appears only in the email header section, not in the app bar.
expect(find.text('Project update'), findsOneWidget);
expect(find.text('See attached slides.'), findsOneWidget);
});
testWidgets('shows from-address in header', (tester) async {
final email = testEmail();
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Hi',
attachments: [],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('bob@example.com'), findsOneWidget);
});
testWidgets('shows attachment section when email has attachments', (
tester,
) async {
final email = testEmail(hasAttachment: true);
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Please review.',
attachments: [
EmailAttachment(
filename: 'report.pdf',
contentType: 'application/pdf',
size: 204800,
),
],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Attachments'), findsOneWidget);
expect(find.text('report.pdf'), findsOneWidget);
});
testWidgets('Reply All button is not present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
findsNothing,
);
});
testWidgets(
'Reply on single-recipient email navigates directly to compose',
(tester) async {
// testEmail has from=[bob], to=[alice]. After removing alice (own),
// only bob remains → no dialog, navigate straight to compose.
final email = testEmail();
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
..._overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing);
},
);
testWidgets('Reply on multi-recipient email shows Reply All dialog', (
tester,
) async {
// Email with an extra Cc recipient so the dialog is triggered.
final email = Email(
id: 'acc-1:42',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 42,
subject: 'Hello world',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
isSeen: false,
isFlagged: false,
hasAttachment: false,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
);
await tester.pumpAndSettle();
// Dialog must appear with title 'Reply All'.
expect(find.text('Reply All'), findsOneWidget);
// Both non-own addresses should be listed in the dialog.
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam is a standalone button, not in popup menu', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// Standalone icon button for mark as spam is in the app bar.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsOneWidget,
);
// It does NOT appear in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsNothing);
});
testWidgets('Mark as spam shows dialog when no junk folder', (
tester,
) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
findsOneWidget,
);
});
testWidgets('Archive shows dialog when no archive folder', (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
);
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
});
testWidgets('Mark as unread is in popup menu, not a standalone button', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as unread.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as unread',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as unread'), findsOneWidget);
});
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
final rawContent = 'A' * 2048;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emailDetail: testEmail(),
emailBody: const EmailBody(
emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: rawContent,
),
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Show Raw Email'));
await tester.pumpAndSettle();
expect(find.text('Raw Email'), findsOneWidget);
expect(find.text('2.0 KB'), findsOneWidget);
});
testWidgets('Download Raw Email closes dialog after download', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emailDetail: testEmail(),
emailBody: const EmailBody(
emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: 'Subject: test\r\n\r\nBody',
),
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Show Raw Email'));
await tester.pumpAndSettle();
expect(find.text('Raw Email'), findsOneWidget);
// Replace path_provider and File I/O with pure-microtask fakes so the
// entire _downloadRaw → Navigator.pop chain completes within pump loops.
final prevPathProvider = PathProviderPlatform.instance;
PathProviderPlatform.instance = _FakePathProviderPlatform();
IOOverrides.global = _FakeIOOverrides();
addTearDown(() {
PathProviderPlatform.instance = prevPathProvider;
IOOverrides.global = null;
});
await tester.tap(find.text('Download'));
// Each pump drains one microtask level: getTemporaryDirectory, then
// writeAsString, then _downloadRaw return, then Navigator.pop.
for (var i = 0; i < 10; i++) {
await tester.pump(Duration.zero);
}
await tester.pumpAndSettle();
// Dialog must be dismissed after download completes.
expect(find.text('Raw Email'), findsNothing);
// SnackBar with Share action must be visible.
expect(find.text('Share'), findsOneWidget);
});
testWidgets('long-press on unsubscribe chip shows URL tooltip', (
tester,
) async {
final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Unsubscribe'), findsOneWidget);
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
),
findsOneWidget,
);
await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle();
expect(find.text('https://example.com/unsubscribe'), findsOneWidget);
});
testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester,
) async {
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Hello',
attachments: [],
mimeTree: MimePart(
contentType: 'multipart/mixed',
children: [
MimePart(contentType: 'text/plain', size: 100),
MimePart(
contentType: 'application/pdf',
filename: 'report.pdf',
size: 204800,
),
],
),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
// Open the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
// Tap the structure item.
await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle();
// The dialog title and all three MIME parts must be visible.
expect(find.text('Mail Structure'), findsOneWidget);
expect(find.textContaining('multipart/mixed'), findsOneWidget);
expect(find.textContaining('text/plain'), findsOneWidget);
expect(find.textContaining('application/pdf'), findsOneWidget);
});
testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
tester,
) async {
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Hello',
attachments: [],
// mimeTree is null — not yet cached or not available.
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle();
expect(find.textContaining('Structure not available'), findsOneWidget);
});
testWidgets(
'Load remote images snack bar auto-dismisses after 3 seconds',
(tester) async {
const body = EmailBody(
emailId: 'acc-1:42',
htmlBody: '<p>Hello <img src="https://example.com/x.png"/></p>',
attachments: [],
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
// The "Load remote images" button is visible because the sender is
// not yet trusted.
expect(find.text('Load remote images'), findsOneWidget);
await tester.tap(find.text('Load remote images'));
// Settle the snack bar enter animation and the setState rebuild
// that swaps in the image-loading WebView.
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Snack bar must be visible.
expect(
find.text('Images will be loaded automatically for this sender.'),
findsOneWidget,
);
// After 3 seconds (the snack bar's duration) plus the reverse
// animation, the snack bar must be gone.
// Regression test for #484: SnackBar with an action defaults to
// persist=true, which disables auto-dismiss — explicit persist:false
// restores duration-based dismissal.
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(
find.text('Images will be loaded automatically for this sender.'),
findsNothing,
);
},
);
});
}
/// Email repository whose [getEmail] and [getEmailBody] futures never resolve,
/// used to test the loading state.
class _NeverEmailRepository extends FakeEmailRepository {
_NeverEmailRepository() : super();
@override
Future<Email?> getEmail(String emailId) => Completer<Email?>().future;
@override
Future<EmailBody> getEmailBody(String emailId) =>
Completer<EmailBody>().future;
}