Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 4f9a4e666f fix: make ChangeLog screen testable with DefaultAssetBundle (#198)
Switch ChangeLogScreen from rootBundle to DefaultAssetBundle.of(context)
so the asset source can be overridden in widget tests. Add
test/widget/changelog_screen_test.dart with a happy-path test (asset loads
and content is rendered) and an error-path test (missing asset shows the
error message).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:51:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f962168c1a fix(agent_loop): harden loop against redundant agents, bad merges, wrong closures
- Add merge verification to the pending_issue PR path (section 2), matching
  the catch-up scan fix: if the PR is still open after _merge_pr returns, set
  State/Question instead of claiming success and leaving the issue closed with
  an unmerged PR.

- Replace _latest_ci_run() with _latest_main_ci_run() that filters to
  non-pull_request events on the 'main' prettyref.  The old limit=1 query
  could return a PR-branch run, causing section 3 to misread CI as failed
  and spawn a ci-fix agent when main was actually fine.

- Guard against redundant ci-fix agents: when the same main CI run ID has been
  failing since the previous ci-fix started (agent pushed to a branch, not
  main), check for any in-flight CI run before spawning another agent.

- Issue agent prompt: explicitly forbid "Closes #N" / "Fixes #N" in commit
  messages.  The loop is responsible for closing issues after CI passes;
  commit-keyword auto-close would race with or bypass that logic.

- Global ci-fix prompt: restore "push directly to main" (ci-fix agents need to
  land on main to clear the main CI run) and keep the "no issue references"
  guard added in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:09:32 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ff488746a0 fix: show git hash as clickable link above stacktrace on crash screen (#201)
Previous PRs (#150, #179) added partial implementations that left duplicate
code via a rebase conflict: plain (non-linked) text above the stacktrace and a
clickable link section below it. This consolidates both into a single clickable
link above the stacktrace.

Also makes `gitHash` an injectable constructor parameter so tests can exercise
the link without needing a release build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:55:38 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d0973fafbf fix(agent_loop): prevent infinite catch-up merge retry and wrong issue closure
Two bugs fixed:

1. Catch-up scan (section 2b) called _merge_pr and immediately returned,
   claiming success even when fgj exits 0 but the merge silently failed
   (e.g. branch-protection rules not satisfied). PR #163 was retried 30+
   times in a row because the PR stayed open after each attempt.
   Fix: verify the PR is no longer open after the merge call; if it is still
   open, set the issue to State/Question instead of looping forever.

2. ci-fix agents wrote "Closes #198" in commit messages, causing Forgejo to
   auto-close issue #198 ("Unable to load asset: assets/changelog.txt") even
   though the commit only fixed the unrelated Play Store upload.
   Fix: both ci-fix prompts now explicitly forbid issue-number references in
   commit messages and close operations. Also save ci_run_id_at_start in
   the ci-fix state (was only done for issue agents) so future guard logic
   can compare run IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:48:00 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7310568157 fix: merge orphaned issue PRs whose CI passed but state was cleared (#200)
Add catch-up scan in agent_loop that finds all open issue-N-fix PRs and
merges those with passed CI, using event-filtered API query (limit=50)
to cover weeks of history instead of the previous ~1.5 h window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:49:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a569177637 fix: treat MissingPluginException from secure storage as permanent sync error (#200)
When flutter_secure_storage's platform channel is unavailable (e.g. on
certain Android devices), getPassword() throws MissingPluginException.
Previously this was not recognised as a permanent error, so the IMAP and
JMAP sync loops retried indefinitely with exponential back-off, filling
the sync log with repeated failures (as shown in the screenshot).

Treat MissingPluginException as a permanent error in both _AccountSync
and _JmapAccountSync so the loop stops immediately instead of retrying.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:46:29 +02:00
7 changed files with 365 additions and 53 deletions
+3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') ||
@@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop {
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase();
return s.contains('invalid credentials') ||
s.contains('authentication failed') ||
+2 -2
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future: rootBundle.loadString('assets/changelog.txt'),
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
+24 -35
View File
@@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget {
super.key,
required this.exception,
required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
});
final Object exception;
final StackTrace? stackTrace;
static const _gitHash = String.fromEnvironment('GIT_HASH');
final String gitHash;
Future<String> _buildReport() async {
String version = 'unknown';
@@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget {
} catch (_) {}
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = _gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
return 'App Version: $version\n'
'$gitLine'
@@ -56,12 +56,27 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (_gitHash.isNotEmpty) ...[
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'Git Commit: $_gitHash',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 24),
@@ -106,32 +121,6 @@ class CrashScreen extends StatelessWidget {
),
),
],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
+160 -16
View File
@@ -8,12 +8,15 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI
a. CI is running → save pending-ci state, exit 0
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
c. CI ok + pending_issue → close the issue (CI passed), exit 0
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
e. No Ready issues → print "nothing to do", exit 0
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
c. Main CI running → save pending-ci state, exit 0
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2a always returns first)
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
g. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
@@ -31,6 +34,7 @@ To resume the Claude conversation, look up the session UUID first:
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
@@ -141,10 +145,19 @@ def _ready_issues() -> list[dict]:
return ready
def _latest_ci_run() -> dict | None:
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
def _latest_main_ci_run() -> dict | None:
"""Return the latest CI run on the main branch (excludes PR runs).
Using the global latest run (limit=1) is wrong: a passing or failing run
on a PR branch could mask the true state of main. We filter to non-PR
events on the 'main' prettyref so section-3 logic only reacts to main.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
return runs[0] if runs else None
for run in runs:
if run.get("event") != "pull_request" and run.get("prettyref") == "main":
return run
return None
def _latest_ci_run_for_branch(branch: str) -> dict | None:
@@ -188,6 +201,40 @@ def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None:
return None
def _open_issue_prs() -> list[dict]:
"""Return all open PRs with issue-{N}-fix branches, oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
issue_prs = []
for pr in prs:
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
issue_prs.append(pr)
issue_prs.sort(key=lambda p: p["number"])
return issue_prs
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
@@ -474,6 +521,9 @@ def _run_loop() -> int:
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push to the same branch. "
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
"issue via a commit message would be a bug. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
@@ -512,7 +562,25 @@ def _run_loop() -> int:
# CI passed on the PR branch — squash-merge and close.
print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.")
_merge_pr(pr_number)
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.",
)
return 0
if _find_pr_for_branch(branch):
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
pending_issue,
f"Automatic merge of PR #{pr_number} failed (PR is still open after the "
"merge command). Please merge manually.",
)
return 0
_close_issue(pending_issue)
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
return 0
@@ -538,8 +606,59 @@ def _run_loop() -> int:
)
return 0
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
run = _latest_ci_run()
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
# This handles PRs whose CI has passed but were never merged because the
# state file was cleared (loop restart, killed agent, manual intervention).
open_prs = _open_issue_prs()
for pr in open_prs:
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
issue_num = int(m.group(1)) if m else None
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
_write_state(None, issue_num, "pending-ci")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
continue
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
)
if issue_num:
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue_num,
f"Automatic merge of PR #{pr_number} failed (PR is still open "
"after the merge command). Please merge manually.",
)
continue
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
else:
print(f"Merged PR #{pr_number}.")
return 0
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
if run and run.get("status") == "running":
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
@@ -548,17 +667,39 @@ def _run_loop() -> int:
return 0
if run and run.get("status") in ("failure", "error"):
# Guard: if the same main CI run has been failing since the last ci-fix
# agent started, that agent pushed to a branch instead of main. Before
# spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [
r for r in (check or {}).get("workflow_runs", [])
if r.get("status") == "running"
]
if in_flight:
print(
f"Main CI still shows the same failed run {run['id']}; "
f"{_ci_run_url(in_flight[0]['id'])} is running "
"(previous ci-fix pushed to a branch). Waiting."
)
return 0
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
prompt = (
"The Codeberg CI for guettli/sharedinbox just failed. "
"The Codeberg CI for guettli/sharedinbox just failed on the main branch. "
f"The CI run ID is {run['id']}. "
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push. "
"Identify the failure, fix it, commit, and push directly to main. "
"Verify locally with 'task check' before pushing. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing an issue via a commit message would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
pid = _start_agent(prompt, "ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
ci_run_id=run["id"] if run else None)
return 0
# CI is ok (or no run).
@@ -617,7 +758,10 @@ Instructions:
- Implement the required change, following the existing code style.
- Write or update tests as appropriate.
- Run 'task check' locally and fix any failures before committing.
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
- Commit with a descriptive message and include (#{issue_number}) in the title,
e.g. "feat: description (#{issue_number})".
Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue
after CI passes; using those keywords would close it prematurely or wrongly.
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
git checkout -b issue-{issue_number}-fix
git push -u origin issue-{issue_number}-fix
+67
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -30,6 +32,40 @@ void main() {
// This is hard to test without real loops, but we can verify it doesn't crash.
manager.syncNow('unknown');
});
// Regression test for issue #200: when flutter_secure_storage throws
// MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff.
test(
'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async {
final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
syncLog: syncLog,
);
m.start();
// Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1));
m.dispose();
});
}
class FakeEmailRepository implements EmailRepository {
@@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
static const _account = Account(
id: '1',
displayName: 'Test',
email: 'test@example.com',
);
@override
Stream<List<Account>> observeAccounts() => Stream.value([_account]);
@override
Future<Account?> getAccount(String id) async => _account;
@override
Future<String> getPassword(String accountId) => Future.error(
MissingPluginException(
'No implementation found for method read on channel '
'plugins.it.nomads.com/flutter_secure_storage',
),
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends Fake implements AssetBundle {
final String content;
_FakeAssetBundle(this.content);
@override
Future<String> loadString(String key, {bool cache = true}) async {
if (key == 'assets/changelog.txt') return content;
throw FlutterError('Asset not found: $key');
}
@override
Future<ByteData> load(String key) async {
throw FlutterError('Asset not found: $key');
}
}
void main() {
testWidgets('ChangeLogScreen renders changelog content', (tester) async {
const fakeChangelog =
'* 2026-01-01: Initial release\n* 2026-01-02: Bug fix';
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle(fakeChangelog),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('2026-01-01'), findsOneWidget);
expect(find.textContaining('Initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _BadAssetBundle(),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog:'), findsOneWidget);
});
}
class _BadAssetBundle extends Fake implements AssetBundle {
@override
Future<String> loadString(String key, {bool cache = true}) async {
throw FlutterError('Unable to load asset: "$key"');
}
@override
Future<ByteData> load(String key) async {
throw FlutterError('Unable to load asset: "$key"');
}
}
+44
View File
@@ -123,6 +123,50 @@ void main() {
},
);
testWidgets(
'CrashScreen shows git hash as clickable link above stacktrace',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
// Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:');
expect(
tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy),
);
// Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder);
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
);
},
);
testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {