Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: E2E (Appium)

# Black-box UI tests on an x86_64 emulator. Kept separate from test.yml because
# it needs KVM + an emulator (slow, ~10-15 min) and builds a different ABI.
# PR-only + manual to stay within free-tier minutes.
on:
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
name: Appium UiAutomator2 (x86_64 emulator)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the workflow file
fd -t f "e2e.yml" .github/workflows/

Repository: timbortnik/widget

Length of output: 87


🏁 Script executed:

# Check the content of the workflow file to see the uses entries
cat -n .github/workflows/e2e.yml | head -120

Repository: timbortnik/widget

Length of output: 4478


Pin GitHub Actions to full commit SHAs.

These uses: entries are tag-pinned (@v*) instead of commit-pinned. That weakens supply-chain guarantees and violates strict action-pinning policy.

Also applies to lines 29, 34, 49, 69, 79, 90, 104.

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 24-27: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 24-24: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/e2e.yml at line 24, Replace all tag-pinned GitHub Actions
references in the e2e.yml file with full commit SHAs to satisfy supply-chain
security requirements. For each `uses:` entry that currently uses `@v*` tags
(such as `actions/checkout@v6`), replace the tag with the corresponding full
commit SHA of that action version. This needs to be applied to all affected
actions/checkout references and any other action references using tag-based
pinning throughout the workflow file.

Source: Linters/SAST tools


# ---- Build stage: produce the x86_64 debug APK (Flutter) ----
# This is the only Flutter step; it just produces the artifact under test.
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'corretto'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.44.1'
cache: true
- name: Install Flutter dependencies
run: flutter pub get
- name: Generate version
run: ./scripts/generate_version.sh
- name: Build x86_64 debug APK
run: flutter build apk --debug --target-platform android-x64

# ---- Test stage: pure Node + Appium, consumes the prebuilt APK ----
# No Flutter on PATH from here on; the test only needs the APK by path.
- name: Set up Node
uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: e2e/package-lock.json
- name: Install E2E dependencies
working-directory: e2e
run: npm ci
- name: Install UiAutomator2 driver
working-directory: e2e
run: npm run driver:install # pinned uiautomator2@4.2.9 (last Appium-2.x driver)

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: AVD cache
uses: actions/cache@v5
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-31-x86_64-google_apis

- name: Create AVD snapshot (on cache miss)
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim -camera-back none
disable-animations: true
script: echo "AVD created"

- name: Run E2E specs
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -no-audio -no-boot-anim -camera-back none
disable-animations: true
# Single-line invocation of a script FILE — android-emulator-runner
# mangles multi-line inline `script:` blocks. run-ci.sh cd's into e2e/.
script: bash e2e/run-ci.sh

- name: Upload E2E artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-artifacts
path: |
e2e/appium.log
e2e/artifacts/
if-no-files-found: ignore
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ app.*.map.json
/android/app/release
android/build/
android/.kotlin/

# E2E (Appium + WebdriverIO) harness
e2e/node_modules/
e2e/spike-pagesource.xml
e2e/.appium/
e2e/artifacts/
2 changes: 1 addition & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
android:resource="@xml/meteogram_widget_info" />
</receiver>

