fix test.
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as mail;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
|
||||
final args = rawArgs == null || rawArgs.isEmpty
|
||||
? const <String>[]
|
||||
: const LineSplitter().convert(rawArgs);
|
||||
await runSyncReliability(args);
|
||||
}
|
||||
|
||||
Future<void> runSyncReliability(List<String> args) async {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
|
||||
final options = _parseOptions(args);
|
||||
final random = Random();
|
||||
|
||||
stdout.writeln(
|
||||
'sync-reliability: updates=${options.updates} cycles=${options.cycles} '
|
||||
'imap-dbs=${options.imapDbs} jmap-dbs=${options.jmapDbs}',
|
||||
);
|
||||
|
||||
final imapEnv = _StalwartEnv.fromEnvironment();
|
||||
final jmapEnv = _StalwartEnv.fromEnvironment();
|
||||
|
||||
final protocolConfigs = <(_Protocol protocol, int dbCount)>[];
|
||||
if (options.imapDbs > 0) {
|
||||
protocolConfigs.add((_Protocol.imap, options.imapDbs));
|
||||
}
|
||||
if (options.jmapDbs > 0) {
|
||||
protocolConfigs.add((_Protocol.jmap, options.jmapDbs));
|
||||
}
|
||||
|
||||
if (protocolConfigs.isEmpty) {
|
||||
throw StateError(
|
||||
'No DBs configured. Set --imap-dbs and/or --jmap-dbs > 0.',
|
||||
);
|
||||
}
|
||||
|
||||
for (final config in protocolConfigs) {
|
||||
final protocol = config.$1;
|
||||
final dbCount = config.$2;
|
||||
stdout.writeln('\n== protocol: ${protocol.name} dbs=$dbCount ==');
|
||||
|
||||
final tempRoot = await Directory.systemTemp.createTemp(
|
||||
'sharedinbox_sync_reliability_',
|
||||
);
|
||||
final runners = <_Runner>[];
|
||||
for (var i = 0; i < dbCount; i++) {
|
||||
final runner = await _createRunner(
|
||||
rootDir: Directory(p.join(tempRoot.path, 'db_${i + 1}')),
|
||||
accountId: 'sync-account',
|
||||
env: protocol == _Protocol.imap ? imapEnv : jmapEnv,
|
||||
protocol: protocol,
|
||||
);
|
||||
runners.add(runner);
|
||||
}
|
||||
|
||||
try {
|
||||
for (var cycle = 1; cycle <= options.cycles; cycle++) {
|
||||
stdout.writeln('cycle $cycle/${options.cycles}: sync start');
|
||||
await _fullSyncAll(runners);
|
||||
|
||||
final opPlan = await _buildOperationPlan(
|
||||
runners,
|
||||
updates: options.updates,
|
||||
random: random,
|
||||
);
|
||||
|
||||
stdout.writeln(
|
||||
'cycle $cycle/${options.cycles}: running ${opPlan.length} concurrent mutations',
|
||||
);
|
||||
|
||||
await Future.wait(opPlan.map((op) => op.run()));
|
||||
|
||||
await _waitForConvergence(runners, cycle: cycle);
|
||||
stdout.writeln('cycle $cycle/${options.cycles}: OK');
|
||||
}
|
||||
} finally {
|
||||
for (final runner in runners) {
|
||||
await runner.close();
|
||||
}
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
stdout.writeln('\nAll sync reliability checks passed.');
|
||||
}
|
||||
|
||||
Future<void> _waitForConvergence(
|
||||
List<_Runner> runners, {
|
||||
required int cycle,
|
||||
}) async {
|
||||
const maxAttempts = 20;
|
||||
const settleDelay = Duration(milliseconds: 250);
|
||||
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
await _fullSyncAll(runners);
|
||||
try {
|
||||
await _assertSnapshotsEqual(runners, cycle: cycle);
|
||||
if (attempt > 1) {
|
||||
stdout.writeln('cycle $cycle: converged after $attempt sync attempts');
|
||||
}
|
||||
return;
|
||||
} catch (_) {
|
||||
if (attempt == maxAttempts) break;
|
||||
await Future<void>.delayed(settleDelay);
|
||||
}
|
||||
}
|
||||
|
||||
stdout.writeln(
|
||||
'cycle $cycle: convergence not reached, forcing full resync on all DBs',
|
||||
);
|
||||
await Future.wait(runners.map((r) => r.forceFullResync()));
|
||||
|
||||
await _assertSnapshotsEqual(runners, cycle: cycle);
|
||||
}
|
||||
|
||||
Future<void> _fullSyncAll(List<_Runner> runners) async {
|
||||
await Future.wait(runners.map((r) => r.syncAll()));
|
||||
}
|
||||
|
||||
Future<List<_Op>> _buildOperationPlan(
|
||||
List<_Runner> runners, {
|
||||
required int updates,
|
||||
required Random random,
|
||||
}) async {
|
||||
final allIdsSet = <String>{};
|
||||
for (final runner in runners) {
|
||||
allIdsSet.addAll(await runner.emailIds());
|
||||
}
|
||||
final allIds = allIdsSet.toList()..sort();
|
||||
final deletableIds = <String>{...allIds};
|
||||
|
||||
final ops = <_Op>[];
|
||||
|
||||
for (var i = 0; i < updates; i++) {
|
||||
final target = runners[random.nextInt(runners.length)];
|
||||
final roll = random.nextInt(100);
|
||||
|
||||
if (roll < 40 || allIds.isEmpty) {
|
||||
ops.add(
|
||||
_Op(
|
||||
label: 'create',
|
||||
run: () async => target.createMessage(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (roll < 70) {
|
||||
final id = allIds[random.nextInt(allIds.length)];
|
||||
final updateSeen = random.nextBool();
|
||||
final flagValue = random.nextBool();
|
||||
ops.add(
|
||||
_Op(
|
||||
label: 'update',
|
||||
run: () async {
|
||||
await target.setRandomFlag(
|
||||
id,
|
||||
useSeen: updateSeen,
|
||||
value: flagValue,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deletableIds.isEmpty) {
|
||||
ops.add(
|
||||
_Op(
|
||||
label: 'create',
|
||||
run: () async => target.createMessage(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = deletableIds.elementAt(random.nextInt(deletableIds.length));
|
||||
deletableIds.remove(id);
|
||||
ops.add(
|
||||
_Op(
|
||||
label: 'delete',
|
||||
run: () async => target.deleteIfPresent(id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
Future<void> _assertSnapshotsEqual(
|
||||
List<_Runner> runners, {
|
||||
required int cycle,
|
||||
}) async {
|
||||
final baseline = runners.first;
|
||||
final baselineSnapshot = await baseline.snapshot();
|
||||
|
||||
for (var i = 1; i < runners.length; i++) {
|
||||
final snapshot = await runners[i].snapshot();
|
||||
if (baselineSnapshot != snapshot) {
|
||||
final leftLines = const LineSplitter().convert(baselineSnapshot);
|
||||
final rightLines = const LineSplitter().convert(snapshot);
|
||||
final max = leftLines.length > rightLines.length
|
||||
? leftLines.length
|
||||
: rightLines.length;
|
||||
|
||||
var diffLine = -1;
|
||||
for (var line = 0; line < max; line++) {
|
||||
final l = line < leftLines.length ? leftLines[line] : '<missing>';
|
||||
final r = line < rightLines.length ? rightLines[line] : '<missing>';
|
||||
if (l != r) {
|
||||
diffLine = line + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
'DB snapshots differ after cycle $cycle. '
|
||||
'First differing line: $diffLine between db1 and db${i + 1}\n'
|
||||
'--- db1 ---\n$baselineSnapshot\n'
|
||||
'--- db${i + 1} ---\n$snapshot',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < runners.length; i++) {
|
||||
final pending = await runners[i].pendingCount();
|
||||
if (pending != 0) {
|
||||
throw StateError(
|
||||
'Pending queue not empty after cycle $cycle: db${i + 1}=$pending',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Runner> _createRunner({
|
||||
required Directory rootDir,
|
||||
required String accountId,
|
||||
required _StalwartEnv env,
|
||||
required _Protocol protocol,
|
||||
}) async {
|
||||
await rootDir.create(recursive: true);
|
||||
final dbFile = File(p.join(rootDir.path, 'db.sqlite'));
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
final storage = _MemSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, storage);
|
||||
|
||||
final mailboxRepo = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _connectImapPlaintext,
|
||||
httpClient: http.Client(),
|
||||
);
|
||||
final emailRepo = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _connectImapPlaintext,
|
||||
smtpConnect: _connectSmtpPlaintext,
|
||||
httpClient: http.Client(),
|
||||
);
|
||||
|
||||
final account = switch (protocol) {
|
||||
_Protocol.imap => model.Account(
|
||||
id: accountId,
|
||||
displayName: 'Sync Reliability IMAP',
|
||||
email: env.user,
|
||||
imapHost: env.imapHost,
|
||||
imapPort: env.imapPort,
|
||||
smtpHost: env.smtpHost,
|
||||
smtpPort: env.smtpPort,
|
||||
),
|
||||
_Protocol.jmap => model.Account(
|
||||
id: accountId,
|
||||
displayName: 'Sync Reliability JMAP',
|
||||
email: env.user,
|
||||
type: model.AccountType.jmap,
|
||||
jmapUrl: '${env.baseUrl}/.well-known/jmap',
|
||||
imapHost: env.imapHost,
|
||||
imapPort: env.imapPort,
|
||||
smtpHost: env.smtpHost,
|
||||
smtpPort: env.smtpPort,
|
||||
),
|
||||
};
|
||||
|
||||
await accounts.addAccount(account, env.password);
|
||||
|
||||
return _Runner(
|
||||
protocol: protocol,
|
||||
accountId: accountId,
|
||||
accountEmail: env.user,
|
||||
imapHost: env.imapHost,
|
||||
imapPort: env.imapPort,
|
||||
accountPassword: env.password,
|
||||
db: db,
|
||||
mailboxes: mailboxRepo,
|
||||
emails: emailRepo,
|
||||
);
|
||||
}
|
||||
|
||||
Future<mail.ImapClient> _connectImapPlaintext(
|
||||
model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = mail.ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<mail.SmtpClient> _connectSmtpPlaintext(
|
||||
model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final at = account.email.lastIndexOf('@');
|
||||
final domain = at == -1 ? account.smtpHost : account.email.substring(at + 1);
|
||||
final client = mail.SmtpClient(domain);
|
||||
await client.connectToServer(
|
||||
account.smtpHost,
|
||||
account.smtpPort,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
isSecure: false,
|
||||
);
|
||||
await client.ehlo();
|
||||
await client.authenticate(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
class _Runner {
|
||||
_Runner({
|
||||
required this.protocol,
|
||||
required this.accountId,
|
||||
required this.accountEmail,
|
||||
required this.imapHost,
|
||||
required this.imapPort,
|
||||
required this.accountPassword,
|
||||
required this.db,
|
||||
required this.mailboxes,
|
||||
required this.emails,
|
||||
});
|
||||
|
||||
final _Protocol protocol;
|
||||
final String accountId;
|
||||
final String accountEmail;
|
||||
final String imapHost;
|
||||
final int imapPort;
|
||||
final String accountPassword;
|
||||
final AppDatabase db;
|
||||
final MailboxRepositoryImpl mailboxes;
|
||||
final EmailRepositoryImpl emails;
|
||||
|
||||
int _createCounter = 0;
|
||||
|
||||
Future<void> syncAll() async {
|
||||
await emails.flushPendingChanges(accountId, accountPassword);
|
||||
await mailboxes.syncMailboxes(accountId);
|
||||
final mailboxRows = await (db.select(db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.path)]))
|
||||
.get();
|
||||
if (mailboxRows.isEmpty) {
|
||||
throw StateError('No mailboxes found for account $accountId after sync');
|
||||
}
|
||||
|
||||
for (final mailbox in mailboxRows) {
|
||||
await emails.syncEmails(accountId, mailbox.path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createMessage() async {
|
||||
_createCounter++;
|
||||
final now = DateTime.now().microsecondsSinceEpoch;
|
||||
final builder = mail.MessageBuilder()
|
||||
..from = [mail.MailAddress('Sync Bot', accountEmail)]
|
||||
..to = [mail.MailAddress('Sync Bot', accountEmail)]
|
||||
..subject = 'sync-reliability-${protocol.name}-$now-$_createCounter'
|
||||
..text = 'sync reliability body $now';
|
||||
final client = mail.ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(
|
||||
imapHost,
|
||||
imapPort,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
isSecure: false,
|
||||
);
|
||||
try {
|
||||
await client.login(accountEmail, accountPassword);
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setRandomFlag(
|
||||
String emailId, {
|
||||
required bool useSeen,
|
||||
required bool value,
|
||||
}) async {
|
||||
if (!await _emailExists(emailId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useSeen) {
|
||||
await emails.setFlag(emailId, seen: value);
|
||||
} else {
|
||||
await emails.setFlag(emailId, flagged: value);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIfPresent(String emailId) async {
|
||||
if (!await _emailExists(emailId)) {
|
||||
return;
|
||||
}
|
||||
await emails.deleteEmail(emailId);
|
||||
}
|
||||
|
||||
Future<bool> _emailExists(String emailId) async {
|
||||
final row = await (db.select(db.emails)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
return row != null;
|
||||
}
|
||||
|
||||
Future<List<String>> emailIds() async {
|
||||
final rows = await (db.select(db.emails)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
return rows.map((e) => e.id).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<int> pendingCount() async {
|
||||
final rows = await (db.select(db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
Future<String> snapshot() async {
|
||||
final mailboxRows = await (db.select(db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.path)]))
|
||||
.get();
|
||||
final emailRows = await (db.select(db.emails)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
final pendingRows = await (db.select(db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
|
||||
final obj = {
|
||||
'mailboxes': mailboxRows
|
||||
.map(
|
||||
(r) => {
|
||||
'id': r.id,
|
||||
'accountId': r.accountId,
|
||||
'path': r.path,
|
||||
'name': r.name,
|
||||
'unreadCount': r.unreadCount,
|
||||
'totalCount': r.totalCount,
|
||||
'role': r.role,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
'emails': emailRows
|
||||
.map(
|
||||
(r) => {
|
||||
'id': r.id,
|
||||
'accountId': r.accountId,
|
||||
'mailboxPath': r.mailboxPath,
|
||||
'uid': r.uid,
|
||||
'subject': r.subject,
|
||||
'fromJson': r.fromJson,
|
||||
'toAddresses': r.toAddresses,
|
||||
'ccJson': r.ccJson,
|
||||
'preview': r.preview,
|
||||
'isSeen': r.isSeen,
|
||||
'isFlagged': r.isFlagged,
|
||||
'hasAttachment': r.hasAttachment,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
'pendingChanges': pendingRows
|
||||
.map(
|
||||
(r) => {
|
||||
'accountId': r.accountId,
|
||||
'resourceType': r.resourceType,
|
||||
'resourceId': r.resourceId,
|
||||
'changeType': r.changeType,
|
||||
'payload': r.payload,
|
||||
'attempts': r.attempts,
|
||||
'lastError': r.lastError,
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
};
|
||||
|
||||
return const JsonEncoder.withIndent(' ').convert(obj);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
Future<void> forceFullResync() async {
|
||||
await (db.delete(db.syncStates)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (db.delete(db.emails)..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (db.delete(db.mailboxes)..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await syncAll();
|
||||
}
|
||||
}
|
||||
|
||||
class _MemSecureStorage implements SecureStorage {
|
||||
final Map<String, String> _values = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async => _values[key];
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) async {
|
||||
if (value == null) {
|
||||
_values.remove(key);
|
||||
} else {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async {
|
||||
_values.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
class _Op {
|
||||
_Op({required this.label, required this.run});
|
||||
|
||||
final String label;
|
||||
final Future<void> Function() run;
|
||||
}
|
||||
|
||||
class _StalwartEnv {
|
||||
const _StalwartEnv({
|
||||
required this.baseUrl,
|
||||
required this.imapHost,
|
||||
required this.imapPort,
|
||||
required this.smtpHost,
|
||||
required this.smtpPort,
|
||||
required this.user,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String imapHost;
|
||||
final int imapPort;
|
||||
final String smtpHost;
|
||||
final int smtpPort;
|
||||
final String user;
|
||||
final String password;
|
||||
|
||||
factory _StalwartEnv.fromEnvironment() {
|
||||
final baseUrl =
|
||||
Platform.environment['STALWART_URL'] ?? 'http://127.0.0.1:8080';
|
||||
final imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
final imapPort =
|
||||
int.tryParse(Platform.environment['STALWART_IMAP_PORT'] ?? '') ?? 1430;
|
||||
final smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
||||
final smtpPort =
|
||||
int.tryParse(Platform.environment['STALWART_SMTP_PORT'] ?? '') ?? 1025;
|
||||
final user = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
||||
final password = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
||||
|
||||
return _StalwartEnv(
|
||||
baseUrl: baseUrl,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
user: user,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Options {
|
||||
const _Options({
|
||||
required this.updates,
|
||||
required this.cycles,
|
||||
required this.imapDbs,
|
||||
required this.jmapDbs,
|
||||
});
|
||||
|
||||
final int updates;
|
||||
final int cycles;
|
||||
final int imapDbs;
|
||||
final int jmapDbs;
|
||||
}
|
||||
|
||||
enum _Protocol { imap, jmap }
|
||||
|
||||
_Options _parseOptions(List<String> args) {
|
||||
var updates = 10;
|
||||
var cycles = 3;
|
||||
var imapDbs = 1;
|
||||
var jmapDbs = 1;
|
||||
|
||||
final positionals = <String>[];
|
||||
|
||||
for (final arg in args) {
|
||||
if (arg == '--help' || arg == '-h') {
|
||||
_printUsageAndExit(0);
|
||||
}
|
||||
|
||||
if (arg.startsWith('--updates=')) {
|
||||
updates = _parsePositiveInt(arg.split('=').last, '--updates');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--cycles=')) {
|
||||
cycles = _parsePositiveInt(arg.split('=').last, '--cycles');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--imap-dbs=')) {
|
||||
imapDbs = _parseNonNegativeInt(arg.split('=').last, '--imap-dbs');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--jmap-dbs=')) {
|
||||
jmapDbs = _parseNonNegativeInt(arg.split('=').last, '--jmap-dbs');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
throw StateError('Unknown option: $arg');
|
||||
}
|
||||
|
||||
positionals.add(arg);
|
||||
}
|
||||
|
||||
if (positionals.isNotEmpty) {
|
||||
updates = _parsePositiveInt(positionals[0], 'updates');
|
||||
}
|
||||
if (positionals.length > 1) {
|
||||
cycles = _parsePositiveInt(positionals[1], 'cycles');
|
||||
}
|
||||
if (positionals.length > 2) {
|
||||
throw StateError('Too many positional args: $positionals');
|
||||
}
|
||||
|
||||
if (imapDbs == 0 && jmapDbs == 0) {
|
||||
throw StateError('At least one of --imap-dbs or --jmap-dbs must be > 0');
|
||||
}
|
||||
|
||||
return _Options(
|
||||
updates: updates,
|
||||
cycles: cycles,
|
||||
imapDbs: imapDbs,
|
||||
jmapDbs: jmapDbs,
|
||||
);
|
||||
}
|
||||
|
||||
int _parsePositiveInt(String value, String name) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed == null || parsed <= 0) {
|
||||
throw StateError('$name must be a positive integer, got "$value"');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
int _parseNonNegativeInt(String value, String name) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed == null || parsed < 0) {
|
||||
throw StateError('$name must be a non-negative integer, got "$value"');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
Never _printUsageAndExit(int code) {
|
||||
stdout.writeln(
|
||||
'Usage: fvm flutter pub run scripts/sync_reliability.dart [updates] [cycles] '
|
||||
'[--imap-dbs=N] [--jmap-dbs=N]\n\n'
|
||||
'Defaults: updates=10 cycles=3 imap-dbs=1 jmap-dbs=1',
|
||||
);
|
||||
exit(code);
|
||||
}
|
||||
Reference in New Issue
Block a user