Skip to content

Commit b8e4eea

Browse files
Merge pull request #92 from brewkits/ci/device-tests-workflow
ci: nightly device integration tests (Android emulator + iOS simulator)
2 parents e6e638f + db9b9d3 commit b8e4eea

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

‎.github/workflows/device-tests.yml‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Device tests
2+
3+
# The normal PR CI never boots a device, so bugs that only show on a real Android/iOS runtime
4+
# (see issues #30, #66, and lib_audit_5 broken by #84) stay invisible until someone runs
5+
# example/integration_test by hand. This runs them on GitHub-hosted emulator/simulator.
6+
# Not a merge gate (emulators are slower and occasionally flaky): nightly + on demand, and on
7+
# PRs that touch this workflow itself so it can be validated.
8+
on:
9+
schedule:
10+
- cron: '0 20 * * *' # 03:00 ICT
11+
workflow_dispatch:
12+
inputs:
13+
groups:
14+
description: 'Comma-separated group names (empty = all)'
15+
required: false
16+
default: ''
17+
pull_request:
18+
paths:
19+
- '.github/workflows/device-tests.yml'
20+
- 'scripts/run_device_groups.py'
21+
22+
concurrency:
23+
group: device-tests-${{ github.ref }}
24+
cancel-in-progress: true
25+
26+
jobs:
27+
android:
28+
name: Android emulator (API 34)
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 120
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- uses: subosito/flutter-action@v2
35+
with:
36+
flutter-version-file: .fvmrc
37+
channel: ''
38+
cache: true
39+
40+
- uses: actions/setup-java@v4
41+
with:
42+
distribution: temurin
43+
java-version: '17'
44+
45+
- name: Enable KVM
46+
run: |
47+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
48+
sudo udevadm control --reload-rules
49+
sudo udevadm trigger --name-match=kvm
50+
51+
- name: Install dependencies
52+
run: cd example && flutter pub get
53+
54+
- name: Run device groups
55+
uses: reactivecircus/android-emulator-runner@v2
56+
with:
57+
api-level: 34
58+
arch: x86_64
59+
target: google_apis
60+
profile: pixel_6
61+
disable-animations: true
62+
script: python3 scripts/run_device_groups.py emulator-5554 --only "${{ github.event.inputs.groups }}"
63+
64+
ios:
65+
name: iOS simulator
66+
runs-on: macos-latest
67+
timeout-minutes: 120
68+
steps:
69+
- uses: actions/checkout@v4
70+
71+
- uses: subosito/flutter-action@v2
72+
with:
73+
flutter-version-file: .fvmrc
74+
channel: ''
75+
cache: true
76+
77+
- name: Boot simulator
78+
id: sim
79+
run: |
80+
UDID=$(xcrun simctl list devices available -j | python3 -c "
81+
import json,sys
82+
d=json.load(sys.stdin)['devices']
83+
c=[x for rt,v in d.items() if 'iOS' in rt for x in v if 'iPhone' in x['name'] and x['isAvailable']]
84+
print(c[-1]['udid'])")
85+
xcrun simctl boot "$UDID"
86+
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
87+
88+
- name: Install dependencies
89+
run: cd example && flutter pub get
90+
91+
- name: Run device groups
92+
run: python3 scripts/run_device_groups.py "${{ steps.sim.outputs.udid }}" --only "${{ github.event.inputs.groups }}"

‎scripts/run_device_groups.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
"""Run example/integration_test/device_integration_test.dart group by group on one device.
3+
4+
The full file hangs when run in one go (see CONTRIBUTING / project notes), so each group is a
5+
separate `flutter test --plain-name` with its own timeout. A failed group is retried once; a
6+
group that passes on retry is reported as FLAKY (green but visible).
7+
8+
Usage: scripts/run_device_groups.py <flutter-device-id> [--only "Group A,Group B"] [--timeout 900]
9+
Exit code is non-zero if any group failed twice.
10+
"""
11+
import argparse, os, subprocess, sys, time
12+
13+
# Android-only/iOS-only groups simply report "no tests" on the other platform; that is not a failure.
14+
GROUPS = ['issue_84:', 'Trigger Types', 'ExistingPolicy', 'Constraints', 'All Workers',
15+
'TLS Certificate Pinning', 'Task Chains', 'Tags', 'Cancellation',
16+
'Events and Progress Streams', 'DartWorker Constraint and Delay Enforcement',
17+
'Custom Native Workers', 'New v1.1 Features', 'Tag utilities', 'Error resilience',
18+
'Task status lifecycle', 'Trigger type acceptance', 'Battery restriction diagnostics',
19+
'Issue #62', 'Issue #36', 'Concurrent tasks', 'Chain data flow', 'Issue #38/#39',
20+
'Issue #57', 'v1_5_0']
21+
22+
23+
def run(group, device, timeout, cwd):
24+
try:
25+
r = subprocess.run(
26+
['flutter', 'test', 'integration_test/device_integration_test.dart', '-d', device,
27+
'--plain-name', group], cwd=cwd, capture_output=True, text=True, timeout=timeout)
28+
except subprocess.TimeoutExpired:
29+
return 'timeout', f'timeout after {timeout}s'
30+
out = r.stdout + r.stderr
31+
if 'All tests passed' in out:
32+
return 'pass', ''
33+
if 'No tests ran' in out or ('+0' in out and 'Some tests failed' not in out and '[E]' not in out):
34+
return 'none', 'no tests for this platform'
35+
fails = [l.strip()[-160:] for l in out.splitlines() if '[E]' in l or 'Some tests failed' in l]
36+
return 'fail', ' | '.join(fails[-2:]) or 'unknown failure'
37+
38+
39+
def main():
40+
ap = argparse.ArgumentParser()
41+
ap.add_argument('device')
42+
ap.add_argument('--only', default='')
43+
ap.add_argument('--timeout', type=int, default=900)
44+
a = ap.parse_args()
45+
groups = [g for g in a.only.split(',') if g] or GROUPS
46+
cwd = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'example')
47+
rows, failed = [], False
48+
for g in groups:
49+
t = time.time()
50+
status, note = run(g, a.device, a.timeout, cwd)
51+
if status in ('fail', 'timeout'):
52+
status2, note2 = run(g, a.device, a.timeout, cwd)
53+
if status2 == 'pass':
54+
status, note = 'FLAKY', f'failed once ({note}), passed on retry'
55+
else:
56+
status, note, failed = 'FAIL', note2 or note, True
57+
rows.append((g, status, int(time.time() - t), note))
58+
print(f'{g}: {status} ({int(time.time() - t)}s) {note}', flush=True)
59+
summary = os.environ.get('GITHUB_STEP_SUMMARY')
60+
if summary:
61+
with open(summary, 'a') as f:
62+
f.write(f'### Device tests on `{a.device}`\n\n| group | result | s | note |\n|---|---|---|---|\n')
63+
for g, s, secs, n in rows:
64+
f.write(f'| {g} | {s} | {secs} | {n} |\n')
65+
sys.exit(1 if failed else 0)
66+
67+
68+
if __name__ == '__main__':
69+
main()

0 commit comments

Comments
 (0)