Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
004aa9e837 | ||
|
|
3868c160d3 | ||
|
|
50fc012e81 | ||
|
|
94b20f50be | ||
|
|
885906b204 | ||
|
|
06df3ee200 | ||
|
|
e03c7708ba | ||
|
|
27bef3356e | ||
|
|
32ba916cbf | ||
|
|
86e12ffe72 |
@@ -38,7 +38,7 @@ jobs:
|
|||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
|
|
||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -136,7 +136,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -178,7 +178,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Monitor Agent Loop
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */2 * * *' # every 2 hours
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
monitor:
|
||||||
|
name: Check Agent Loop Health
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check agent loop heartbeat
|
||||||
|
run: python3 scripts/agent_loop.py monitor
|
||||||
@@ -238,6 +238,7 @@ tasks:
|
|||||||
|
|
||||||
publish-android:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
|
deps: [generate-changelog]
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
|||||||
|
|
||||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
||||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final version = await _fetchVersion();
|
final version = await _fetchVersion();
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
|
final versionDisplay = gitHash.isNotEmpty
|
||||||
|
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
|
||||||
|
: version;
|
||||||
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'
|
||||||
: '';
|
: '';
|
||||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||||
return 'App Version: $version\n'
|
return 'App Version: $versionDisplay\n'
|
||||||
'Build Mode: $_buildMode\n'
|
'Build Mode: $_buildMode\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n'
|
'Platform: $platform\n'
|
||||||
@@ -86,6 +89,35 @@ class CrashScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: PackageInfo.fromPlatform(),
|
||||||
|
builder: (_, snapshot) {
|
||||||
|
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||||
|
final version =
|
||||||
|
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||||
|
);
|
||||||
|
await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'App Version: $version',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
var _sieveSsl = true;
|
var _sieveSsl = true;
|
||||||
var _verbose = false;
|
var _verbose = false;
|
||||||
final _jmapUrlCtrl = TextEditingController();
|
final _jmapUrlCtrl = TextEditingController();
|
||||||
|
bool _hasStoredPassword = false;
|
||||||
|
|
||||||
// -- "Try connection" state ------------------------------------------------
|
// -- "Try connection" state ------------------------------------------------
|
||||||
bool _tryTesting = false;
|
bool _tryTesting = false;
|
||||||
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.addListener(_rebuild);
|
_smtpHostCtrl.addListener(_rebuild);
|
||||||
_sieveHostCtrl.addListener(_rebuild);
|
_sieveHostCtrl.addListener(_rebuild);
|
||||||
_imapHostCtrl.addListener(_rebuild);
|
_imapHostCtrl.addListener(_rebuild);
|
||||||
|
_passwordCtrl.addListener(_rebuild);
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
context.pop();
|
context.pop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await repo.getPassword(account.id);
|
||||||
|
_hasStoredPassword = true;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
_account = account;
|
_account = account;
|
||||||
_displayNameCtrl.text = account.displayName;
|
_displayNameCtrl.text = account.displayName;
|
||||||
_usernameCtrl.text = account.username;
|
_usernameCtrl.text = account.username;
|
||||||
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_smtpHostCtrl.removeListener(_rebuild);
|
_smtpHostCtrl.removeListener(_rebuild);
|
||||||
_sieveHostCtrl.removeListener(_rebuild);
|
_sieveHostCtrl.removeListener(_rebuild);
|
||||||
_imapHostCtrl.removeListener(_rebuild);
|
_imapHostCtrl.removeListener(_rebuild);
|
||||||
|
_passwordCtrl.removeListener(_rebuild);
|
||||||
for (final c in [
|
for (final c in [
|
||||||
_displayNameCtrl,
|
_displayNameCtrl,
|
||||||
_usernameCtrl,
|
_usernameCtrl,
|
||||||
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
),
|
),
|
||||||
_field(
|
_field(
|
||||||
_passwordCtrl,
|
_passwordCtrl,
|
||||||
'New password (leave blank to keep)',
|
_hasStoredPassword
|
||||||
|
? 'New password (leave blank to keep)'
|
||||||
|
: 'Password',
|
||||||
key: const Key('editPasswordField'),
|
key: const Key('editPasswordField'),
|
||||||
obscure: true,
|
obscure: true,
|
||||||
required: false,
|
required: !_hasStoredPassword,
|
||||||
),
|
),
|
||||||
if (account.type == AccountType.jmap) ...[
|
if (account.type == AccountType.jmap) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
testing: _tryTesting,
|
testing: _tryTesting,
|
||||||
okMessage: _tryOk,
|
okMessage: _tryOk,
|
||||||
errorMessage: _tryErr,
|
errorMessage: _tryErr,
|
||||||
onPressed: _tryConnection,
|
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||||
|
? _tryConnection
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(
|
||||||
|
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||||
|
? _save
|
||||||
|
: null,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ os.environ["PATH"] = (
|
|||||||
REPO = "guettli/sharedinbox"
|
REPO = "guettli/sharedinbox"
|
||||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||||
|
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
|
||||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||||
|
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
|
||||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||||
"-" + str(Path.home())[1:].replace("/", "-")
|
"-" + str(Path.home())[1:].replace("/", "-")
|
||||||
)
|
)
|
||||||
@@ -263,6 +265,14 @@ def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_issue_labels(issue: int) -> list[str]:
|
||||||
|
"""Return label names for an issue."""
|
||||||
|
data = _tea_get(f"repos/{REPO}/issues/{issue}")
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
return [lbl["name"] for lbl in data.get("labels", [])]
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -301,6 +311,12 @@ def _clear_state() -> None:
|
|||||||
STATE_FILE.unlink(missing_ok=True)
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_heartbeat() -> None:
|
||||||
|
"""Record that the agent loop ran right now."""
|
||||||
|
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
|
||||||
|
HEARTBEAT_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def _find_session_uuid(session_name: str) -> str | None:
|
def _find_session_uuid(session_name: str) -> str | None:
|
||||||
"""Return the Claude session UUID for *session_name*, or None if not found.
|
"""Return the Claude session UUID for *session_name*, or None if not found.
|
||||||
|
|
||||||
@@ -470,12 +486,44 @@ def cmd_list() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── monitor subcommand ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_monitor() -> int:
|
||||||
|
"""Check that the agent loop has run within the last 2 hours.
|
||||||
|
|
||||||
|
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
|
||||||
|
Intended to be called from a scheduled CI job or cron every 2 hours.
|
||||||
|
"""
|
||||||
|
if not HEARTBEAT_FILE.exists():
|
||||||
|
print(
|
||||||
|
f"WARNING: Agent loop heartbeat file missing — "
|
||||||
|
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
|
||||||
|
except ValueError:
|
||||||
|
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
|
||||||
|
return 1
|
||||||
|
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
||||||
|
if age > MAX_HEARTBEAT_AGE_SECONDS:
|
||||||
|
print(
|
||||||
|
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
|
||||||
|
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _run_loop() -> int:
|
def _run_loop() -> int:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||||
|
_update_heartbeat()
|
||||||
|
|
||||||
state = _read_state()
|
state = _read_state()
|
||||||
|
|
||||||
@@ -684,6 +732,9 @@ def _run_loop() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if pr_run and pr_run.get("status") == "success":
|
if pr_run and pr_run.get("status") == "success":
|
||||||
|
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
|
||||||
|
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
|
||||||
|
continue
|
||||||
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
|
||||||
try:
|
try:
|
||||||
_merge_pr(pr_number)
|
_merge_pr(pr_number)
|
||||||
@@ -873,10 +924,13 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||||
sub = parser.add_subparsers(dest="cmd")
|
sub = parser.add_subparsers(dest="cmd")
|
||||||
sub.add_parser("list", help="List recent agent sessions")
|
sub.add_parser("list", help="List recent agent sessions")
|
||||||
|
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.cmd == "list":
|
if args.cmd == "list":
|
||||||
return cmd_list()
|
return cmd_list()
|
||||||
|
if args.cmd == "monitor":
|
||||||
|
return cmd_monitor()
|
||||||
return _run_loop()
|
return _run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -744,5 +745,110 @@ class TestRunLoopResumeCommand(unittest.TestCase):
|
|||||||
self.assertNotIn("Resume:", output)
|
self.assertNotIn("Resume:", output)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
||||||
|
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
|
||||||
|
|
||||||
|
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
|
||||||
|
return {"number": pr_number, "head": {"ref": branch}}
|
||||||
|
|
||||||
|
def test_skips_merge_when_issue_has_question_label(self):
|
||||||
|
pr = self._make_pr()
|
||||||
|
ci_run = {"id": 999, "status": "success"}
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
|
||||||
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
|
patch("agent_loop._comment_issue") as mock_comment, \
|
||||||
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
mock_comment.assert_not_called()
|
||||||
|
mock_labels.assert_not_called()
|
||||||
|
|
||||||
|
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
|
||||||
|
pr = self._make_pr()
|
||||||
|
ci_run = {"id": 999, "status": "success"}
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[pr]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
|
||||||
|
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
|
||||||
|
patch("agent_loop._merge_pr") as mock_merge, \
|
||||||
|
patch("agent_loop._find_pr_for_branch", return_value=None), \
|
||||||
|
patch("agent_loop._close_issue"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_merge.assert_called_once_with(50)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat(unittest.TestCase):
|
||||||
|
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
|
||||||
|
self._tmp.close()
|
||||||
|
self._orig = agent_loop.HEARTBEAT_FILE
|
||||||
|
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
|
||||||
|
Path(self._tmp.name).unlink() # Start with no heartbeat file.
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
agent_loop.HEARTBEAT_FILE = self._orig
|
||||||
|
Path(self._tmp.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_update_heartbeat_writes_timestamp(self):
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
content = Path(self._tmp.name).read_text().strip()
|
||||||
|
dt = datetime.fromisoformat(content)
|
||||||
|
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||||
|
self.assertLess(age, 5)
|
||||||
|
|
||||||
|
def test_update_heartbeat_creates_file(self):
|
||||||
|
self.assertFalse(Path(self._tmp.name).exists())
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
self.assertTrue(Path(self._tmp.name).exists())
|
||||||
|
|
||||||
|
def test_monitor_healthy_when_recent(self):
|
||||||
|
agent_loop._update_heartbeat()
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
|
||||||
|
def test_monitor_warns_when_heartbeat_missing(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_monitor_warns_when_stale(self):
|
||||||
|
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
|
||||||
|
Path(self._tmp.name).write_text(stale)
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_monitor_warns_when_corrupted(self):
|
||||||
|
Path(self._tmp.name).write_text("not-a-timestamp")
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
result = agent_loop.cmd_monitor()
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertIn("WARNING", buf.getvalue())
|
||||||
|
|
||||||
|
def test_run_loop_updates_heartbeat(self):
|
||||||
|
self.assertFalse(Path(self._tmp.name).exists())
|
||||||
|
with patch("agent_loop._read_state", return_value=None), \
|
||||||
|
patch("agent_loop._open_issue_prs", return_value=[]), \
|
||||||
|
patch("agent_loop._latest_main_ci_run", return_value=None), \
|
||||||
|
patch("agent_loop._ready_issues", return_value=[]):
|
||||||
|
agent_loop._run_loop()
|
||||||
|
self.assertTrue(Path(self._tmp.name).exists())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ void main() {
|
|||||||
gitHash: testHash,
|
gitHash: testHash,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Git hash link should be present
|
// Git hash link should be present
|
||||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||||
@@ -199,6 +200,109 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CrashScreen shows app version as clickable link when git hash is set',
|
||||||
|
(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: version link test';
|
||||||
|
final stackTrace = StackTrace.current;
|
||||||
|
const testHash = 'abc1234';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CrashScreen(
|
||||||
|
exception: exception,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
gitHash: testHash,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// App version link should be present (mocked as 1.0.0+42)
|
||||||
|
final versionLinkFinder = find.textContaining('App Version: 1.0.0+42');
|
||||||
|
expect(versionLinkFinder, findsOneWidget);
|
||||||
|
|
||||||
|
// It must appear above the git hash link
|
||||||
|
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(versionLinkFinder).dy,
|
||||||
|
lessThan(tester.getTopLeft(gitLinkFinder).dy),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tapping it should open the Codeberg commit URL
|
||||||
|
await tester.tap(versionLinkFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mock.launchedUrl,
|
||||||
|
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CrashScreen copy-to-clipboard includes app version as markdown link when git hash is set',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
String? clipboardText;
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
SystemChannels.platform,
|
||||||
|
(MethodCall call) async {
|
||||||
|
if (call.method == 'Clipboard.setData') {
|
||||||
|
clipboardText =
|
||||||
|
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addTearDown(
|
||||||
|
() => tester.binding.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exception = 'TestException: version link clipboard test';
|
||||||
|
final stackTrace = StackTrace.current;
|
||||||
|
const testHash = 'abc1234';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CrashScreen(
|
||||||
|
exception: exception,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
gitHash: testHash,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Copy to Clipboard'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(clipboardText, isNotNull);
|
||||||
|
// App Version must be a markdown link pointing to the commit
|
||||||
|
expect(
|
||||||
|
clipboardText,
|
||||||
|
contains(
|
||||||
|
'App Version: [1.0.0+42](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
clipboardText,
|
||||||
|
contains(
|
||||||
|
'Git Commit: [abc1234](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 {
|
||||||
|
|||||||
@@ -105,6 +105,88 @@ void main() {
|
|||||||
expect(find.text('Edit account'), findsNothing);
|
expect(find.text('Edit account'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'try connection button is disabled when no password stored or entered',
|
||||||
|
(
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
hasStoredPassword: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester.widget<OutlinedButton>(
|
||||||
|
find.byKey(const Key('editTryConnectionButton')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'try connection button is enabled after typing password with no stored password',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
hasStoredPassword: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('editPasswordField')),
|
||||||
|
'mypassword',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final button = tester.widget<OutlinedButton>(
|
||||||
|
find.byKey(const Key('editTryConnectionButton')),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('save button is disabled when no password stored or entered', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
hasStoredPassword: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester
|
||||||
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('connection error shows error message', (tester) async {
|
testWidgets('connection error shows error message', (tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class FakeAccountRepository implements AccountRepository {
|
class FakeAccountRepository implements AccountRepository {
|
||||||
final List<Account> _accounts;
|
|
||||||
|
|
||||||
FakeAccountRepository([List<Account>? accounts])
|
FakeAccountRepository([List<Account>? accounts])
|
||||||
: _accounts = List.of(accounts ?? []);
|
: _accounts = List.of(accounts ?? []);
|
||||||
|
|
||||||
|
final List<Account> _accounts;
|
||||||
|
bool hasPassword = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
|
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
|
||||||
|
|
||||||
@@ -75,7 +76,12 @@ class FakeAccountRepository implements AccountRepository {
|
|||||||
_accounts.removeWhere((a) => a.id == id);
|
_accounts.removeWhere((a) => a.id == id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> getPassword(String accountId) async => 'test-password';
|
Future<String> getPassword(String accountId) async {
|
||||||
|
if (!hasPassword) {
|
||||||
|
throw StateError('No password stored for account $accountId');
|
||||||
|
}
|
||||||
|
return 'test-password';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||||
@@ -514,10 +520,12 @@ List<Override> baseOverrides({
|
|||||||
DiscoveryResult? discovery,
|
DiscoveryResult? discovery,
|
||||||
Exception? connectionError,
|
Exception? connectionError,
|
||||||
ShareKeyRepository? shareKeyRepository,
|
ShareKeyRepository? shareKeyRepository,
|
||||||
|
bool hasStoredPassword = true,
|
||||||
}) =>
|
}) =>
|
||||||
[
|
[
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository(accounts)),
|
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||||
|
),
|
||||||
mailboxRepositoryProvider
|
mailboxRepositoryProvider
|
||||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
|||||||
Reference in New Issue
Block a user