Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9a4e666f | ||
|
|
f962168c1a | ||
|
|
ff488746a0 | ||
|
|
d0973fafbf | ||
|
|
7310568157 | ||
|
|
a569177637 | ||
|
|
375fd5d914 | ||
|
|
7ece6f09e5 |
@@ -3,7 +3,41 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
|||||||
@@ -6,10 +6,55 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Changed Files
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
android: ${{ steps.diff.outputs.android }}
|
||||||
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Detect Android and Linux changes
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# On workflow_dispatch always build everything
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||||
|
# when the parent is unavailable (initial commit, shallow clone).
|
||||||
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
||||||
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
|
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||||
|
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||||
|
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||||
|
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
name: Android Instrumented Tests (Firebase Test Lab)
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -46,6 +91,8 @@ jobs:
|
|||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -83,6 +130,8 @@ jobs:
|
|||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -122,6 +171,8 @@ jobs:
|
|||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -200,7 +251,13 @@ jobs:
|
|||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
if: |
|
||||||
|
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||||
|
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||||
|
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||||
|
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||||
|
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||||
|
)
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -209,7 +266,7 @@ jobs:
|
|||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
FORGEJO_URL: ${{ github.server_url }}
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
|
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
python3 - << 'PYEOF'
|
||||||
import os, json, urllib.request, urllib.error
|
import os, json, urllib.request, urllib.error
|
||||||
|
|||||||
+15
-6
@@ -835,16 +835,25 @@ flowchart TD
|
|||||||
integration --> check
|
integration --> check
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
|
||||||
ciCheck["check"]
|
ciCheck["check"]
|
||||||
buildLinux["build-linux\n(main only)"]
|
end
|
||||||
deployPS["deploy-playstore\n(main only)"]
|
|
||||||
pubWeb["publish-website\n(main only)"]
|
|
||||||
|
|
||||||
ciCheck --> buildLinux
|
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
|
||||||
ciCheck --> deployPS
|
detectChanges["check-changes\ndetect android / linux diff"]
|
||||||
|
buildLinux["build-linux\n(linux changed)"]
|
||||||
|
deployPS["deploy-playstore\n(android changed)"]
|
||||||
|
deployApk["deploy-apk\n(android changed)"]
|
||||||
|
fbTest["test-android-firebase\n(android changed)"]
|
||||||
|
pubWeb["publish-website\n(any build succeeded)"]
|
||||||
|
|
||||||
|
detectChanges --> buildLinux
|
||||||
|
detectChanges --> deployPS
|
||||||
|
detectChanges --> deployApk
|
||||||
|
detectChanges --> fbTest
|
||||||
buildLinux --> pubWeb
|
buildLinux --> pubWeb
|
||||||
deployPS --> pubWeb
|
deployPS --> pubWeb
|
||||||
|
deployApk --> pubWeb
|
||||||
end
|
end
|
||||||
|
|
||||||
check -- "task check-dagger" --> ciCheck
|
check -- "task check-dagger" --> ciCheck
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
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/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
@@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
s.contains('authentication failed') ||
|
s.contains('authentication failed') ||
|
||||||
|
|||||||
@@ -47,10 +47,14 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
|
||||||
return '## sharedinbox.de\n\n'
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
: '';
|
||||||
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
'| Property | Value |\n'
|
'| Property | Value |\n'
|
||||||
'|----------|-------|\n'
|
'|----------|-------|\n'
|
||||||
'| App Version | $versionDisplay |\n'
|
'| App Version | $versionDisplay |\n'
|
||||||
|
'$gitCommitLine'
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
'| Resolution | ${physW}x$physH px'
|
'| Resolution | ${physW}x$physH px'
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: rootBundle.loadString('assets/changelog.txt'),
|
future:
|
||||||
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.exception,
|
required this.exception,
|
||||||
required this.stackTrace,
|
required this.stackTrace,
|
||||||
|
this.gitHash = const String.fromEnvironment('GIT_HASH'),
|
||||||
});
|
});
|
||||||
|
|
||||||
final Object exception;
|
final Object exception;
|
||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
|
final String gitHash;
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
Future<String> _buildReport() async {
|
||||||
String version = 'unknown';
|
String version = 'unknown';
|
||||||
@@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final gitLine = _gitHash.isNotEmpty
|
final gitLine = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n'
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
return 'App Version: $version\n'
|
return 'App Version: $version\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
@@ -56,12 +56,27 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (_gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
GestureDetector(
|
||||||
'Git Commit: $_gitHash',
|
onTap: () async {
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
final url = Uri.parse(
|
||||||
textAlign: TextAlign.center,
|
'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),
|
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),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|||||||
+160
-16
@@ -8,12 +8,15 @@ Flow
|
|||||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
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)
|
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
|
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||||
a. CI is running → save pending-ci state, exit 0
|
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
|
||||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
|
||||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
c. Main CI running → save pending-ci state, exit 0
|
||||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
|
||||||
save state, exit 0
|
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
|
||||||
e. No Ready issues → print "nothing to do", exit 0
|
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.
|
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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -141,10 +145,19 @@ def _ready_issues() -> list[dict]:
|
|||||||
return ready
|
return ready
|
||||||
|
|
||||||
|
|
||||||
def _latest_ci_run() -> dict | None:
|
def _latest_main_ci_run() -> dict | None:
|
||||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
"""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", [])
|
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:
|
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
|
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:
|
def _merge_pr(pr_number: int) -> None:
|
||||||
"""Squash-merge a PR via fgj."""
|
"""Squash-merge a PR via fgj."""
|
||||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
_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. "
|
"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. "
|
"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 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. "
|
"Verify locally with 'task check' before pushing. "
|
||||||
"When done, stop."
|
"When done, stop."
|
||||||
)
|
)
|
||||||
@@ -512,7 +562,25 @@ def _run_loop() -> int:
|
|||||||
|
|
||||||
# CI passed on the PR branch — squash-merge and close.
|
# 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}.")
|
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)
|
_close_issue(pending_issue)
|
||||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||||
return 0
|
return 0
|
||||||
@@ -538,8 +606,59 @@ def _run_loop() -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
# ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ─────
|
||||||
run = _latest_ci_run()
|
# 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":
|
if run and run.get("status") == "running":
|
||||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||||
@@ -548,17 +667,39 @@ def _run_loop() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if run and run.get("status") in ("failure", "error"):
|
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.")
|
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||||
prompt = (
|
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']}. "
|
f"The CI run ID is {run['id']}. "
|
||||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
"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. "
|
"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."
|
"When done, stop."
|
||||||
)
|
)
|
||||||
pid = _start_agent(prompt, "ci-fix")
|
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
|
return 0
|
||||||
|
|
||||||
# CI is ok (or no run).
|
# CI is ok (or no run).
|
||||||
@@ -617,7 +758,10 @@ Instructions:
|
|||||||
- Implement the required change, following the existing code style.
|
- Implement the required change, following the existing code style.
|
||||||
- Write or update tests as appropriate.
|
- Write or update tests as appropriate.
|
||||||
- Run 'task check' locally and fix any failures before committing.
|
- 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:
|
- 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 checkout -b issue-{issue_number}-fix
|
||||||
git push -u origin issue-{issue_number}-fix
|
git push -u origin issue-{issue_number}-fix
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.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.
|
// This is hard to test without real loops, but we can verify it doesn't crash.
|
||||||
manager.syncNow('unknown');
|
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 {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {}
|
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 {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ void main() {
|
|||||||
expect(clipboardText, contains('Dark Mode'));
|
expect(clipboardText, contains('Dark Mode'));
|
||||||
expect(clipboardText, contains('IMAP Accounts'));
|
expect(clipboardText, contains('IMAP Accounts'));
|
||||||
expect(clipboardText, contains('JMAP Accounts'));
|
expect(clipboardText, contains('JMAP Accounts'));
|
||||||
|
expect(
|
||||||
|
clipboardText,
|
||||||
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
testWidgets(
|
||||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user