<!-- Meteograph 14-day Home Screen Widget -->
<!-- Meteograph weekly (7-day) Home Screen Widget -->
<receiver
android:name=".MeteogramWeeklyWidgetProvider"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,16 @@ class MainActivity : FlutterActivity() {
// Run on background thread
Thread {
try {
val success = WeatherFetcher.fetchWeatherSync(this, latitude, longitude)
val fetchResult = WeatherFetcher.fetchWeatherSync(this, latitude, longitude)
runOnUiThread {
if (success) {
if (fetchResult.success) {
result.success(true)
} else {
result.error("FETCH_FAILED", "Failed to fetch weather data", null)
result.error(
"FETCH_FAILED",
fetchResult.error ?: "Failed to fetch weather data",
null,
)
}
}
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import java.util.Locale

/**
* Default (48-hour) meteogram widget provider. Also serves as the base class
* for variants (e.g. the 14-day weekly widget) — subclasses override the
* for variants (e.g. the 7-day weekly widget) — subclasses override the
* extension points to change layout, time range, or time labels without
* re-implementing the full RemoteViews update cycle.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ class SvgChartGenerator {

// Anchor labels inside the "past" region but keep them clear of the
// left edge. The plain nowFraction/2.5 formula is near zero when the
// past region is tiny (e.g. weekly mode with 6h past in 14 days),
// past region is tiny (e.g. weekly mode with 6h past in 7 days),
// which pushes the labels off the canvas — clamp to a sane minimum.
val centerX = (nowFraction / 2.5).coerceAtLeast(0.05) * width

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ class SvgChartPlatformView(
// Store SVG for rendering once view is laid out
creationParams?.let {
pendingSvg = it["svg"] as? String
// Accessibility label for the chart image. A Flutter Semantics
// wrapper cannot reach this hybrid-composition PlatformView, so the
// label is passed natively and set as content-desc (read by TalkBack
// and UiAutomator2).
(it["a11yLabel"] as? String)?.let { label ->
imageView.contentDescription = label
}
}

// Render when view is laid out and we know actual size
Expand Down
32 changes: 20 additions & 12 deletions android/app/src/main/kotlin/org/bortnik/meteogram/WeatherFetcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ object WeatherFetcher {
private val PAST_HOURS = WeatherConstants.WEEKLY_PAST_HOURS
private const val FORECAST_DAYS = 7

/**
* Outcome of a single fetch: [success] plus, on failure, a short [error]
* reason (e.g. "HTTP 429", "UnknownHostException: ...") for the UI's error
* screen. Returned per call — no shared mutable state, so concurrent fetches
* (in-app + background worker/alarm) can't clobber each other's reason.
*/
data class FetchResult(val success: Boolean, val error: String? = null)

/**
* Fetch weather data synchronously and save to SharedPreferences.
* Uses cached location from SharedPreferences.
Expand All @@ -42,7 +50,7 @@ object WeatherFetcher {
return
}

if (fetchWeatherSync(context, latitude, longitude)) {
if (fetchWeatherSync(context, latitude, longitude).success) {
// Trigger widget update
WidgetUtils.rerenderAllWidgetsNative(context)
}
Expand All @@ -52,22 +60,22 @@ object WeatherFetcher {
* Fetch weather data synchronously for given coordinates.
* Saves to SharedPreferences. Does NOT trigger widget update (caller's responsibility).
* Call from background thread only.
* @return true on success, false on failure
* @return a [FetchResult] — success flag plus, on failure, the reason.
*/
fun fetchWeatherSync(context: Context, latitude: Double, longitude: Double): Boolean {
fun fetchWeatherSync(context: Context, latitude: Double, longitude: Double): FetchResult {
Log.d(TAG, "Fetching weather for $latitude, $longitude")

val jsonResponse = fetchFromApi(latitude, longitude)
val (jsonResponse, fetchError) = fetchFromApi(latitude, longitude)
if (jsonResponse == null) {
Log.e(TAG, "Failed to fetch weather from API")
return false
return FetchResult(false, fetchError ?: "Failed to fetch weather from API")
}

// Transform API response to cached format (matching Dart's toJson())
val cachedJson = transformApiResponse(jsonResponse)
if (cachedJson == null) {
Log.e(TAG, "Failed to transform API response")
return false
return FetchResult(false, "Failed to parse weather data")
}

// Save to SharedPreferences
Expand All @@ -93,7 +101,7 @@ object WeatherFetcher {
.apply()

Log.d(TAG, "Weather data cached successfully, current temp: $currentTemp°C")
return true
return FetchResult(true)
}

/**
Expand Down Expand Up @@ -172,9 +180,9 @@ object WeatherFetcher {
/**
* Fetch weather data from Open-Meteo API.
* Certificate pinning not implemented - acceptable for public weather data with no auth.
* @return JSON response or null on failure
* @return (JSON response, null) on success, or (null, error reason) on failure.
*/
private fun fetchFromApi(latitude: Double, longitude: Double): JSONObject? {
private fun fetchFromApi(latitude: Double, longitude: Double): Pair<JSONObject?, String?> {
val url = URL(buildUrl(latitude, longitude))
var connection: HttpURLConnection? = null

Expand All @@ -187,7 +195,7 @@ object WeatherFetcher {
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
Log.e(TAG, "API returned $responseCode")
return null
return null to "HTTP $responseCode"
}

val reader = BufferedReader(InputStreamReader(connection.inputStream))
Expand All @@ -198,10 +206,10 @@ object WeatherFetcher {
}
reader.close()

JSONObject(response.toString())
JSONObject(response.toString()) to null
} catch (e: Exception) {
Log.e(TAG, "Network error", e)
null
null to (e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
} finally {
connection?.disconnect()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 14-day weekly meteogram widget. Structurally identical to meteogram_widget.xml;
<!-- 7-day weekly meteogram widget. Structurally identical to meteogram_widget.xml;
the provider draws a longer time range with weekday labels. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
Expand Down
48 changes: 48 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# E2E UI tests (Appium + UiAutomator2)

Black-box UI tests that drive the **installed APK** on an Android emulator/device.
The harness is pure JavaScript (WebdriverIO + Appium) with **no Flutter
dependency** — it locates elements through the Android accessibility tree
(`resource-id` / `content-desc`), which the app populates via Flutter `Semantics`
(see `lib/a11y_ids.dart`).

## Prerequisites

- Node 20+ and npm
- An Android emulator or device, **API ≥ 30**, **x86_64** (matches `make debug`)
- A debug APK for that ABI — from the repo root:
```bash
make debug # -> build/app/outputs/flutter-apk/app-debug.apk (needs JDK 17)
```

## Run locally

```bash
cd e2e
npm install
npm run driver:install # installs uiautomator2@4.2.9 into ./.appium

npm run appium # terminal 1: start the Appium server
npm test # terminal 2: emulator booted + apk built
```

Test a different build with `APP_PATH=/abs/path/to.apk npm test`.

## Specs

- `specs/home_happy_path.e2e.js` — launch + core navigation (location/theme sheets).
- `specs/accessibility.e2e.js` — black-box ADA: every control exposes a
`content-desc` and is ≥ 48dp. Inline attribution links are size-exempt
(WCAG 2.5.8 inline-text exception). Contrast is **not** covered black-box (deferred).

## Notes

- **Driver pin:** `uiautomator2@4.2.9` is the last driver compatible with Appium
2.x (5.x+ require Appium 3). Bump both together.
- **Charts** are hybrid-composition PlatformViews with no `resource-id`; they
expose a native `content-desc` (set in `SvgChartPlatformView.kt`) and are
located by accessibility-id.
- Flutter text surfaces as `content-desc`, not the `text` attribute — locate by
`resourceId` or `description*`, never `.text()`.
- CI: `.github/workflows/e2e.yml` (PR + manual) builds the x86_64 APK then runs
these specs on a KVM-accelerated emulator.
26 changes: 26 additions & 0 deletions e2e/a11y_ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Mirror of lib/a11y_ids.dart — these string values are the contract between
// the Flutter app (Semantics identifier -> Android resource-id) and these
// black-box tests. The test harness imports NO Flutter; keep this in sync by
// hand. Do not rename a value without updating lib/a11y_ids.dart.
module.exports = {
// Home screen
homeRetryButton: 'home_retry_button',
homeThemeButton: 'home_theme_button',
homeLocationSelector: 'home_location_selector',
homeOpenMeteoLink: 'home_open_meteo_link',
homeGithubLink: 'home_github_link',
// Charts are hybrid-composition PlatformViews with no resource-id; they carry
// a native content-desc instead (locate via accessibility-id if needed).

// Location picker sheet
locationSearchField: 'location_search_field',
locationClearSearch: 'location_clear_search',
locationGpsTile: 'location_gps_tile',
locationResultTilePrefix: 'location_result_tile',
locationRecentTilePrefix: 'location_recent_tile',

// Theme picker sheet
themeOptionSystem: 'theme_option_system',
themeOptionLight: 'theme_option_light',
themeOptionDark: 'theme_option_dark',
};
Loading
Loading