stalwart-dev/integration_ui_test.sh working! great!

This commit is contained in:
Thomas Güttler
2026-04-18 12:05:20 +02:00
parent f0d3d9e6a2
commit c7a121e386
14 changed files with 411 additions and 24 deletions
+33
View File
@@ -61,6 +61,39 @@ jobs:
stalwart-dev/test.sh
"
integration-ui:
name: UI Integration tests (Stalwart + Xvfb)
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Install Flutter Linux build dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Run UI integration tests
run: |
nix develop --command bash -c "
flutter pub get &&
flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/integration_ui_test.sh
"
build-linux:
name: Build Linux desktop
runs-on: ubuntu-latest
+3
View File
@@ -22,6 +22,9 @@
# Local IMAP/SMTP dev server for integration tests
stalwart-mail
# Headless display for UI integration tests
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
# Utilities
git
curl
+195
View File
@@ -0,0 +1,195 @@
// E2E integration tests — requires a running Stalwart instance and a display.
// Run via: stalwart-dev/integration_ui_test.sh
//
// Environment variables (set by the runner script):
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@localhost)
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/main.dart' as app;
/// In-memory drop-in for SecureStorage — no D-Bus or keyring daemon required.
class _InMemorySecureStorage implements SecureStorage {
final _store = <String, String>{};
@override
Future<void> write({required String key, required String? value}) async {
if (value == null) {
_store.remove(key);
} else {
_store[key] = value;
}
}
@override
Future<String?> read({required String key}) async => _store[key];
@override
Future<void> delete({required String key}) async => _store.remove(key);
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
late String imapHost;
late int imapPort;
late String smtpHost;
late int smtpPort;
late String userEmail;
late String userPass;
setUpAll(() {
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@localhost';
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
});
testWidgets(
'E2E: add account, send mail to self, verify sent/inbox, search',
(tester) async {
// The Flutter Linux test runner defaults to a 1×1 window; give it a
// real size so widgets are laid out and hittable.
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
app.main(overrides: [
secureStorageProvider.overrideWithValue(_InMemorySecureStorage()),
]);
await tester.pumpAndSettle();
// ── Add account ────────────────────────────────────────────────────────
expect(find.text('No accounts yet.'), findsOneWidget);
await tester.tap(find.widgetWithIcon(FloatingActionButton, Icons.add));
await tester.pumpAndSettle();
expect(find.text('Add account'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextFormField, 'Display name'), 'Alice');
await tester.enterText(
find.widgetWithText(TextFormField, 'Email address'), userEmail);
await tester.enterText(
find.widgetWithText(TextFormField, 'Password'), userPass);
await tester.enterText(
find.widgetWithText(TextFormField, 'IMAP host'), imapHost);
// The form has two "Port" fields: index 0 = IMAP, index 1 = SMTP.
final imapPortField =
find.widgetWithText(TextFormField, 'Port').at(0);
await tester.ensureVisible(imapPortField);
await tester.enterText(imapPortField, imapPort.toString());
// IMAP SSL defaults to on — turn it off for the plaintext dev server.
final imapSslSwitch =
find.widgetWithText(SwitchListTile, 'SSL/TLS').at(0);
await tester.ensureVisible(imapSslSwitch);
await tester.tap(imapSslSwitch);
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'SMTP host'), smtpHost);
final smtpPortField =
find.widgetWithText(TextFormField, 'Port').at(1);
await tester.ensureVisible(smtpPortField);
await tester.enterText(smtpPortField, smtpPort.toString());
final saveButton = find.widgetWithText(FilledButton, 'Save');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pumpAndSettle();
// Back at account list.
expect(find.text('Alice'), findsOneWidget);
expect(find.text(userEmail), findsOneWidget);
// ── Navigate to mailboxes ──────────────────────────────────────────────
await tester.tap(find.text('Alice'));
// Give the background sync time to populate mailboxes from IMAP.
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('INBOX'), findsOneWidget);
// ── Compose and send email to self ─────────────────────────────────────
await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
final subject = 'E2E-${DateTime.now().millisecondsSinceEpoch}';
await tester.enterText(
find.widgetWithText(TextFormField, 'To'), userEmail);
await tester.enterText(
find.widgetWithText(TextFormField, 'Subject'), subject);
final bodyField = find.widgetWithText(TextFormField, 'Body');
await tester.ensureVisible(bodyField);
await tester.enterText(bodyField, 'Hello from integration test!');
await tester.tap(find.byIcon(Icons.send));
// Wait for SMTP send + IMAP APPEND to complete.
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
// ComposeScreen pops back to EmailListScreen (INBOX) after send.
// ── Check Sent folder ──────────────────────────────────────────────────
// Go back to MailboxListScreen.
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Sent'));
await tester.pumpAndSettle();
// Sync Sent folder to fetch the appended message.
await tester.tap(find.byIcon(Icons.sync));
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text(subject), findsOneWidget);
// ── Check Inbox ────────────────────────────────────────────────────────
await tester.pageBack(); // Sent EmailList → MailboxList
await tester.pumpAndSettle();
await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle();
// Sync INBOX — Stalwart delivers to self near-instantly.
await tester.tap(find.byIcon(Icons.sync));
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text(subject), findsOneWidget);
// ── Search ─────────────────────────────────────────────────────────────
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
// Search by the 'E2E-' prefix — should match the message we just sent.
await tester.enterText(find.byType(TextField), 'E2E-');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text(subject), findsOneWidget);
},
);
}
+15 -2
View File
@@ -1,3 +1,5 @@
import 'dart:io' show HandshakeException;
import 'package:enough_mail/enough_mail.dart';
import '../../core/models/account.dart';
@@ -25,7 +27,7 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
final clientDomain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(clientDomain);
var client = SmtpClient(clientDomain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
@@ -33,9 +35,20 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
);
await client.ehlo();
if (!account.smtpSsl) {
// Opportunistic TLS on submission port (587)
// Opportunistic TLS on submission port (587).
try {
await client.startTls();
} on HandshakeException catch (e) {
// TLS handshake failure (e.g. self-signed cert) breaks the socket.
// Reconnect plaintext so authenticate() can still proceed.
log('STARTTLS handshake failed on ${account.smtpHost}: $e — reconnecting without TLS');
client = SmtpClient(clientDomain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: false,
);
await client.ehlo();
} catch (e) {
log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS');
}
@@ -234,22 +234,36 @@ class EmailRepositoryImpl implements EmailRepository {
Future<void> sendEmail(String accountId, model.EmailDraft draft) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final builder = imap.MessageBuilder()
..from = [imap.MailAddress(draft.from.name, draft.from.email)]
..to = draft.to.map((a) => imap.MailAddress(a.name, a.email)).toList()
..cc = draft.cc.map((a) => imap.MailAddress(a.name, a.email)).toList()
..subject = draft.subject
..text = draft.body;
final mimeMessage = builder.buildMimeMessage();
final smtpClient = await _smtpConnect(account, password);
try {
final builder = imap.MessageBuilder()
..from = [imap.MailAddress(draft.from.name, draft.from.email)]
..to = draft.to
.map((a) => imap.MailAddress(a.name, a.email))
.toList()
..cc = draft.cc
.map((a) => imap.MailAddress(a.name, a.email))
.toList()
..subject = draft.subject
..text = draft.body;
await smtpClient.sendMessage(builder.buildMimeMessage());
await smtpClient.sendMessage(mimeMessage);
} finally {
await smtpClient.quit();
}
// Save a copy to the Sent folder via IMAP APPEND.
// Create the folder first — many servers don't pre-create it.
final imapClient = await _imapConnect(account, password);
try {
try {
await imapClient.createMailbox('Sent');
} catch (_) {
// Already exists — that's fine.
}
await imapClient.appendMessage(
mimeMessage,
targetMailboxPath: 'Sent',
flags: [r'\Seen'],
);
} finally {
await imapClient.logout();
}
}
@override
+6 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/repositories/account_repository.dart';
import 'core/repositories/email_repository.dart';
import 'core/repositories/mailbox_repository.dart';
import 'core/storage/secure_storage.dart';
import 'core/sync/account_sync_manager.dart';
import 'data/db/database.dart';
import 'data/repositories/account_repository_impl.dart';
@@ -16,10 +17,14 @@ final dbProvider = Provider<AppDatabase>((ref) {
return db;
});
final secureStorageProvider = Provider<SecureStorage>((ref) {
return const FlutterSecureStorageImpl();
});
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
return AccountRepositoryImpl(
ref.watch(dbProvider),
const FlutterSecureStorageImpl(),
ref.watch(secureStorageProvider),
);
});
+2 -2
View File
@@ -4,8 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'di.dart';
import 'ui/router.dart';
void main() {
runApp(const ProviderScope(child: SharedInboxApp()));
void main({List<Override> overrides = const []}) {
runApp(ProviderScope(overrides: overrides, child: const SharedInboxApp()));
}
class SharedInboxApp extends ConsumerStatefulWidget {
+2
View File
@@ -34,6 +34,8 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^4.0.0
drift_dev: ^2.20.3
build_runner: ^2.4.13
+2 -2
View File
@@ -48,13 +48,13 @@ type = "memory"
[[directory."memory".principals]]
class = "individual"
name = "alice"
name = "alice@localhost"
secret = "secret"
email = ["alice@localhost"]
[[directory."memory".principals]]
class = "individual"
name = "bob"
name = "bob@localhost"
secret = "secret"
email = ["bob@localhost"]
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Starts Stalwart on random ports, then runs Flutter UI integration tests inside
# a virtual X server (Xvfb). Works on a local desktop and in headless CI.
# No D-Bus or keyring daemon is required — tests inject an in-memory SecureStorage.
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
export STALWART_USER_B="${STALWART_USER_B:-alice@localhost}"
export STALWART_PASS_B="${STALWART_PASS_B:-secret}"
export STALWART_USER_C="${STALWART_USER_C:-bob@localhost}"
export STALWART_PASS_C="${STALWART_PASS_C:-secret}"
export STALWART_RANDOM_PORTS=1
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
export STALWART_TMPDIR
# Isolate the app database: fresh HOME → fresh path_provider directory.
TEST_HOME="$(mktemp -d /tmp/sharedinbox-test-home-XXXXXX)"
cleanup() {
kill "${STALWART_PID:-}" 2>/dev/null || true
wait "${STALWART_PID:-}" 2>/dev/null || true
rm -rf "$TEST_HOME"
}
trap cleanup EXIT
command -v stalwart >/dev/null || {
echo "stalwart not in PATH."
echo "Run inside the nix dev shell:"
echo " nix develop --command stalwart-dev/integration_ui_test.sh"
exit 1
}
command -v xvfb-run >/dev/null || {
echo "xvfb-run not in PATH."
echo "Run inside the nix dev shell:"
echo " nix develop --command stalwart-dev/integration_ui_test.sh"
exit 1
}
# Pre-seed spam-filter version so Stalwart does not fetch it on first boot.
mkdir -p "$STALWART_TMPDIR"
sqlite3 "${STALWART_TMPDIR}/data.sqlite" \
"CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL);
INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');" 2>/dev/null || true
LOGFILE="${STALWART_TMPDIR}/stalwart.log"
rm -f "$LOGFILE"
"$(dirname "$0")/start" >"$LOGFILE" 2>&1 &
STALWART_PID=$!
# Wait until Stalwart is accepting connections (up to 10 s).
for _i in $(seq 1 20); do
# shellcheck source=/dev/null
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
grep -E "Configuration build error|Build error for key|already in use" "$LOGFILE" >/dev/null 2>&1 && {
cat "$LOGFILE"; echo "Stalwart reported a startup error"; exit 1
}
kill -0 "$STALWART_PID" 2>/dev/null || {
cat "$LOGFILE"; echo "Stalwart process died unexpectedly"; exit 1
}
if [ -n "${STALWART_URL:-}" ] && \
curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" 2>/dev/null; then
break
fi
sleep 0.5
done
[ -n "${STALWART_URL:-}" ] || { cat "$LOGFILE"; echo "Stalwart did not publish its chosen ports"; exit 1; }
curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || {
cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1
}
echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export STALWART_IMAP_HOST="127.0.0.1"
export STALWART_SMTP_HOST="127.0.0.1"
export HOME="$TEST_HOME"
START=$(date +%s)
# xvfb-run provides a virtual framebuffer so the Flutter Linux runner has a
# display without requiring a real desktop session. No D-Bus or keyring daemon
# is needed because the integration tests inject an in-memory SecureStorage.
xvfb-run --auto-servernum fvm flutter test integration_test/ -d linux
END=$(date +%s)
echo "ui-integration: $((END - START))s"
+4 -3
View File
@@ -4,9 +4,9 @@
set -Eeuo pipefail
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice}"
export STALWART_USER_B="${STALWART_USER_B:-alice@localhost}"
export STALWART_PASS_B="${STALWART_PASS_B:-secret}"
export STALWART_USER_C="${STALWART_USER_C:-bob}"
export STALWART_USER_C="${STALWART_USER_C:-bob@localhost}"
export STALWART_PASS_C="${STALWART_PASS_C:-secret}"
export STALWART_RANDOM_PORTS=1
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
@@ -32,6 +32,7 @@ trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null
# Wait until Stalwart is accepting connections (up to 10 s).
for _i in $(seq 1 20); do
# shellcheck source=/dev/null
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
grep -E "Configuration build error|Build error for key|already in use" "$LOGFILE" >/dev/null 2>&1 && {
cat "$LOGFILE"; echo "Stalwart reported a startup error"; exit 1
@@ -51,7 +52,7 @@ curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || {
cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1
}
echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT} SMTP=:${STALWART_SMTP_PORT}"
echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
+2 -2
View File
@@ -66,8 +66,8 @@ void main() {
await smtpClient.authenticate(userA, passA);
final builder = MessageBuilder()
..from = [MailAddress('Alice', '$userA@localhost')]
..to = [MailAddress('Bob', '$userB@localhost')]
..from = [MailAddress('Alice', userA)]
..to = [MailAddress('Bob', userB)]
..subject = 'Integration test ${DateTime.now().millisecondsSinceEpoch}'
..text = 'Hello from SharedInbox integration test.';
await smtpClient.sendMessage(builder.buildMimeMessage());
+4 -1
View File
@@ -371,7 +371,7 @@ void main() {
expect(r.fakeImap.logoutCalled, isTrue);
});
test('sendEmail calls SMTP sendMessage and quit', () async {
test('sendEmail sends via SMTP and appends copy to Sent folder', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
@@ -388,6 +388,9 @@ void main() {
expect(r.fakeSmtp.messageSent, isTrue);
expect(r.fakeSmtp.quitCalled, isTrue);
expect(r.fakeImap.appendCalls, 1);
expect(r.fakeImap.lastAppendMailboxPath, 'Sent');
expect(r.fakeImap.logoutCalled, isTrue);
});
test('searchEmails returns emails matching IMAP search', () async {
+27
View File
@@ -17,6 +17,9 @@ class FakeImapClient extends imap.ImapClient {
int markDeletedCalls = 0;
int expungeCalls = 0;
int moveEmailCalls = 0;
int appendCalls = 0;
String? lastAppendMailboxPath;
int createMailboxCalls = 0;
@override
Future<imap.Mailbox> selectMailboxByPath(
@@ -107,6 +110,30 @@ class FakeImapClient extends imap.ImapClient {
return null;
}
@override
Future<imap.Mailbox> createMailbox(String path) async {
createMailboxCalls++;
return imap.Mailbox(
encodedName: path,
encodedPath: path,
flags: [],
pathSeparator: '/',
);
}
@override
Future<imap.GenericImapResult> appendMessage(
imap.MimeMessage message, {
List<String>? flags,
imap.Mailbox? targetMailbox,
String? targetMailboxPath,
Duration? responseTimeout,
}) async {
appendCalls++;
lastAppendMailboxPath = targetMailboxPath;
return imap.GenericImapResult();
}
@override
Future<imap.GenericImapResult> uidMove(
imap.MessageSequence sequence, {