Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ada3798b6 | ||
|
|
07ac73dcb2 | ||
|
|
bb475a2350 | ||
|
|
63f7463ced | ||
|
|
0175c9e5a5 | ||
|
|
9f9bf14bbe | ||
|
|
a7783d46cf |
@@ -136,7 +136,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 100
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 100
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
||||
run: task renovate
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
+10
-2
@@ -224,7 +224,7 @@ tasks:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
cmds:
|
||||
- mkdir -p build/app/outputs/bundle/release
|
||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
@@ -247,7 +247,7 @@ tasks:
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
@@ -336,6 +336,14 @@ tasks:
|
||||
- |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||
|
||||
renovate:
|
||||
desc: Run Renovate bot against the repository via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||
|
||||
+69
-13
@@ -286,6 +286,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||
})
|
||||
}
|
||||
|
||||
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
||||
// Gradle dependencies survive across Dagger execution-cache misses.
|
||||
func (m *Ci) androidBase() *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
||||
func (m *Ci) firebaseBase() *dagger.Container {
|
||||
return m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
@@ -584,9 +599,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
||||
}
|
||||
|
||||
// BuildLinuxRelease builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
func (m *Ci) BuildLinuxRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.Directory {
|
||||
args := []string{"flutter", "build", "linux", "--release"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
WithExec(args).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
@@ -599,7 +622,7 @@ func (m *Ci) DeployLinux(
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
bundle := m.BuildLinuxRelease()
|
||||
bundle := m.BuildLinuxRelease(commitHash)
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
@@ -615,16 +638,27 @@ func (m *Ci) DeployLinux(
|
||||
|
||||
// setupKeystore decodes the base64 keystore into the android build container.
|
||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
return m.androidBase().
|
||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||
}
|
||||
|
||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||
func (m *Ci) BuildAndroidApk(
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||
WithExec(args).
|
||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||
}
|
||||
|
||||
@@ -640,7 +674,7 @@ func (m *Ci) DeployApk(
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
@@ -656,8 +690,7 @@ func (m *Ci) DeployApk(
|
||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||
built := m.firebaseBase().
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
@@ -716,9 +749,17 @@ func (m *Ci) TestAndroidFirebase(
|
||||
|
||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||
func (m *Ci) BuildAndroidRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.androidBase().
|
||||
WithExec(args).
|
||||
File("build/app/outputs/bundle/release/app-release.aab")
|
||||
}
|
||||
|
||||
@@ -790,14 +831,29 @@ func (m *Ci) PublishAndroid(
|
||||
playStoreConfig *dagger.Secret,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
versionCode := int(time.Now().Unix())
|
||||
aab := m.BuildAndroidRelease()
|
||||
aab := m.BuildAndroidRelease(commitHash)
|
||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||
}
|
||||
|
||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||
return dag.Container().
|
||||
From("renovate/renovate:39").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithExec([]string{"renovate"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||
// or save it as a .md file to get a rendered diagram.
|
||||
|
||||
@@ -360,7 +360,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
FilledButton(
|
||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||
? _save
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -278,6 +279,41 @@ def _merge_pr(pr_number: int) -> None:
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
|
||||
|
||||
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
|
||||
"""Handle a PR that is still open after a successful _merge_pr() call.
|
||||
|
||||
Returns one of:
|
||||
"rebase-spawned" — merge conflict detected; rebase agent started, state written
|
||||
"merged" — PR closed after a retry
|
||||
"fallback" — all options exhausted; caller should set State/Question
|
||||
"""
|
||||
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||
mergeable = (pr_data or {}).get("mergeable")
|
||||
|
||||
if mergeable is False:
|
||||
prompt = (
|
||||
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
|
||||
"Do not change any logic — only resolve conflicts and push."
|
||||
)
|
||||
session_name = f"rebase-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
|
||||
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
|
||||
return "rebase-spawned"
|
||||
|
||||
for attempt in range(1, 3):
|
||||
time.sleep(5)
|
||||
try:
|
||||
_merge_pr(pr_number)
|
||||
except RuntimeError as e:
|
||||
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
|
||||
if not _find_pr_for_branch(branch):
|
||||
print(f"PR #{pr_number} merged on retry {attempt}.")
|
||||
return "merged"
|
||||
|
||||
return "fallback"
|
||||
|
||||
|
||||
# ── state file ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -676,6 +712,13 @@ def _run_loop() -> int:
|
||||
)
|
||||
return 0
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
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(
|
||||
@@ -744,6 +787,16 @@ def _run_loop() -> int:
|
||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||
# (e.g. branch-protection rules not satisfied).
|
||||
if _find_pr_for_branch(branch):
|
||||
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
|
||||
if merge_result == "rebase-spawned":
|
||||
return 0
|
||||
if merge_result == "merged":
|
||||
if issue_num:
|
||||
_close_issue(issue_num)
|
||||
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
|
||||
else:
|
||||
print(f"Catch-up: merged PR #{pr_number} after retry.")
|
||||
return 0
|
||||
print(
|
||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||
"— skipping to avoid infinite retry."
|
||||
|
||||
@@ -785,6 +785,90 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
||||
mock_merge.assert_called_once_with(50)
|
||||
|
||||
|
||||
class TestMergeFailsOpen(unittest.TestCase):
|
||||
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||
|
||||
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||
return {
|
||||
"pid": 999999999,
|
||||
"issue": issue,
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"type": kind,
|
||||
}
|
||||
|
||||
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
|
||||
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
||||
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
||||
written_state = {}
|
||||
|
||||
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||
written_state["pid"] = pid
|
||||
written_state["issue"] = issue
|
||||
written_state["kind"] = kind
|
||||
written_state["session_name"] = session_name
|
||||
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
||||
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
||||
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_start.assert_called_once()
|
||||
prompt = mock_start.call_args[0][0]
|
||||
self.assertIn("Rebase branch", prompt)
|
||||
self.assertIn("issue-10-fix", prompt)
|
||||
self.assertEqual(written_state.get("kind"), "pending-ci")
|
||||
self.assertEqual(written_state.get("issue"), 10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
||||
"""mergeable=true, second attempt succeeds → issue closed."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once_with(10)
|
||||
|
||||
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
||||
"""All retries exhausted with PR still open → falls through to State/Question."""
|
||||
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||
patch("agent_loop._find_pr_for_branch",
|
||||
side_effect=[self._open_pr(), self._open_pr(),
|
||||
self._open_pr(), self._open_pr()]), \
|
||||
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||
patch("agent_loop._merge_pr"), \
|
||||
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||
patch("agent_loop.time.sleep"), \
|
||||
patch("agent_loop._set_labels") as mock_labels, \
|
||||
patch("agent_loop._comment_issue") as mock_comment, \
|
||||
patch("agent_loop._close_issue") as mock_close, \
|
||||
patch("agent_loop._clear_state"):
|
||||
result = agent_loop._run_loop()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_not_called()
|
||||
mock_labels.assert_called_once_with(
|
||||
10,
|
||||
add=[agent_loop.LABEL_QUESTION],
|
||||
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||
)
|
||||
mock_comment.assert_called_once()
|
||||
|
||||
|
||||
class TestHeartbeat(unittest.TestCase):
|
||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||
|
||||
|
||||
@@ -163,6 +163,30 @@ void main() {
|
||||
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 {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
Reference in New Issue
Block a user