From e0c7adf342aa63e75de1d872f4c9a002a384e111 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 7 Oct 2026 11:42:10 +0300 Subject: [PATCH] ci: add emulator release gates, JUnit reports and PR artifact links --- .github/workflows/android-device.yml | 72 ++++++++++ .github/workflows/ci.yml | 133 +++++++++++++++++- .../workflows/pr-artifacts-description.yml | 107 ++++++++++++++ .github/workflows/release.yml | 20 +++ .github/workflows/test-reports.yml | 55 ++++++++ .gitignore | 1 + DevServer/README.md | 4 +- DevServer/tests/smoke.py | 6 + DevServer/tests/telegram-format.test.cjs | 94 +++++++------ README.md | 4 +- app/build.gradle.kts | 5 + .../life/andre/message487/DeviceTestRunner.kt | 15 ++ .../message487/StorageDeliveryDeviceTest.kt | 116 +++++++++++++++ docs/en/apk-installation.md | 5 + docs/en/release-automation.md | 6 +- docs/en/release-testing.md | 7 + docs/en/testing.md | 47 ++++++- docs/reviews/megaproxy-sync-2026-10-07.md | 42 ++++++ docs/ru/apk-installation.md | 6 + docs/ru/release-automation.md | 5 +- docs/ru/release-testing.md | 7 + docs/ru/testing.md | 48 ++++++- fastlane/Fastfile | 24 +++- fastlane/README.md | 8 ++ requirements-dev.txt | 1 + scripts/release_automation.py | 2 + scripts/tests/test_device_lane.py | 72 ++++++++++ scripts/tests/test_pr_artifacts.py | 86 +++++++++++ scripts/tests/test_release_automation.py | 9 +- 29 files changed, 938 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/android-device.yml create mode 100644 .github/workflows/pr-artifacts-description.yml create mode 100644 .github/workflows/test-reports.yml create mode 100644 app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt create mode 100644 app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt create mode 100644 docs/reviews/megaproxy-sync-2026-10-07.md create mode 100644 scripts/tests/test_device_lane.py create mode 100644 scripts/tests/test_pr_artifacts.py diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml new file mode 100644 index 0000000..cb4e9b7 --- /dev/null +++ b/.github/workflows/android-device.yml @@ -0,0 +1,72 @@ +name: Android device tests + +on: + workflow_call: + inputs: + api: + required: true + type: number + +permissions: + contents: read + +jobs: + device: + name: Device tests + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + ANDROID_SERIAL: emulator-5554 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - name: Enable hardware acceleration + run: | + test -e /dev/kvm + 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: Install build tools + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0"' + - name: Build test APKs + run: ./gradlew assembleDebug assembleDebugAndroidTest --no-daemon + - name: Run on disposable Android emulator + uses: reactivecircus/android-emulator-runner@v2.38.0 + with: + api-level: ${{ inputs.api }} + target: google_apis + arch: x86_64 + profile: pixel_5 + avd-name: message487-tests + disable-animations: true + emulator-options: -accel on -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + script: | + mkdir -p test-results/android-api${{ inputs.api }} + adb logcat -v threadtime > test-results/android-api${{ inputs.api }}/logcat.txt & + bundle exec fastlane android device_tests + - name: Upload Android evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: android-api${{ inputs.api }}-results + path: | + test-results/android-api${{ inputs.api }}/ + app/build/reports/androidTests/connected/ + retention-days: 14 + - name: Upload emulator JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Android API ${{ inputs.api }} + path: app/build/outputs/androidTest-results/connected/**/*.xml + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f3573c..da906df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,18 +89,34 @@ jobs: app/build/reports/lint-results-* if-no-files-found: warn retention-days: 14 + - name: Upload Android JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Android JVM + path: app/build/test-results/testDebugUnitTest/*.xml + retention-days: 14 - uses: actions/upload-artifact@v7 id: apk with: - name: message487-builds - path: | - app/build/outputs/apk/**/*.apk + name: message487-pr-${{ github.event.pull_request.number || github.run_id }}-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + retention-days: 14 + - uses: actions/upload-artifact@v7 + id: release_apk + with: + name: message487-pr-${{ github.event.pull_request.number || github.run_id }}-unsigned-release-apk + path: app/build/outputs/apk/release/app-release-unsigned.apk if-no-files-found: error retention-days: 14 - name: Link build artifacts env: - ARTIFACT_URL: ${{ steps.apk.outputs.artifact-url }} - run: echo "[Download debug APK and unsigned release APK]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" + DEBUG_URL: ${{ steps.apk.outputs.artifact-url }} + RELEASE_URL: ${{ steps.release_apk.outputs.artifact-url }} + run: | + echo "[Download debug APK]($DEBUG_URL)" >> "$GITHUB_STEP_SUMMARY" + echo "[Download unsigned release APK]($RELEASE_URL)" >> "$GITHUB_STEP_SUMMARY" python: if: github.event_name != 'workflow_dispatch' || inputs.release_dry_run_version == '' @@ -122,6 +138,13 @@ jobs: cache-dependency-path: requirements-dev.txt - run: python -m pip install -r requirements-dev.txt - run: bundle exec fastlane android python_checks + - name: Upload Python JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Python + path: test-results/python/**/*.xml + retention-days: 14 dev-server: name: Development server tests @@ -137,10 +160,110 @@ jobs: with: ruby-version: "3.4.10" bundler-cache: true + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python -m pip install -r requirements-dev.txt - run: bundle exec fastlane android server_tests + - name: Upload n8n JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-n8n + path: test-results/server/*.xml + retention-days: 14 - name: Server logs if: failure() run: docker compose -f DevServer/compose.yaml logs --tail 150 - name: Stop server if: always() run: docker compose -f DevServer/compose.yaml down + + emulator26: + name: Android emulator API 26 + if: github.event_name != 'workflow_dispatch' || inputs.release_dry_run_version == '' + uses: ./.github/workflows/android-device.yml + with: + api: 26 + + emulator35: + name: Android emulator API 35 + if: github.event_name != 'workflow_dispatch' || inputs.release_dry_run_version == '' + uses: ./.github/workflows/android-device.yml + with: + api: 35 + + report-android: + name: Publish Android JVM results + needs: android + if: >- + !cancelled() && needs.android.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android JVM)$/' + + report-python: + name: Publish Python results + needs: python + if: >- + !cancelled() && needs.python.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Python)$/' + + report-dev-server: + name: Publish n8n results + needs: dev-server + if: >- + !cancelled() && needs.dev-server.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(n8n)$/' + + report-emulator26: + name: Publish Android API 26 results + needs: emulator26 + if: >- + !cancelled() && needs.emulator26.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android API 26)$/' + + report-emulator35: + name: Publish Android API 35 results + needs: emulator35 + if: >- + !cancelled() && needs.emulator35.result != 'skipped' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Android API 35)$/' diff --git a/.github/workflows/pr-artifacts-description.yml b/.github/workflows/pr-artifacts-description.yml new file mode 100644 index 0000000..59c3ce9 --- /dev/null +++ b/.github/workflows/pr-artifacts-description.yml @@ -0,0 +1,107 @@ +name: PR artifact links + +on: + workflow_run: + workflows: + - CI + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + description: + name: Add APK links to the pull request + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: pr-apk-links-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: false + steps: + - name: Create or update the APK artifact description block + uses: actions/github-script@v9 + with: + script: | + const run = context.payload.workflow_run; + const pullRequests = run.pull_requests || []; + + if (pullRequests.length !== 1) { + core.notice(`Expected one pull request for workflow run ${run.id}, found ${pullRequests.length}`); + return; + } + + const pullNumber = pullRequests[0].number; + const expectedArtifacts = { + debug: `message487-pr-${pullNumber}-debug-apk`, + release: `message487-pr-${pullNumber}-unsigned-release-apk`, + }; + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id, + per_page: 100, + } + ); + + const findArtifact = (name) => artifacts.find( + (artifact) => artifact.name === name && !artifact.expired + ); + const debugArtifact = findArtifact(expectedArtifacts.debug); + const releaseArtifact = findArtifact(expectedArtifacts.release); + + if (!debugArtifact || !releaseArtifact) { + core.setFailed(`Expected APK artifacts were not found for workflow run ${run.id}`); + return; + } + + const artifactUrl = (artifact) => + `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` + + `/actions/runs/${run.id}/artifacts/${artifact.id}`; + const marker = ''; + const body = [ + marker, + ``, + '## APK artifacts', + '', + `- [Download debug APK](${artifactUrl(debugArtifact)}) — signed with the disposable Android debug key`, + `- [Download unsigned release APK](${artifactUrl(releaseArtifact)}) — verified unsigned`, + '', + `Built from commit \`${run.head_sha.slice(0, 8)}\` by [CI run #${run.run_number}](${run.html_url}).`, + 'Artifacts expire after 14 days. Neither APK uses the Message487 release key.', + '', + ].join('\n'); + + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, pull_number: pullNumber, + }); + if (pull.state !== 'open' || pull.head.sha !== run.head_sha) { + core.notice('PR is closed or its head changed; keeping the existing description'); + return; + } + const description = pull.body || ''; + const previousRun = description.match(//); + if (previousRun && ( + Number(previousRun[1]) > run.run_number || + (Number(previousRun[1]) === run.run_number && Number(previousRun[2]) > (run.run_attempt || 1)) + )) { + core.notice('A newer CI run already published its artifacts'); + return; + } + const block = /[\s\S]*?/g; + const authorBody = description.replace(block, '').trimEnd(); + if (authorBody.includes(marker) || authorBody.includes('')) { + core.setFailed('Malformed APK block markers; refusing to overwrite the PR description'); + return; + } + await github.rest.pulls.update({ + ...context.repo, pull_number: pullNumber, + body: authorBody ? `${authorBody}\n\n${body}` : body, + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be53921..e73cf59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,26 @@ jobs: app/build/reports/lint-results-* retention-days: 14 + - name: Upload release JUnit reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: junit-Release Android JVM + path: app/build/test-results/testDebugUnitTest/*.xml + retention-days: 14 + + report-tests: + name: Publish release Android JVM results + needs: release + if: ${{ !cancelled() && needs.release.result != 'skipped' }} + permissions: + contents: read + actions: read + checks: write + uses: ./.github/workflows/test-reports.yml + with: + artifact: '/^junit-(Release Android JVM)$/' + publish: name: Publish GitHub Release needs: release diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml new file mode 100644 index 0000000..d931f96 --- /dev/null +++ b/.github/workflows/test-reports.yml @@ -0,0 +1,55 @@ +name: JUnit reports + +on: + workflow_call: + inputs: + artifact: + type: string + required: false + default: "/^junit-(.+)$/" + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + actions: read + checks: write + +jobs: + reports: + name: Test results + # Internal PRs and pushes call this workflow from CI. Fork PRs need a trusted + # workflow_run context because their CI token cannot write GitHub Checks. + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name != github.repository) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Parse uploaded data only; never check out or run code from the tested PR. + - name: Publish JUnit results in GitHub Checks + uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 + with: + artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} + name: 'Test results · $1' + path: '**/*.xml' + reporter: java-junit + list-tests: all + max-annotations: 10 + fail-on-empty: false + use-actions-summary: false + - name: Add collapsible test groups to Actions Summary + if: ${{ !cancelled() }} + uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 + with: + artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} + name: 'Test results · $1' + path: '**/*.xml' + reporter: java-junit + list-tests: all + max-annotations: 10 + fail-on-empty: false + use-actions-summary: true + collapsed: always diff --git a/.gitignore b/.gitignore index 2194360..2012478 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ ReviewServer/credentials.json __pycache__/ dist/ .playwright-cli/ +test-results/ diff --git a/DevServer/README.md b/DevServer/README.md index ee5b775..9bd18a0 100644 --- a/DevServer/README.md +++ b/DevServer/README.md @@ -4,7 +4,9 @@ Run from the repository root with Docker Compose installed and a Docker engine r ```sh docker compose -f DevServer/compose.yaml up -d --wait -bundle exec fastlane android server_tests +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +PYTHON=.venv/bin/python bundle exec fastlane android server_tests ``` Open the editor at . Local development login: diff --git a/DevServer/tests/smoke.py b/DevServer/tests/smoke.py index d7943ff..7171501 100644 --- a/DevServer/tests/smoke.py +++ b/DevServer/tests/smoke.py @@ -1,5 +1,6 @@ import json import socket +import unittest import urllib.error import urllib.request import uuid @@ -79,5 +80,10 @@ def main(): ) +class ServerSmokeTest(unittest.TestCase): + def test_authenticated_delivery_and_failures(self): + main() + + if __name__ == '__main__': main() diff --git a/DevServer/tests/telegram-format.test.cjs b/DevServer/tests/telegram-format.test.cjs index 49a8d37..c23bd63 100644 --- a/DevServer/tests/telegram-format.test.cjs +++ b/DevServer/tests/telegram-format.test.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { join } = require('node:path'); -const { test } = require('node:test'); +const { describe, test } = require('node:test'); const { runInNewContext } = require('node:vm'); const script = readFileSync(join(__dirname, '../telegram-format.js'), 'utf8'); @@ -20,53 +20,55 @@ const format = changes => runInNewContext(`(function () { ${script}\n})()`, { const plain = html => html.replace(/<\/?b>/g, '') .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); -test('SMS uses the compact bold header and event time in Moscow', () => { - assert.equal(format({})[0], - 'Message487: +79991234567\n' + - 'SMS\npersonal-phone\n09.09.2026 12:30:00\nYour message'); -}); +describe('Telegram formatting', () => { + test('SMS uses the compact bold header and event time in Moscow', () => { + assert.equal(format({})[0], + 'Message487: +79991234567\n' + + 'SMS\npersonal-phone\n09.09.2026 12:30:00\nYour message'); + }); -test('Notification preserves title and escapes every input field', () => { - const result = format({ - message_type: 'notification', sender: undefined, - source_name: '', device_code: '', - title: 'Title', text: 'A & B < C', - })[0]; - assert.ok(result.startsWith('Message487: <App & Co>\n')); - assert.ok(result.includes('Уведомление\n<phone>')); - assert.ok(result.endsWith('<b>Title</b>\nA & B < C')); -}); + test('Notification preserves title and escapes every input field', () => { + const result = format({ + message_type: 'notification', sender: undefined, + source_name: '', device_code: '', + title: 'Title', text: 'A & B < C', + })[0]; + assert.ok(result.startsWith('Message487: <App & Co>\n')); + assert.ok(result.includes('Уведомление\n<phone>')); + assert.ok(result.endsWith('<b>Title</b>\nA & B < C')); + }); -test('Missing display fields fall back without inventing an event timestamp', () => { - const result = format({ - message_type: 'test', sender: undefined, source_name: undefined, - source: 'life.andre.message487', device_code: undefined, occurred_at: null, - })[0]; - assert.equal(result, 'Message487: life.andre.message487\n' + - 'Тест\ninstallation-id\n—\nYour message'); - assert.ok(format({ occurred_at: 'invalid' })[0].includes('personal-phone\n—')); -}); + test('Missing display fields fall back without inventing an event timestamp', () => { + const result = format({ + message_type: 'test', sender: undefined, source_name: undefined, + source: 'life.andre.message487', device_code: undefined, occurred_at: null, + })[0]; + assert.equal(result, 'Message487: life.andre.message487\n' + + 'Тест\ninstallation-id\n—\nYour message'); + assert.ok(format({ occurred_at: 'invalid' })[0].includes('personal-phone\n—')); + }); -test('Long headers and body preserve text, Unicode and balanced HTML in each part', () => { - const sender = '😀<&>'.repeat(1500); - const text = 'Hello 📨 & \n'.repeat(1000); - const parts = format({ sender, text }); - assert.ok(parts.length > 2); - const restored = parts.map((part, index) => { - assert.ok(part.startsWith(`[${index + 1}/${parts.length}]\n`)); - assert.ok(plain(part).length <= 4096); - assert.equal((part.match(//g) || []).length, (part.match(/<\/b>/g) || []).length); - assert.ok(plain(part).isWellFormed()); - return plain(part.replace(/^\[\d+\/\d+\]\n/, '')); - }).join(''); - assert.equal(restored, `Message487: ${sender}\nSMS\npersonal-phone\n09.09.2026 12:30:00\n${text}`); -}); + test('Long headers and body preserve text, Unicode and balanced HTML in each part', () => { + const sender = '😀<&>'.repeat(1500); + const text = 'Hello 📨 & \n'.repeat(1000); + const parts = format({ sender, text }); + assert.ok(parts.length > 2); + const restored = parts.map((part, index) => { + assert.ok(part.startsWith(`[${index + 1}/${parts.length}]\n`)); + assert.ok(plain(part).length <= 4096); + assert.equal((part.match(//g) || []).length, (part.match(/<\/b>/g) || []).length); + assert.ok(plain(part).isWellFormed()); + return plain(part.replace(/^\[\d+\/\d+\]\n/, '')); + }).join(''); + assert.equal(restored, `Message487: ${sender}\nSMS\npersonal-phone\n09.09.2026 12:30:00\n${text}`); + }); -test('Invalid events fail before producing Telegram messages', () => { - for (const changes of [ - { schema_version: 2 }, { event_id: '' }, { message_type: 'unknown' }, - { message_type: 'toString' }, { text: null }, - ]) { - assert.throws(() => format(changes), /Invalid Message487 event/); - } + test('Invalid events fail before producing Telegram messages', () => { + for (const changes of [ + { schema_version: 2 }, { event_id: '' }, { message_type: 'unknown' }, + { message_type: 'toString' }, { text: null }, + ]) { + assert.throws(() => format(changes), /Invalid Message487 event/); + } + }); }); diff --git a/README.md b/README.md index 460dfaa..ade790a 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ for the visual conventions and references. Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM/Robolectric tests, debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`. -PR CI has no release signing credentials and does not require an emulator. +PR CI has no release signing credentials. It runs JVM/lint checks and instrumentation tests +on disposable API 26 and API 35 emulators. JUnit results appear in GitHub Checks; debug and +unsigned release APK links are kept in the PR description. See [testing](docs/en/testing.md). For signed APK releases, see [Releases](docs/en/releases.md) and [release automation](docs/en/release-automation.md), and [release testing](docs/en/release-testing.md). Store graphics and their provenance are documented in [Branding](assets/branding/README.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fed337..b72e371 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ android { applicationId = "life.andre.message487" minSdk = 26 targetSdk = 36 + testInstrumentationRunner = "life.andre.message487.DeviceTestRunner" versionCode = 6 versionName = "1.0.0" buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"") @@ -99,6 +100,10 @@ dependencies { testImplementation("org.robolectric:robolectric:4.16") testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:core:1.6.1") + androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") diff --git a/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt b/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt new file mode 100644 index 0000000..7ad7e20 --- /dev/null +++ b/app/src/androidTest/java/life/andre/message487/DeviceTestRunner.kt @@ -0,0 +1,15 @@ +package life.andre.message487 + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner + +// Keep production workers and crash handlers out of isolated storage/transport tests. +class DeviceTestApplication : MessageApplication() { + override fun onCreate() {} +} + +class DeviceTestRunner : AndroidJUnitRunner() { + override fun newApplication(loader: ClassLoader, name: String, context: Context): Application = + super.newApplication(loader, DeviceTestApplication::class.java.name, context) +} diff --git a/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt new file mode 100644 index 0000000..5196584 --- /dev/null +++ b/app/src/androidTest/java/life/andre/message487/StorageDeliveryDeviceTest.kt @@ -0,0 +1,116 @@ +package life.andre.message487 + +import android.content.Context +import androidx.core.content.FileProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class StorageDeliveryDeviceTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val source = AppSource("synthetic.source", "Synthetic source") + private val token = "synthetic-device-token" + + @Before fun clearStorage() { + context.deleteDatabase("outbox.db") + context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit() + } + + @After fun cleanStorage() = clearStorage() + + @Test fun keystoreEncryptsSettingsAndRejectsTampering() { + val cipher = KeystorePayloadCipher() + val encrypted = cipher.encrypt(token) + assertEquals(token, KeystorePayloadCipher().decrypt(encrypted)) + assertFalse(encrypted.contentEquals(cipher.encrypt(token))) + encrypted[encrypted.lastIndex] = (encrypted.last().toInt() xor 1).toByte() + assertThrows(Exception::class.java) { cipher.decrypt(encrypted) } + SettingsStore(context).update { it.copy(authToken = token, url = "https://example.test/hook") } + val reopened = SettingsStore(context).state.value + assertEquals(token, reopened.authToken) + assertTrue(reopened.ready()) + assertFalse(context.getSharedPreferences("connection", Context.MODE_PRIVATE).all.toString().contains(token)) + } + + @Test fun encryptedQueueSurvivesReopenAndRetriesWithOriginalIdentity() { + val event = MessageEvent("device", "emulator", source, messageType = "sms", text = "Synthetic SMS Привет") + val settings = ForwardingSettings(url = "https://example.test/hook", authToken = token) + // SQLiteOpenHelper implements AutoCloseable only from API 29. + val outbox = Outbox(context, KeystorePayloadCipher()) + try { + assertTrue(outbox.enqueue(event, settings)) + outbox.readableDatabase.rawQuery("SELECT payload FROM events", null).use { rows -> + assertTrue(rows.moveToFirst()) + val raw = String(rows.getBlob(0), Charsets.ISO_8859_1) + assertFalse(raw.contains(token)) + assertFalse(raw.contains("Synthetic SMS")) + } + val attempt = outbox.beginAttempt(event.eventId)!! + outbox.finish(event.eventId, attempt.token, DeliveryResult(event.eventId, DeliveryStatus.TIMEOUT)) + } finally { + outbox.close() + } + val reopened = Outbox(context, KeystorePayloadCipher()) + try { + assertFalse(reopened.enqueue(event.copy(eventId = "duplicate"), settings)) + val retry = reopened.beginAttempt(event.eventId)!! + assertEquals(token, retry.request.authToken) + assertEquals(settings.url, retry.request.url) + assertEquals(event.text, JSONObject(retry.request.json).getString("text")) + assertEquals(2, reopened.entries().single().attempts) + reopened.finish(event.eventId, retry.token, DeliveryResult(event.eventId, DeliveryStatus.ACCEPTED, 200)) + assertEquals(0, reopened.pendingCount()) + reopened.readableDatabase.rawQuery("SELECT payload FROM events", null).use { rows -> + assertTrue(rows.moveToFirst()) + assertTrue(rows.isNull(0)) + } + } finally { + reopened.close() + } + } + + @Test fun androidHttpTransportRequiresMatchingAckAndDoesNotFollowRedirects() { + MockWebServer().use { server -> + server.start() + val event = MessageEvent("device", "emulator", source) + val client = WebhookClient() + val url = server.url("/receive").toString() + server.enqueue(MockResponse().setBody("""{"status":"accepted","event_id":"${event.eventId}"}""")) + assertEquals(DeliveryStatus.ACCEPTED, client.send(url, event, true, token).status) + val request = server.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("Bearer $token", request.getHeader("Authorization")) + assertEquals(event.eventId, JSONObject(request.body.readUtf8()).getString("event_id")) + server.enqueue(MockResponse().setBody("""{"status":"accepted","event_id":"wrong"}""")) + assertEquals(DeliveryStatus.INVALID_ACK, client.send(url, event, true, token).status) + server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", url)) + assertEquals(DeliveryStatus.HTTP_ERROR, client.send(url, event, true, token).status) + assertEquals(3, server.requestCount) + } + } + + @Test fun fileProviderSharesApkButRejectsPrivateSettings() { + val apk = File(context.cacheDir, "updates/device-test.apk") + apk.parentFile!!.mkdirs() + try { + apk.writeText("Synthetic APK fixture") + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + assertEquals("content", uri.scheme) + assertEquals("Synthetic APK fixture", context.contentResolver.openInputStream(uri)!!.bufferedReader().use { it.readText() }) + assertThrows(IllegalArgumentException::class.java) { + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", File(context.filesDir, "connection.xml")) + } + } finally { + apk.delete() + } + } +} diff --git a/docs/en/apk-installation.md b/docs/en/apk-installation.md index e159a4d..92a4711 100644 --- a/docs/en/apk-installation.md +++ b/docs/en/apk-installation.md @@ -47,6 +47,11 @@ before the next APK update, including one downloaded inside Message487. Menu nam on the model and One UI version. [Samsung instructions](https://www.samsung.com/us/support/answer/ANS10003636/). +Auto Blocker can also block USB commands, including ADB installation. If USB debugging +or `adb install` is blocked, check this setting too. Restore it after the installation +attempt, including a failed attempt. Work-device policies may prevent changing it; contact +the administrator. Auto Blocker is separate from Play Protect and restricted settings. + ## Identify the blocking screen | What you see | Next step | diff --git a/docs/en/release-automation.md b/docs/en/release-automation.md index 97b2992..8f215fa 100644 --- a/docs/en/release-automation.md +++ b/docs/en/release-automation.md @@ -34,7 +34,8 @@ The tag build keeps the same signing key so existing installations can upgrade. Enable squash merging in **Settings → General → Pull Requests**. Protect `main` with required **Release CI identity**, **Android tests and checks**, **Python tests and style** and -**Development server tests**; do not grant the bot a bypass. Require human review if you want to +**Development server tests**, **Android emulator API 26 / Device tests** and +**Android emulator API 35 / Device tests**; do not grant the bot a bypass. Require human review if you want to review notes before merge. The workflow cannot approve itself. After a rejected merge, approve and rerun the failed finalize job. Required merge queues are unsupported. Tag rules must allow the bot to create `v*` tags. @@ -81,7 +82,8 @@ Historical notes remain unchanged. GitHub Release uses the same texts. You choos prereleases are unsupported. Finalize waits up to 60 minutes for successful full CI for that PR, head SHA and comparison base. -Skipped/neutral jobs are not success. Changed PRs or an advanced `main` stop merge. The merged +Both API 26/35 emulator jobs must succeed, including on version-only release PRs. +Skipped/neutral/missing emulator jobs are not success. Changed PRs or an advanced `main` stop merge. The merged tree must equal the checked head tree before a tag is created on the actual merged commit. Finalize runs trusted workflow code, never code from the release PR. Retries never force-push, move tags or overwrite published Releases. diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index e36784f..f30e08d 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -226,3 +226,10 @@ Raw material stays local. Test the signed release-PR candidate before publicatio human review in [release automation](release-automation.md) to inspect notes before merge. R03 is additional post-publication verification, not permission to release untested APKs. No device tests were performed while writing this procedure. + +## CI evidence for a candidate + +Record the exact PR head/base and CI URL, individual API 26/35 verdicts, JUnit Checks, +APK artifact link and SHA-256. Download links are updated in the PR description; unsigned +PR APKs and debug-emulator tests do not establish acceptance of the signed release candidate. +A new commit requires a new CI run; skipped or missing emulator results block finalization. diff --git a/docs/en/testing.md b/docs/en/testing.md index 2725f54..1d57853 100644 --- a/docs/en/testing.md +++ b/docs/en/testing.md @@ -7,6 +7,7 @@ Run the same suites locally and in GitHub Actions: | Suite | Command | Coverage | | --- | --- | --- | | Android | `bundle exec fastlane android checks` | JVM logic, MockWebServer HTTP integration, Robolectric database/preferences/provider integration, Compose interactions, localization, manifest security, light/dark contrast, debug/release lint and APK builds, unsigned release verification | +| Emulator | `ANDROID_HOME=/path/to/sdk ANDROID_SERIAL=emulator-5554 bundle exec fastlane android device_tests` | Real Android Keystore, encrypted preferences/SQLite queue and retry identity, Android HTTP/ACK/redirect behavior, FileProvider access boundaries on API 26/35 | | Python | `PYTHON=.venv/bin/python bundle exec fastlane android python_checks` | DevServer HTTP test-client behavior against a local HTTP server, release automation with mocked APIs/local Git, pinned Black/isort style checks | | n8n | `bundle exec fastlane android server_tests` | Live receive/validation, notification/SMS/test payloads, HTTP failure, invalid ACK and timeout | @@ -14,10 +15,13 @@ For Python, create `.venv` with `python3 -m venv .venv` and install `requirement For n8n, first run `docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300`. The server suite uses synthetic data and retains it in the development execution history. -`.github/workflows/ci.yml` runs the three main jobs on pull requests, main pushes and manual dispatch. +`.github/workflows/ci.yml` runs Android, Python, n8n and separate API 26/35 emulator jobs on pull requests, main pushes and manual dispatch. Manual dispatch with `release_dry_run_version` runs only the release dry run and skips these suites. Android XML/HTML test and lint reports are uploaded even if a check fails. Successful Android jobs -also publish a debug APK and unsigned release APK files. These checks never sign release artifacts. +also publish separate debug APK and unsigned release APK artifacts, expiring after 14 days. +The trusted `pr-artifacts-description.yml` workflow keeps their links in a marked PR-description +block, preserving author text and rejecting old heads/runs. It never executes PR code or posts +comments. It starts working after merge into the default branch. These checks never sign release artifacts. Local success does not establish a GitHub run result for uncommitted/unpushed changes. ## Comparison with MegaProxy @@ -35,10 +39,45 @@ factory binds each test to its own Application; background completions are drain connection validation and persistence, bulk application selection and diagnostic deletion. They do not prove notification/SMS permission delivery, WorkManager/OS scheduling, real Android Keystore, process death handling or email-client behavior. Those remain device/emulator checks; see -[diagnostics](diagnostics.md) for the crash/report scenario. Required hosted CI does not depend on -an Android emulator, following MegaProxy's approach to unreliable KVM availability. +[diagnostics](diagnostics.md) for the crash/report scenario. The separate emulator checks exercise real Keystore, storage, HTTP and provider APIs; +Robolectric still covers most screen interactions. Emulator CI requires KVM; startup or test +failure keeps the check red, with no automatic assertion retries. Signed release verification runs the Android test/lint suite separately with signing inputs. PR checks reject those inputs and remain unsigned. See [release automation](releases.md). +## Emulator runs and JUnit reports + +Use a **disposable** Google APIs AVD named `message487-tests` or `message487-tests-26` / +`message487-tests-35`. Tests clear Message487 settings and the queue on that emulator. +Connect only that emulator and set `ANDROID_SERIAL` and `ANDROID_HOME`; the lane rejects +physical devices, other AVD names and additional connected devices. CI creates a fresh AVD +for each API and builds only debug/test APKs, without release signing secrets. + +To create a local AVD, choose `arm64-v8a` on Apple Silicon or `x86_64` on Intel/Linux: + +```sh +sdkmanager "system-images;android-35;google_apis;arm64-v8a" +avdmanager create avd --name message487-tests-35 --package "system-images;android-35;google_apis;arm64-v8a" --device pixel_5 +emulator -avd message487-tests-35 -no-snapshot +# In another terminal, using the actual serial from adb devices: +ANDROID_SERIAL=emulator-5554 bundle exec fastlane android device_tests +``` + +Repeat with API 26. Reopening stores is tested; process death, real SMS/notification capture, +WorkManager scheduling, OEM restrictions and upgrade of a signed APK remain device acceptance +scenarios. These debug-emulator results do not certify a release candidate. + +Android uses Gradle JUnit XML. Python and the live n8n smoke test use `unittest-xml-reporting`; +n8n Node tests use Node's native JUnit reporter. Install `requirements-dev.txt` for both Python +and server lanes. Python XML is in `test-results/python/`, n8n XML in `test-results/server/`, +and device XML in `app/build/outputs/androidTest-results/connected/`. CI uploads reports even +after test failures; emulator evidence includes HTML and Logcat (14-day retention). + +`test-reports.yml` publishes each suite as GitHub Checks and a collapsible Actions summary +as soon as its job finishes. Internal CI/release jobs call it directly; fork PR reports use +a trusted `workflow_run` after merge into the default branch. The reporter parses artifacts +without checking out or executing PR code. A test/build failure without XML remains a failed +CI job, never a passing test report. Signed-release JVM results are published separately. + For signed candidates, F-Droid and device checks, follow [Release testing](release-testing.md). diff --git a/docs/reviews/megaproxy-sync-2026-10-07.md b/docs/reviews/megaproxy-sync-2026-10-07.md new file mode 100644 index 0000000..0fd14f1 --- /dev/null +++ b/docs/reviews/megaproxy-sync-2026-10-07.md @@ -0,0 +1,42 @@ +# Перенос актуальных изменений AndroidMegaProxy — 2026-10-07 + +Сравнен `andre487/AndroidMegaProxy` до коммита +[`d94f861`](https://github.com/andre487/AndroidMegaProxy/commit/d94f861), включая последние +40 коммитов, с текущим Message487. Изменения адаптированы под Kotlin/Android и n8n; +Go/JNI/VPN-стенд не нужен этому приложению. + +| Изменение источника | Решение для Message487 | +| --- | --- | +| [Эмуляторы API 26/35, #64](https://github.com/andre487/AndroidMegaProxy/pull/64) | Два обязательных CI job через общий workflow. Штатный Gradle instrumentation runner; настоящий Keystore, зашифрованные настройки/SQLite, повторы доставки, HTTP/ACK/redirect и FileProvider. Чистые AVD, KVM, JUnit/HTML/Logcat. Локальный lane отвергает физические устройства и обычные AVD. | +| [JUnit Checks, #65](https://github.com/andre487/AndroidMegaProxy/pull/65) | Отдельные отчёты Android JVM, API 26, API 35, Python, n8n и JVM подписанной сборки; Checks и сворачиваемые summaries. Fork PR используют доверенный workflow без исполнения кода PR. Python использует xmlrunner, Node — встроенный reporter с именованным suite. | +| [Артефакты в описании PR, #62](https://github.com/andre487/AndroidMegaProxy/pull/62) | Отдельные debug/unsigned APK. Обновляется только отмеченный блок описания; авторский текст сохраняется, устаревшие head/run и повреждённые маркеры отвергаются. Комментарии не создаются. | +| [Samsung Auto Blocker, #60](https://github.com/andre487/AndroidMegaProxy/pull/60) | EN/RU-инструкция уже была, включая повторное отключение для обновлений внутри приложения. Дополнены USB/ADB, восстановление защиты после неуспешной попытки и ограничения администратора. [Инструкция Samsung](https://www.samsung.com/us/support/answer/ANS10003636/). | +| [Регламент проверки релизов, #47](https://github.com/andre487/AndroidMegaProxy/pull/47), [обновление документации, #51](https://github.com/andre487/AndroidMegaProxy/pull/51) | В нашем регламенте уже есть матрица устройств, PASS/FAIL/BLOCKED/NOT TESTED/N/A, идентичность APK, негативные контроли, восстановление и отдельное решение для GitHub/F-Droid. Добавлены ссылки на свидетельства CI и различие debug/unsigned артефакта и подписанного кандидата. Release gate теперь требует успех обоих эмуляторов на точных head/base, включая version-only PR. | +| [Bundler-артефакты, #53](https://github.com/andre487/AndroidMegaProxy/pull/53) | `.bundle/` и `vendor/bundle/` уже исключены. | +| [Changelog для всех ABI, #58](https://github.com/andre487/AndroidMegaProxy/pull/58), [повторное использование AAR, #55](https://github.com/andre487/AndroidMegaProxy/pull/55) | Не применимо: один APK/versionCode, без native AAR и ABI-вариантов. | +| [Portable config schemas, #66](https://github.com/andre487/AndroidMegaProxy/pull/66) | Не применимо: нет импорта MegaProxyConfig. | +| [Диалог обновления при входе, #61](https://github.com/andre487/AndroidMegaProxy/pull/61) | Отдельное изменение пользовательского интерфейса; в этот перенос CI и документации не включено. | + +## Проверки + +- 66 Android JVM-тестов, debug/release lint, debug/test APK и unsigned release APK: PASS. +- 4 instrumentation-теста на каждом отдельном AVD API 26 и API 35: PASS. + Первый прогон API 26 обнаружил ошибку нового теста: `SQLiteOpenHelper` реализует + `AutoCloseable` только с API 29. Тест исправлен на `try/finally`; оба API перепроверены. + Для финального API 35 потребовался перезапуск AVD на другом порту после сбоя регистрации ADB; + assertions не менялись после успешного API 26. +- 17 Python-тестов, включая release gate, обновление описания PR и защиту device lane: + PASS; Black/isort: PASS. +- 5 Node-тестов форматирования Telegram с корректным JUnit suite: PASS. +- Actionlint 1.7.11, Ruby syntax и `git diff --check`: PASS. + +Live n8n smoke не выполнен: локальный Docker daemon недоступен. Hosted GitHub Actions, +публикация Checks и реальная запись описания PR ещё не выполнялись. Подписанный APK, +реальные SMS/notification capture, смерть процесса, OEM и F-Droid этой проверкой не сертифицированы. +Локальные XML сохранены в игнорируемом `test-results/`; это не результаты опубликованного CI. +Созданные одноразовые AVD остановлены и удалены; существующие AVD и данные телефонов не менялись. + +После слияния workflow в основную ветку заработают `workflow_run` для описания PR и fork-отчётов. +Для защиты обычных PR добавьте оба `Android emulator API 26/35 / Device tests` в required checks +(точные имена приведены в [релизной автоматизации](../ru/release-automation.md)). +Скрипт релизной финализации требует их самостоятельно; настройки GitHub в этой задаче не менялись. diff --git a/docs/ru/apk-installation.md b/docs/ru/apk-installation.md index 4b8d4b8..3898d9e 100644 --- a/docs/ru/apk-installation.md +++ b/docs/ru/apk-installation.md @@ -50,6 +50,12 @@ F-Droid, включая альтернативные клиенты с подд Названия пунктов зависят от модели и версии One UI. [Инструкция Samsung](https://www.samsung.com/us/support/answer/ANS10003636/). +Автоблокировка также может блокировать команды через USB, включая установку по ADB. +Если USB debugging или `adb install` заблокированы, проверьте и эту настройку. +Восстановите защиту после попытки установки, в том числе неуспешной. На рабочем устройстве +изменение может запрещать политика организации — обратитесь к администратору. +Автоблокировка, Play Protect и ограниченные настройки — отдельные проверки. + ## Определить, что именно заблокировано | Что показывает Android | Что делать | diff --git a/docs/ru/release-automation.md b/docs/ru/release-automation.md index 75833f3..d97f3d3 100644 --- a/docs/ru/release-automation.md +++ b/docs/ru/release-automation.md @@ -36,7 +36,8 @@ 3. В **Settings → General → Pull Requests** разрешите **Squash merging**. В **Settings → Rules / Branches** защитите `main`, сделайте обязательными проверки **Release CI identity**, **Android tests and checks**, **Python tests and style**, - **Development server tests** и не давайте боту обходить правила. + **Development server tests**, **Android emulator API 26 / Device tests** и + **Android emulator API 35 / Device tests**; не давайте боту обходить правила. Если хотите проверить changelog до автоматического merge, сделайте человеческое ревью обязательным и одобрите релизный PR после чтения текстов. Бот не одобряет себя. Этот флоу использует прямой squash merge и не поддерживает обязательную merge queue. @@ -78,6 +79,8 @@ эти же тексты. Номер версии задаёте вы; prerelease не поддерживается. Finalize ждёт до 60 минут успешного CI именно этого PR, head SHA и базы сравнения. +Оба эмуляторных job API 26/35 обязательны и для release PR, меняющего только версию. +Отсутствующие проверки тоже блокируют слияние и тег. Skipped, neutral, failure и cancelled не считаются успехом. Если `main` или PR изменились, merge останавливается. После merge проверяется точное совпадение дерева с проверенным head; тег создаётся на фактическом слитом коммите. Код из релизного PR не исполняется в finalize. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 4dd161b..6b92131 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -248,3 +248,10 @@ probe APK и синтетические события; верните сеть, для проверки changelog до merge нужен human review gate в [релизном флоу](release-automation.md). Постпубликационный R03 — дополнительная проверка, а не разрешение выпускать непроверенный APK. В рамках подготовки этого регламента тесты на устройствах не выполнялись. + +## Свидетельства CI для кандидата + +Запишите точные head/base PR, URL CI, отдельные результаты API 26/35, JUnit Checks, +ссылку на APK и SHA-256. Ссылки обновляются в описании PR; неподписанные PR APK +и тесты debug на эмуляторах не подтверждают приёмку подписанного релизного кандидата. +Новому коммиту нужен новый CI; skipped или отсутствующие результаты эмуляторов блокируют финализацию. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index c89bbd4..15298b0 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -7,6 +7,7 @@ | Набор | Команда | Что проверяется | | --- | --- | --- | | Android | `bundle exec fastlane android checks` | JVM-логика, HTTP через MockWebServer, БД/настройки/provider через Robolectric, Compose UI, локализация, манифест, контраст обеих тем, debug/release lint и сборки, отсутствие release-подписи | +| Эмулятор | `ANDROID_HOME=/path/to/sdk ANDROID_SERIAL=emulator-5554 bundle exec fastlane android device_tests` | Настоящий Keystore, шифрование настроек/SQLite-очереди, сохранность при повторе, HTTP/ACK/redirect и границы доступа FileProvider на API 26/35 | | Python | `PYTHON=.venv/bin/python bundle exec fastlane android python_checks` | HTTP-клиент тестового сервера на локальном HTTP-стенде, релизная автоматизация с mock API и локальным Git, форматирование закреплёнными Black/isort | | n8n | `bundle exec fastlane android server_tests` | Приём test/notification/SMS, валидация, HTTP-ошибка, неверный ACK и таймаут на живом сервере | @@ -15,11 +16,14 @@ `docker compose -f DevServer/compose.yaml up -d --wait --wait-timeout 300`. Серверные проверки используют синтетические данные и сохраняют их в истории execution. -`.github/workflows/ci.yml` запускает три основных job на PR, push в main и вручную. +`.github/workflows/ci.yml` запускает Android, Python, n8n и отдельные job эмуляторов API 26/35 на PR, push в main и вручную. При ручном запуске с `release_dry_run_version` выполняется только dry-run релиза; основные наборы в этом режиме пропускаются. XML/HTML-отчёты Android-тестов и lint загружаются и при ошибках. Успешный Android-job -также публикует debug APK и неподписанные release APK. Подпись release в этих проверках +также публикует отдельные артефакты debug APK и неподписанного release APK на 14 дней. +Доверенный `pr-artifacts-description.yml` обновляет ссылки в отмеченном блоке описания PR, +сохраняет авторский текст и отвергает старые head/run. Он не исполняет код PR и не создаёт +комментарии; начинает работать после слияния в основную ветку. Подпись release в этих проверках не используется. Локальный успех не подтверждает результат GitHub для неотправленных изменений. ## Сравнение с MegaProxy @@ -39,10 +43,48 @@ Application без запуска Worker и установки глобальн Эти тесты не подтверждают системную доставку SMS/уведомлений, работу разрешений, планирование WorkManager, настоящий Android Keystore, смерть процесса или почтовый клиент. Для этого нужны устройства/эмулятор; сценарий креша описан в [диагностике](diagnostics.md). -Как и MegaProxy, обязательный hosted CI не зависит от нестабильной доступности KVM. +Отдельные эмуляторные проверки используют настоящий Keystore, хранилище, HTTP и provider. +Для CI эмуляторов нужен KVM; ошибка запуска или теста оставляет проверку красной. +Автоматического повтора упавших assertions нет. Подписанная release-сборка отдельно запускает Android-тесты и lint с signing-переменными. PR-проверки отвергают эти переменные и остаются неподписанными. См. [релизы](releases.md). +## Эмуляторы и JUnit + +Используйте **одноразовый** Google APIs AVD с именем `message487-tests`, +`message487-tests-26` или `message487-tests-35`. Тесты очищают настройки и очередь +Message487 на выбранном эмуляторе. Подключите только его и задайте `ANDROID_SERIAL` +и `ANDROID_HOME`: lane отвергает физические устройства, другие имена AVD и дополнительные +подключённые устройства. CI создаёт чистый AVD для каждого API и собирает debug/test APK +без release signing secrets. + +Для локального AVD используйте `arm64-v8a` на Apple Silicon или `x86_64` на Intel/Linux: + +```sh +sdkmanager "system-images;android-35;google_apis;arm64-v8a" +avdmanager create avd --name message487-tests-35 --package "system-images;android-35;google_apis;arm64-v8a" --device pixel_5 +emulator -avd message487-tests-35 -no-snapshot +# В другом терминале; serial возьмите из adb devices: +ANDROID_SERIAL=emulator-5554 bundle exec fastlane android device_tests +``` + +Повторите с API 26. Проверяется повторное открытие хранилищ; смерть процесса, системный +приём SMS/уведомлений, расписание WorkManager, ограничения OEM и обновление подписанного APK +остаются сценариями приёмки на устройствах. Debug-эмуляторы не сертифицируют релизный кандидат. + +Android сохраняет JUnit XML средствами Gradle; Python и live n8n smoke используют +`unittest-xml-reporting`, Node — встроенный JUnit reporter. Для Python и server lane +установите `requirements-dev.txt`. Python XML: `test-results/python/`, n8n XML: +`test-results/server/`, device XML: `app/build/outputs/androidTest-results/connected/`. +CI загружает отчёты и после ошибки тестов; свидетельства эмуляторов включают HTML +и Logcat и хранятся 14 дней. + +`test-reports.yml` публикует каждый набор в GitHub Checks и сворачиваемом Actions summary +сразу после завершения job. Внутренний CI и релиз вызывают его напрямую; для fork PR +используется доверенный `workflow_run` после слияния workflow в основную ветку. Reporter +только разбирает артефакты и не запускает код PR. Ошибка без XML остаётся ошибкой CI, +а не успешным тестовым отчётом. JVM-отчёты подписанной сборки публикуются отдельно. + Полный регламент проверки подписанного кандидата, F-Droid и устройств — [Проверка релиза](release-testing.md). diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e5f24bc..6e999d7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -36,18 +36,32 @@ platform :android do Dir.chdir(project_root) do sh(python, "-m", "isort", "--check-only", "DevServer/tests", "scripts/release_automation.py", "scripts/tests") sh(python, "-m", "black", "--check", "DevServer/tests", "scripts/release_automation.py", "scripts/tests") - sh(python, "-m", "unittest", "discover", "-s", "DevServer/tests", "-v") - sh(python, "-m", "unittest", "discover", "-s", "scripts/tests", "-v") + FileUtils.rm_rf("test-results/python") + sh(python, "-m", "xmlrunner", "discover", "-s", "DevServer/tests", "-o", "test-results/python/dev-server") + sh(python, "-m", "xmlrunner", "discover", "-s", "scripts/tests", "-o", "test-results/python/scripts") end end desc "Exercise the running development n8n server" lane :server_tests do Dir.chdir(project_root) do - sh("docker", "compose", "-f", "DevServer/compose.yaml", "exec", "-T", "n8n", - "node", "--test", "/bootstrap/tests/telegram-format.test.cjs") + FileUtils.rm_rf("test-results/server") + FileUtils.mkdir_p("test-results/server") + sh("docker compose -f DevServer/compose.yaml exec -T n8n node --test --test-reporter=junit /bootstrap/tests/telegram-format.test.cjs > test-results/server/telegram.xml") + sh(ENV.fetch("PYTHON", "python3"), "-m", "xmlrunner", "discover", "-s", "DevServer/tests", "-p", "smoke.py", "-o", "test-results/server") end - sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py")) + end + + desc "Run instrumentation tests on a disposable emulator (never a personal device)" + lane :device_tests do + serial = ENV.fetch("ANDROID_SERIAL") + UI.user_error!("Select one disposable emulator with ANDROID_SERIAL") unless serial.match?(/\Aemulator-\d+\z/) + adb = File.join(ENV.fetch("ANDROID_HOME"), "platform-tools/adb") + devices = IO.popen([adb, "devices"], &:read).lines.filter_map { |line| line.split.first if line.match?(/\tdevice$/) } + UI.user_error!("Connect only the selected disposable emulator") unless devices == [serial] + avd = IO.popen([adb, "-s", serial, "emu", "avd", "name"], &:read).lines.first.to_s.strip + UI.user_error!("Use an AVD named message487-tests (or message487-tests-API)") unless avd.match?(/\Amessage487-tests(?:-\d+)?\z/) + gradle(task: "connectedDebugAndroidTest", flags: "--no-daemon", project_dir: project_root) end desc "Build, sign and verify release APK and checksums" diff --git a/fastlane/README.md b/fastlane/README.md index dbe5f84..29d079e 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -39,6 +39,14 @@ Run Python tests and formatting checks for the development server Exercise the running development n8n server +### android device_tests + +```sh +[bundle exec] fastlane android device_tests +``` + +Run instrumentation tests on a disposable emulator (never a personal device) + ### android release_artifacts ```sh diff --git a/requirements-dev.txt b/requirements-dev.txt index 80e7482..5147c6f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ black==26.5.1 isort==9.0.1 +unittest-xml-reporting==4.0.0 diff --git a/scripts/release_automation.py b/scripts/release_automation.py index 7c411f6..5a9f09a 100644 --- a/scripts/release_automation.py +++ b/scripts/release_automation.py @@ -18,6 +18,8 @@ "android": "Android tests and checks", "python": "Python tests and style", "dev-server": "Development server tests", + "emulator26": "Android emulator API 26 / Device tests", + "emulator35": "Android emulator API 35 / Device tests", } VERSION = r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" diff --git a/scripts/tests/test_device_lane.py b/scripts/tests/test_device_lane.py new file mode 100644 index 0000000..4a6030f --- /dev/null +++ b/scripts/tests/test_device_lane.py @@ -0,0 +1,72 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[2] +LANE = ( + (ROOT / 'fastlane/Fastfile') + .read_text() + .split(' lane :device_tests do\n', 1)[1] + .split('\n end', 1)[0] +) + + +class DeviceLaneTest(unittest.TestCase): + def test_only_one_explicit_disposable_emulator_can_run_tests(self): + with tempfile.TemporaryDirectory() as directory: + adb = Path(directory) / 'platform-tools/adb' + adb.parent.mkdir() + adb.write_text( + '#!/bin/sh\n' + 'if [ "$1" = devices ]; then printf "%b" "$TEST_DEVICES"; ' + 'else printf "%s\\n" "$TEST_AVD"; fi\n' + ) + adb.chmod(0o755) + for serial, devices, avd, allowed in [ + ('emulator-5554', 'emulator-5554\tdevice\n', 'message487-tests', True), + ( + 'emulator-5554', + 'emulator-5554\tdevice\n', + 'message487-tests-26', + True, + ), + ('phone', 'phone\tdevice\n', 'message487-tests', False), + ('', '', 'message487-tests', False), + ('emulator-5554', '', 'message487-tests', False), + ( + 'emulator-5554', + 'emulator-5554\tdevice\nphone\tdevice\n', + 'message487-tests', + False, + ), + ( + 'emulator-5554', + 'emulator-5554\tdevice\n', + 'Message487_API_35', + False, + ), + ]: + with self.subTest(serial=serial, devices=devices, avd=avd): + result = subprocess.run( + [ + 'ruby', + '-e', + 'module UI; def self.user_error!(s); abort(s); end; end\n' + 'def gradle(**args); puts "RUN_TESTS"; end\n' + 'project_root = "."\n' + LANE, + ], + env=dict( + os.environ, + ANDROID_HOME=directory, + ANDROID_SERIAL=serial, + TEST_DEVICES=devices, + TEST_AVD=avd, + ), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode == 0, allowed, result.stderr) + self.assertEqual('RUN_TESTS' in result.stdout, allowed) diff --git a/scripts/tests/test_pr_artifacts.py b/scripts/tests/test_pr_artifacts.py new file mode 100644 index 0000000..67a23c9 --- /dev/null +++ b/scripts/tests/test_pr_artifacts.py @@ -0,0 +1,86 @@ +import json +import subprocess +import textwrap +import unittest +from pathlib import Path + +WORKFLOW = Path(__file__).parents[2] / ".github/workflows/pr-artifacts-description.yml" +SCRIPT = textwrap.dedent(WORKFLOW.read_text().split("script: |\n", 1)[1]) +HARNESS = """ +const script = JSON.parse(process.argv[1]); +const options = JSON.parse(process.argv[2]); +const run = {id: 10, run_number: 20, run_attempt: 1, head_sha: 'abc', + html_url: 'https://example/run', pull_requests: [{number: 7}]}; +const result = {updates: [], errors: []}; +const github = { + rest: { + actions: {listWorkflowRunArtifacts: 'artifacts'}, + pulls: { + get: async () => ({data: {body: options.body ?? '', state: 'open', + head: {sha: options.head ?? 'abc'}}}), + update: async value => { result.updates.push(value); } + } + }, + paginate: async () => ['debug-apk', 'unsigned-release-apk'].map((suffix, id) => + ({id, name: 'message487-pr-7-' + suffix, expired: options.expired ?? false})) +}; +const context = {repo: {owner: 'owner', repo: 'repo'}, serverUrl: 'https://github.com', + payload: {workflow_run: run}}; +const core = {notice() {}, setFailed: message => result.errors.push(message)}; +const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; +new AsyncFunction('github', 'context', 'core', script)(github, context, core) + .then(() => process.stdout.write(JSON.stringify(result))) + .catch(error => {console.error(error); process.exitCode = 1;}); +""" + + +class ArtifactDescriptionTest(unittest.TestCase): + def run_workflow(self, **options): + output = subprocess.check_output( + ["node", "-e", HARNESS, json.dumps(SCRIPT), json.dumps(options)], + text=True, + ) + return json.loads(output) + + def test_append_replace_and_preserve_author_text(self): + first = self.run_workflow(body="Author description\n\n") + self.assertFalse(first["errors"]) + body = first["updates"][0]["body"] + self.assertTrue( + body.startswith("Author description\n\n") + ) + self.assertTrue(body.endswith("")) + self.assertIn("/actions/runs/10/artifacts/0", body) + second = self.run_workflow(body=body + "\n\nAuthor follow-up") + new_body = second["updates"][0]["body"] + self.assertIn("Author follow-up\n\n", new_body) + self.assertEqual(new_body.count("## APK artifacts"), 1) + self.assertEqual( + self.run_workflow(body=new_body)["updates"][0]["body"], new_body + ) + self.assertTrue(self.run_workflow()["updates"][0]["body"].startswith(""}, + {"body": ""}, + ]: + with self.subTest(options=options): + result = self.run_workflow(**options) + self.assertEqual(result["updates"], []) + self.assertEqual(result["errors"], []) + + def test_missing_artifacts_and_broken_markers_fail_without_overwriting(self): + for options in [ + {"expired": True}, + {"body": "Author text\n\nEdited block"}, + ]: + with self.subTest(options=options): + result = self.run_workflow(**options) + self.assertEqual(result["updates"], []) + self.assertTrue(result["errors"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_release_automation.py b/scripts/tests/test_release_automation.py index 02e2537..ea8af97 100644 --- a/scripts/tests/test_release_automation.py +++ b/scripts/tests/test_release_automation.py @@ -251,6 +251,10 @@ def test_changed_draft_closed_and_fork_prs_stop_before_fetch_or_merge(self): m.finish("0.1.2", 7, "a" * 40) def test_ci_requires_each_success_on_exact_pr_head_and_recorded_base(self): + for api in (26, 35): + self.assertIn( + f"Android emulator API {api} / Device tests", m.CHECKS.values() + ) run = { "event": "pull_request", "head_sha": "a" * 40, @@ -293,7 +297,10 @@ def test_ci_requires_each_success_on_exact_pr_head_and_recorded_base(self): ]: with self.assertRaises(RuntimeError): m.full_ci(dict(run, **kwargs), jobs, "a" * 40, "b" * 40, 7) - for changed in [jobs[:-1], jobs + [jobs[0]]]: + for changed in [ + *[jobs[:i] + jobs[i + 1 :] for i in range(len(jobs))], + jobs + [jobs[0]], + ]: with self.assertRaises(RuntimeError): m.full_ci(run, changed, "a" * 40, "b" * 40, 7) with self.assertRaises(RuntimeError):