Gate APK releases on worker factory and Matrix regression tests
Build and Release APK / Build APK (push) Failing after 2m46s

This commit is contained in:
2026-10-21 23:00:00 +02:00
parent af4e7899e3
commit d27edde5e8
6 changed files with 258 additions and 3 deletions
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Require the critical runtime regression suites before publishing APKs."""
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
WORKERS = 'dev.octoshrimpy.quik.worker.InjectionWorkerFactoryTest'
REQUIRED = {
WORKERS: {
'matrixCoroutineWorkerIsCreatedByTheApplicationFactory',
'matrixWatcherStartsTheExpectedService',
'housekeepingReceivesItsRepository',
'smsWorkerReceivesEveryRequiredDependency',
'mmsWorkerReceivesEveryRequiredDependency',
'eachRequestCreatesANewWorkerInstance',
},
'ch.skew.remotrix.bridge.BackfillRunnerTest': set(),
'ch.skew.remotrix.bridge.RoomResolverTest': set(),
'ch.skew.remotrix.bridge.CloseMatrixClientTest': set(),
'ch.skew.remotrix.bridge.BridgeResetTest': set(),
}
def check(report_dirs):
passed = {}
count = 0
for directory in report_dirs:
for path in Path(directory).glob('TEST-*.xml'):
suite = ET.parse(path).getroot()
for case in suite.iter('testcase'):
name = case.get('name', '')
classname = case.get('classname', suite.get('name', ''))
if case.find('failure') is not None or case.find('error') is not None:
raise ValueError(f'Failed test: {classname}.{name}')
if case.find('skipped') is not None:
if classname in REQUIRED:
raise ValueError(f'Skipped critical test: {classname}.{name}')
continue
passed.setdefault(classname, set()).add(name)
count += 1
for classname, names in REQUIRED.items():
if not passed.get(classname):
raise ValueError(f'Missing critical test suite: {classname}')
missing = names - passed[classname]
if missing:
raise ValueError(f'Missing critical tests in {classname}: {sorted(missing)}')
return count
def main():
try:
total = check([
ROOT / 'presentation/build/test-results/testDebugUnitTest',
ROOT / 'remotrix-sync/build/test-results/testDebugUnitTest',
])
except (OSError, ValueError, ET.ParseError) as error:
print(f'Release test gate failed: {error}', file=sys.stderr)
return 1
print(f'Release test gate passed: {total} tests, all critical suites present.')
return 0
if __name__ == '__main__':
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
import xml.etree.ElementTree as ET
spec = importlib.util.spec_from_file_location(
'test_results_gate', Path(__file__).parents[1] / 'scripts/check_test_results.py')
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class ReleaseTestGateTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
for classname, names in gate.REQUIRED.items():
suite = ET.Element('testsuite', name=classname)
for name in names or {'regressionTest'}:
ET.SubElement(suite, 'testcase', classname=classname, name=name)
ET.ElementTree(suite).write(self.directory / f'TEST-{classname}.xml')
def change_worker_report(self, change):
path = self.directory / f'TEST-{gate.WORKERS}.xml'
tree = ET.parse(path)
change(tree.getroot())
tree.write(path)
def test_all_required_tests_pass(self):
self.assertEqual(gate.check([self.directory]), 10)
def test_empty_report_directory_blocks_release(self):
with self.assertRaisesRegex(ValueError, 'Missing critical test suite'):
gate.check([self.directory / 'missing'])
def test_failed_worker_test_blocks_release(self):
self.change_worker_report(lambda r: ET.SubElement(r[0], 'failure'))
with self.assertRaisesRegex(ValueError, 'Failed test'):
gate.check([self.directory])
def test_skipped_worker_test_blocks_release(self):
self.change_worker_report(lambda r: ET.SubElement(r[0], 'skipped'))
with self.assertRaisesRegex(ValueError, 'Skipped critical test'):
gate.check([self.directory])
def test_removed_coroutine_worker_test_blocks_release(self):
def remove(root):
for case in list(root):
if case.get('name') == 'matrixCoroutineWorkerIsCreatedByTheApplicationFactory':
root.remove(case)
self.change_worker_report(remove)
with self.assertRaisesRegex(ValueError, 'Missing critical tests'):
gate.check([self.directory])
+5 -2
View File
@@ -63,8 +63,11 @@ jobs:
env:
BUILD_NUMBER: ${{ github.run_number }}
- name: Test Matrix bridge
run: ./gradlew :remotrix-sync:testDebugUnitTest
- name: Test worker factory and Matrix bridge
run: |
set -eu
./gradlew :presentation:cleanTestDebugUnitTest :remotrix-sync:cleanTestDebugUnitTest :presentation:testDebugUnitTest :remotrix-sync:testDebugUnitTest
python3 .gitea/scripts/check_test_results.py
- name: Build Debug APK
run: |
+20
View File
@@ -186,3 +186,23 @@ QUIK is developed and maintained by [Marcos Jones](https://github.com/octoshrimp
## License
QUIK is released under the **The GNU General Public License v3.0 (GPLv3)**, which can be found in the [LICENSE](LICENSE) file in the root of this project.
## Automated release checks
Before building or publishing APKs, Gitea Actions runs Robolectric tests against the actual
`InjectionWorkerFactory`. They construct every worker type used by Quik and the Matrix bridge,
verify SMS/MMS dependency injection, and exercise the Matrix watcher's service-start intent.
These tests use simulated Android services and mocked repositories, without sending SMS or
connecting to Matrix. Matrix room creation, client shutdown, reset and history import also have
regression tests. Signing checks run separately.
Run the same runtime gate locally with JDK 17 and the Android SDK:
```sh
./gradlew :presentation:cleanTestDebugUnitTest :remotrix-sync:cleanTestDebugUnitTest :presentation:testDebugUnitTest :remotrix-sync:testDebugUnitTest
python3 .gitea/scripts/check_test_results.py
```
The gate rejects failures, missing required suites, and skipped critical tests. A failed gate
stops the workflow before publication. These tests cover known regression scenarios; they do
not replace testing the installed Release APK on Android devices.
+3 -1
View File
@@ -193,7 +193,9 @@ dependencies {
androidTestImplementation "org.mockito:mockito-android:$mockito_version"
testImplementation "androidx.test:runner:$androidx_testrunner_version"
testImplementation "junit:junit:$junit_version"
testImplementation "org.mockito:mockito-core:$mockito_version"
testImplementation "org.mockito:mockito-inline:4.11.0"
testImplementation "org.robolectric:robolectric:4.11.1"
testImplementation "androidx.work:work-testing:$androidx_work_version"
// moshi
implementation "com.squareup.moshi:moshi:$moshi_version"
@@ -0,0 +1,111 @@
package dev.octoshrimpy.quik.worker
import android.app.Application
import android.content.ComponentName
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import ch.skew.remotrix.background.CommandService
import ch.skew.remotrix.background.ServiceWatcher
import dev.octoshrimpy.quik.blocking.BlockingClient
import dev.octoshrimpy.quik.interactor.UpdateBadge
import dev.octoshrimpy.quik.manager.ActiveConversationManager
import dev.octoshrimpy.quik.manager.NotificationManager
import dev.octoshrimpy.quik.manager.ShortcutManager
import dev.octoshrimpy.quik.repository.ContactRepository
import dev.octoshrimpy.quik.repository.ConversationRepository
import dev.octoshrimpy.quik.repository.MessageContentFilterRepository
import dev.octoshrimpy.quik.repository.MessageRepository
import dev.octoshrimpy.quik.repository.ScheduledMessageRepository
import dev.octoshrimpy.quik.repository.SyncRepository
import dev.octoshrimpy.quik.util.Preferences
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/** Real WorkManager construction and Android service intents; no SMS or Matrix network calls. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE, application = Application::class)
class InjectionWorkerFactoryTest {
private val conversationRepo = mock(ConversationRepository::class.java)
private val blockingClient = mock(BlockingClient::class.java)
private val prefs = mock(Preferences::class.java)
private val messageRepo = mock(MessageRepository::class.java)
private val updateBadge = mock(UpdateBadge::class.java)
private val shortcutManager = mock(ShortcutManager::class.java)
private val scheduledMessages = mock(ScheduledMessageRepository::class.java)
private val notifications = mock(NotificationManager::class.java)
private val activeConversation = mock(ActiveConversationManager::class.java)
private val syncRepo = mock(SyncRepository::class.java)
private val filters = mock(MessageContentFilterRepository::class.java)
private val contacts = mock(ContactRepository::class.java)
private val factory = InjectionWorkerFactory(
conversationRepo, blockingClient, prefs, messageRepo, updateBadge, shortcutManager,
scheduledMessages, notifications, activeConversation, syncRepo, filters, contacts
)
private val application: Application get() = RuntimeEnvironment.getApplication()
private inline fun <reified W : ListenableWorker> build(): W =
TestListenableWorkerBuilder<W>(application).setWorkerFactory(factory).build()
@Test
fun matrixCoroutineWorkerIsCreatedByTheApplicationFactory() {
assertEquals(ServiceWatcher::class.java, build<ServiceWatcher>().javaClass)
}
@Test
fun matrixWatcherStartsTheExpectedService() = runBlocking {
val worker = build<ServiceWatcher>()
assertEquals(ListenableWorker.Result.success(), worker.doWork())
val intent = shadowOf(application).nextStartedService
assertNotNull(intent)
assertEquals(CommandService.START_ALL, intent.action)
assertEquals(ComponentName(application, CommandService::class.java), intent.component)
}
@Test
fun housekeepingReceivesItsRepository() {
assertSame(scheduledMessages, build<HousekeepingWorker>().scheduledMessageRepository)
}
@Test
fun smsWorkerReceivesEveryRequiredDependency() {
val worker = build<ReceiveSmsWorker>()
assertSame(conversationRepo, worker.conversationRepo)
assertSame(blockingClient, worker.blockingClient)
assertSame(prefs, worker.prefs)
assertSame(messageRepo, worker.messageRepo)
assertSame(notifications, worker.notificationManager)
assertSame(updateBadge, worker.updateBadge)
assertSame(shortcutManager, worker.shortcutManager)
assertSame(filters, worker.filterRepo)
assertSame(contacts, worker.contactsRepo)
}
@Test
fun mmsWorkerReceivesEveryRequiredDependency() {
val worker = build<ReceiveMmsWorker>()
assertSame(activeConversation, worker.activeConversationManager)
assertSame(conversationRepo, worker.conversationRepo)
assertSame(blockingClient, worker.blockingClient)
assertSame(prefs, worker.prefs)
assertSame(syncRepo, worker.syncRepo)
assertSame(messageRepo, worker.messageRepo)
assertSame(notifications, worker.notificationManager)
assertSame(updateBadge, worker.updateBadge)
assertSame(shortcutManager, worker.shortcutManager)
assertSame(filters, worker.filterRepo)
assertSame(contacts, worker.contactsRepo)
}
@Test
fun eachRequestCreatesANewWorkerInstance() {
assertNotSame(build<ServiceWatcher>(), build<ServiceWatcher>())
assertNotSame(build<ReceiveSmsWorker>(), build<ReceiveSmsWorker>())
}
}