fixed test.

This commit is contained in:
Thomas Güttler
2026-04-25 07:07:05 +02:00
parent 92a8a79952
commit 518eb5ccc8
3 changed files with 141 additions and 54 deletions
+15 -8
View File
@@ -18,14 +18,14 @@ tasks:
run: once run: once
deps: [_nix-check] deps: [_nix-check]
cmds: cmds:
- cmd: fvm install --skip-pub-get - cmd: scripts/silent_on_success.sh fvm install --skip-pub-get
_pub-get: _pub-get:
internal: true internal: true
run: once run: once
deps: [_flutter-check] deps: [_flutter-check]
cmds: cmds:
- fvm flutter pub get --suppress-analytics - scripts/silent_on_success.sh fvm flutter pub get --suppress-analytics
_linux-deps-check: _linux-deps-check:
internal: true internal: true
@@ -33,7 +33,7 @@ tasks:
preconditions: preconditions:
- sh: command -v clang >/dev/null 2>&1 - sh: command -v clang >/dev/null 2>&1
msg: "Linux desktop deps missing. Run: sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev" msg: "Linux desktop deps missing. Run: sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev"
- sh: pkg-config --exists gtk+-3.0 2>/dev/null - sh: /usr/bin/pkg-config --exists gtk+-3.0 2>/dev/null
msg: "Linux desktop deps missing. Run: sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev" msg: "Linux desktop deps missing. Run: sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev"
install-hooks: install-hooks:
@@ -41,6 +41,13 @@ tasks:
cmds: cmds:
- pre-commit install - pre-commit install
_codegen:
internal: true
run: once
deps: [_pub-get]
cmds:
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
codegen: codegen:
desc: Generate Drift DB code (run after any schema change) desc: Generate Drift DB code (run after any schema change)
deps: [_nix-check, _pub-get] deps: [_nix-check, _pub-get]
@@ -49,7 +56,7 @@ tasks:
analyze: analyze:
desc: Static analysis (flutter analyze) desc: Static analysis (flutter analyze)
deps: [_nix-check, _pub-get] deps: [_nix-check, _codegen]
cmds: cmds:
- scripts/run_analyze.sh - scripts/run_analyze.sh
@@ -67,13 +74,13 @@ tasks:
test: test:
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing) desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
deps: [_nix-check, _pub-get] deps: [_nix-check, _codegen]
cmds: cmds:
- scripts/run_unit_tests.sh - scripts/run_unit_tests.sh
test-widget: test-widget:
desc: Widget tests — headless, no display or network required desc: Widget tests — headless, no display or network required
deps: [_nix-check, _pub-get] deps: [_nix-check, _codegen]
cmds: cmds:
- scripts/run_widget_tests.sh - scripts/run_widget_tests.sh
@@ -97,9 +104,9 @@ tasks:
build-linux: build-linux:
desc: Build the Linux desktop app (debug) desc: Build the Linux desktop app (debug)
deps: [_nix-check, _linux-deps-check, _pub-get] deps: [_nix-check, _linux-deps-check, _codegen]
cmds: cmds:
- fvm flutter build linux --debug --no-pub - scripts/silent_on_success.sh fvm flutter build linux --debug --no-pub
_android-sdk-check: _android-sdk-check:
internal: true internal: true
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Run a command silently. On failure, replay captured output and exit with the original code.
# When DEBUG is set, print start time, command, end time, and duration.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
if [ -n "${DEBUG:-}" ]; then
start=$(date +%s)
echo "[$(date '+%H:%M:%S')] + $*"
fi
if "$@" > "$tmp" 2>&1; then
code=0
else
code=$?
cat "$tmp"
fi
if [ -n "${DEBUG:-}" ]; then
end=$(date +%s)
echo "[$(date '+%H:%M:%S')] = $* (${code}) $((end - start))s"
fi
exit $code
+102 -46
View File
@@ -7,7 +7,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:enough_mail/enough_mail.dart' show ImapClient; import 'package:enough_mail/enough_mail.dart'
show ImapClient, SmtpClient, MessageBuilder, MailAddress;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
@@ -129,19 +130,25 @@ class _FakeEmails implements EmailRepository {
Future<void> retryMutation(int id) async {} Future<void> retryMutation(int id) async {}
} }
// Plain (non-TLS) IMAP connect for the local dev Stalwart, which has no TLS. Future<void> _sendMessage({
// Production connectImap() rejects imapSsl:false, so tests inject this instead. required String host,
Future<ImapClient> _connectImapPlain( required int port,
Account account, required String from,
String username, required String pass,
String password, required String to,
) async { required String subject,
final client = ImapClient( }) async {
defaultResponseTimeout: const Duration(seconds: 20), final smtp = SmtpClient('sharedinbox-test');
); await smtp.connectToServer(host, port, isSecure: false);
await client.connectToServer(account.imapHost, account.imapPort); await smtp.ehlo();
await client.login(username, password); await smtp.authenticate(from, pass);
return client; final builder = MessageBuilder()
..from = [MailAddress('', from)]
..to = [MailAddress('', to)]
..subject = subject
..text = 'IDLE wake-up test body';
await smtp.sendMessage(builder.buildMimeMessage());
await smtp.quit();
} }
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
@@ -160,42 +167,91 @@ void main() {
pass = _env('STALWART_PASS_B'); pass = _env('STALWART_PASS_B');
}); });
test('IDLE connects, wakes on new message, and shuts down cleanly', () async { test(
final fakeAccounts = _FakeAccounts()..password = pass; 'IDLE connects, wakes on new message, and shuts down cleanly',
timeout: const Timeout(Duration(seconds: 30)),
() async {
final firstIdleConnected = Completer<void>();
final secondIdleConnected = Completer<void>();
Object? connectError;
// Stalwart's memory directory authenticates by principal name ('alice'), Future<ImapClient> trackingConnect(
// not by email address ('alice@example.com'). connectImap() passes Account account,
// account.email as the IMAP login username, so use the bare name here. String username,
final account = Account( String password,
id: 'integration-test', ) async {
displayName: 'Integration Test', try {
email: user, final client = ImapClient(
imapHost: imapHost, defaultResponseTimeout: const Duration(seconds: 20),
imapPort: imapPort, );
imapSsl: false, await client.connectToServer(
smtpHost: imapHost, account.imapHost,
smtpPort: smtpPort, account.imapPort,
); isSecure: false,
);
await client.login(username, password);
if (!firstIdleConnected.isCompleted) {
firstIdleConnected.complete();
} else if (!secondIdleConnected.isCompleted) {
secondIdleConnected.complete();
}
return client;
} catch (e) {
connectError ??= e;
rethrow;
}
}
final mgr = AccountSyncManager( final fakeAccounts = _FakeAccounts()..password = pass;
fakeAccounts, final account = Account(
_FakeMailboxes(), id: 'integration-test',
_FakeEmails(), displayName: 'Integration Test',
imapConnect: _connectImapPlain, email: user,
); imapHost: imapHost,
mgr.start(); imapPort: imapPort,
imapSsl: false,
smtpHost: imapHost,
smtpPort: smtpPort,
);
// Push the account — this triggers _sync() then _idle() in the background. final mgr = AccountSyncManager(
fakeAccounts.push([account]); fakeAccounts,
_FakeMailboxes(),
_FakeEmails(),
imapConnect: trackingConnect,
);
addTearDown(mgr.dispose);
mgr.start();
fakeAccounts.push([account]);
// Give the manager time to connect and enter IDLE. // 1. IDLE connects
await Future<void>.delayed(const Duration(seconds: 2)); await firstIdleConnected.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('IDLE did not connect within 5s; error: $connectError'),
);
expect(connectError, isNull, reason: 'IMAP connect should succeed');
// Shut down — stop() completes the _stopSignal completer so _idle() exits // 2. Wakes on new message — deliver a message and wait for IDLE to
// immediately without waiting for the 25-minute cap. // reconnect, which proves the manager woke up and re-entered IDLE.
mgr.dispose(); await _sendMessage(
host: imapHost,
port: smtpPort,
from: user,
pass: pass,
to: user,
subject: 'wake-idle-${DateTime.now().millisecondsSinceEpoch}',
);
await secondIdleConnected.future.timeout(
const Duration(seconds: 10),
onTimeout: () =>
fail('IDLE did not reconnect after new message within 10s'),
);
expect(connectError, isNull, reason: 'reconnect should succeed');
// Let all in-flight async work (idleDone, logout) finish. // 3. Shuts down cleanly — dispose() must return quickly without hanging
await Future<void>.delayed(const Duration(seconds: 1)); // on the 25-minute IDLE cap.
}); mgr.dispose();
await Future<void>.delayed(const Duration(seconds: 1));
},
);
} }