diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3186575..2ea8f0b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,12 +4,32 @@ on: branches: - main pull_request: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: check: name: Full Project Check runs-on: ubuntu-latest timeout-minutes: 60 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 - name: Setup Dagger Remote Engine env: diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 1d6bc87..7ad874a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -15,6 +15,23 @@ jobs: linux: ${{ steps.diff.outputs.linux }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -141,6 +158,23 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -175,6 +209,23 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -203,6 +254,23 @@ jobs: if: needs.check-changes.outputs.linux == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -236,6 +304,23 @@ jobs: timeout-minutes: 5 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue env: FORGEJO_TOKEN: ${{ github.token }} diff --git a/.forgejo/workflows/firebase-tests.yml b/.forgejo/workflows/firebase-tests.yml index edd3e81..7022957 100644 --- a/.forgejo/workflows/firebase-tests.yml +++ b/.forgejo/workflows/firebase-tests.yml @@ -14,6 +14,23 @@ jobs: has_changes: ${{ steps.diff.outputs.has_changes }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -50,6 +67,23 @@ jobs: if: needs.check-changes.outputs.has_changes == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/.forgejo/workflows/website.yml b/.forgejo/workflows/website.yml index 661d402..ea67892 100644 --- a/.forgejo/workflows/website.yml +++ b/.forgejo/workflows/website.yml @@ -124,6 +124,23 @@ jobs: if: needs.check-changes.outputs.has_changes == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: submodules: recursive diff --git a/ci/main.go b/ci/main.go index a7b8423..b167724 100644 --- a/ci/main.go +++ b/ci/main.go @@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } @@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 3bcdc01..2cfbc93 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository { final sql = accountId != null ? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' + ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50' : 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; + ' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50'; final variables = accountId != null ? [Variable(ftsQuery), Variable(accountId)] : [Variable(ftsQuery)]; @@ -2942,6 +2942,7 @@ class EmailRepositoryImpl implements EmailRepository { for (final e in [...emailRows.map(_toModel), ...noteRows]) { if (seen.add(e.id)) merged.add(e); } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); return merged; } @@ -2952,16 +2953,12 @@ class EmailRepositoryImpl implements EmailRepository { String? mailboxPath, String query, ) async { - final words = query - .trim() - .split(RegExp(r'\s+')) - .where((w) => w.isNotEmpty) - .toList(); + final words = + query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); if (words.isEmpty) return []; final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND '); - final likeVars = - words.map((w) => Variable('%$w%')).toList(); + final likeVars = words.map((w) => Variable('%$w%')).toList(); final extraConditions = StringBuffer(); final extraVars = >[]; @@ -2980,14 +2977,13 @@ class EmailRepositoryImpl implements EmailRepository { ' WHERE $noteConditions$extraConditions' ' ORDER BY e.received_at DESC LIMIT 50'; - final rows = await _db - .customSelect( - sql, - variables: [...likeVars, ...extraVars], - readsFrom: {_db.emails, _db.emailNotes}, - ) - .get(); - final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); + final rows = await _db.customSelect( + sql, + variables: [...likeVars, ...extraVars], + readsFrom: {_db.emails, _db.emailNotes}, + ).get(); + final emailRows = + await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); return emailRows.map(_toModel).toList(); } @@ -2997,9 +2993,7 @@ class EmailRepositoryImpl implements EmailRepository { static String _toFtsQuery(String query) { final words = query .trim() - .split(RegExp(r'\s+')) - .where((w) => w.isNotEmpty) - .map((w) => w.replaceAll(RegExp(r'[^\w]'), '')) + .split(RegExp(r'[^\w]+')) .where((w) => w.isNotEmpty) .toList(); if (words.isEmpty) return ''; @@ -3101,6 +3095,8 @@ class EmailRepositoryImpl implements EmailRepository { } @override + // Results are limited to emails already synced into the local SQLite FTS5 + // index; call syncEmails first to ensure the index is up-to-date. Future> searchEmails( String accountId, String mailboxPath, @@ -3111,7 +3107,7 @@ class EmailRepositoryImpl implements EmailRepository { const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?' - ' ORDER BY rank LIMIT 50'; + ' ORDER BY e.received_at DESC LIMIT 50'; final variables = [ Variable(ftsQuery), Variable(accountId), @@ -3124,14 +3120,14 @@ class EmailRepositoryImpl implements EmailRepository { queryRows.map((r) => _db.emails.mapFromRow(r)), ); - final noteRows = - await _searchEmailsByNotes(accountId, mailboxPath, query); + final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query); final seen = {}; final merged = []; for (final e in [...emailRows.map(_toModel), ...noteRows]) { if (seen.add(e.id)) merged.add(e); } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); return merged; } diff --git a/lib/main.dart b/lib/main.dart index 20c6a2a..5bae652 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState { theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( @@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState { brightness: Brightness.dark, ), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), routerConfig: router, ); diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 1567556..c23f192 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( appBar: AppBar( title: const Text('Something went wrong'), diff --git a/test/backend/chaos_monkey_test.dart b/test/backend/chaos_monkey_test.dart index f6715a4..485f387 100644 --- a/test/backend/chaos_monkey_test.dart +++ b/test/backend/chaos_monkey_test.dart @@ -10,6 +10,9 @@ // CHAOS_ROUNDS (default: 30) — number of random operations to perform // CHAOS_SEED (default: current epoch ms) — seed for reproducibility +@Tags(['nightly']) +library; + import 'dart:io'; import 'dart:math'; @@ -132,7 +135,7 @@ void main() { tearDown(() => db.close()); test('chaos monkey — random operations do not crash the repository', - () async { + timeout: Timeout.none, () async { final seedStr = _env('CHAOS_SEED'); final seed = seedStr.isEmpty ? DateTime.now().millisecondsSinceEpoch diff --git a/test/backend/email_repository_imap_test.dart b/test/backend/email_repository_imap_test.dart index 50e009e..6d20993 100644 --- a/test/backend/email_repository_imap_test.dart +++ b/test/backend/email_repository_imap_test.dart @@ -433,6 +433,7 @@ void main() { final r = makeRepo(); await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); final results = await r.emails.searchEmails( 'test', diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index ff24382..710990e 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -514,8 +514,7 @@ void main() { ), ); - final results = - await r.emails.searchEmailsGlobal(null, 'urgent'); + final results = await r.emails.searchEmailsGlobal(null, 'urgent'); expect(results, hasLength(1)); expect(results.first.subject, 'Weekly report'); }); @@ -569,13 +568,76 @@ void main() { ), ); - final results = - await r.emails.searchEmails('acc-1', 'INBOX', 'client'); + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client'); expect(results, hasLength(1)); expect(results.first.subject, 'Project update'); expect(results.first.mailboxPath, 'INBOX'); }); + test('searchEmailsGlobal returns results sorted by receivedAt descending', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + subject: const Value('Older report'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer report'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmailsGlobal(null, 'report'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer report'); + expect(results[1].subject, 'Older report'); + }); + + test('searchEmails returns results sorted by receivedAt descending', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + subject: const Value('Older meeting'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer meeting'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer meeting'); + expect(results[1].subject, 'Older meeting'); + }); + test( 'searchAddresses returns results sorted by most recently used', () async { diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index 2c3cdd7..64c5988 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -50,7 +50,10 @@ Widget _buildScreen({List accounts = const []}) { FakeAccountRepository(accounts), ), ], - child: const MaterialApp(home: AboutScreen()), + child: MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), + home: const AboutScreen(), + ), ); }