From 2fc6f4747275bdbfec2288ac1a45f7d5bedcd9ac Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:54:31 -0600 Subject: [PATCH] Clear unsent chat text when panic wipe is confirmed (#1027) The chat screen stored the unsent message separately from the state cleared by panic wipe, leaving old text visible after completion. Mention suggestions could also put text back into the cleared input. Cleared the input and its command and mention suggestions when the user confirms the wipe. Rejected mention selections when suggestions are hidden or the nickname is no longer in the current list, including older click handlers and rows still visible during their closing animation. --- app/build.gradle.kts | 11 +- .../ui/ComposerEdgeCasesInstrumentedTest.kt | 149 ++++++++++++++ .../ui/ComposerOutcomeInstrumentedTest.kt | 178 +++++++++++++++++ .../ui/ComposerPanicInstrumentedTest.kt | 79 ++++++++ .../android/ui/ComposerPanicTestRunner.kt | 25 +++ .../java/com/bitchat/android/ui/ChatScreen.kt | 20 +- docs/composer-panic-test.md | 96 +++++++++ docs/testing-conventions.md | 6 + tools/composer/run_regression.py | 184 ++++++++++++++++++ tools/composer/test_run_regression.py | 121 ++++++++++++ 10 files changed, 862 insertions(+), 7 deletions(-) create mode 100644 app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerEdgeCasesInstrumentedTest.kt create mode 100644 app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerOutcomeInstrumentedTest.kt create mode 100644 app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicInstrumentedTest.kt create mode 100644 app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicTestRunner.kt create mode 100644 docs/composer-panic-test.md create mode 100644 tools/composer/run_regression.py create mode 100644 tools/composer/test_run_regression.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1ee132e9..782668c85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,8 @@ plugins { // Opt-in instrumentation of the minified variant; production release signing is unchanged. val releaseUiTests = providers.gradleProperty("releaseUiTests").orNull == "true" +val composerUiTests = providers.gradleProperty("composerUiTests").orNull == "true" +require(!(releaseUiTests && composerUiTests)) { "Select one UI test configuration" } val githubReleaseCertSha256 = providers .environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256") @@ -42,7 +44,9 @@ android { "\"$normalizedGithubReleaseCertSha256\"" ) - testInstrumentationRunner = if (releaseUiTests) { + testInstrumentationRunner = if (composerUiTests) { + "com.bitchat.android.ui.ComposerPanicTestRunner" + } else if (releaseUiTests) { "com.bitchat.android.ui.ReleaseScannerTestRunner" } else { "com.bitchat.android.ui.PerformanceTestRunner" @@ -59,8 +63,13 @@ android { includeInBundle = false } + if (composerUiTests) { + sourceSets.getByName("androidTest").kotlin.directories.add("src/composerAndroidTest/java") + } + buildTypes { debug { + if (composerUiTests) applicationIdSuffix = ".composerTest" ndk { // Include x86_64 for emulator support during development abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86") diff --git a/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerEdgeCasesInstrumentedTest.kt b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerEdgeCasesInstrumentedTest.kt new file mode 100644 index 000000000..150faa7c6 --- /dev/null +++ b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerEdgeCasesInstrumentedTest.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.ViewModelProvider +import android.view.KeyEvent +import androidx.test.platform.app.InstrumentationRegistry +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.services.AppStateStore +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ComposerEdgeCasesInstrumentedTest { + @get:Rule val compose = createAndroidComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val editor = hasSetTextAction() and SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send) + private fun field() = compose.onNode(editor) + private fun start() { + compose.waitUntil(30_000) { compose.onAllNodes(editor).fetchSemanticsNodes().size == 1 } + field().assertTextEquals("") + } + private fun openConfirmation() { + compose.mainClock.autoAdvance = false + try { repeat(3) { compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about)).performClick() } } + finally { compose.mainClock.autoAdvance = true } + compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed() + } + private fun confirmAndWait() { + val prior = AppStateStore.publicMessages.value.map { it.id }.toSet() + compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick() + compose.waitUntil(30_000) { + AppStateStore.publicMessages.value.any { + it.id !in prior && it.content == context.getString(R.string.system_panic_completed) + } + } + } + @Test fun backPreservesMultilineUnicodeAndSelectedTextThenWipeClears() { + start() + val draft = "SYNTHETIC\n雪🙂 é\nline two" + field().performTextInput(draft) + field().performTextInputSelection(TextRange(2, 7)) + openConfirmation() + InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK) + field().assertTextEquals(draft) + field().assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(2, 7))) + openConfirmation(); confirmAndWait() + field().assertTextEquals("") + field().assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange.Zero)) + field().performTextInput("SYNTHETIC_FRESH") + field().assertTextEquals("SYNTHETIC_FRESH") + } + @Test fun emptyAndRepeatedWipesPreserveFreshTyping() { + start(); openConfirmation(); confirmAndWait(); field().assertTextEquals("") + field().performTextInput("SYNTHETIC_SECOND") + openConfirmation(); confirmAndWait(); field().assertTextEquals("") + field().performTextInput("SYNTHETIC_AFTER_SECOND") + field().assertTextEquals("SYNTHETIC_AFTER_SECOND") + } + @Test fun confirmedWipeDismissesCommandSuggestions() { + start(); field().performTextInput("/w") + val vm = ViewModelProvider(compose.activity)[ChatViewModel::class.java] + compose.waitUntil(5_000) { vm.showCommandSuggestions.value && vm.commandSuggestions.value.isNotEmpty() } + val command = vm.commandSuggestions.value.first().command + compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertIsDisplayed() + openConfirmation(); confirmAndWait() + field().assertTextEquals("") + assertFalse("pre-wipe command suggestion popup must be dismissed", vm.showCommandSuggestions.value) + compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertDoesNotExist() + field().performTextInput("/w") + compose.waitUntil(5_000) { vm.showCommandSuggestions.value } + compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertIsDisplayed() + } + private fun seedMention(nickname: String = "SyntheticMentionPeer"): ChatViewModel { + val vm = ViewModelProvider(compose.activity)[ChatViewModel::class.java] + compose.runOnIdle { + vm.meshService.updatePeerInfo( + peerID = "aaaabbbbccccdddd", nickname = nickname, + noisePublicKey = ByteArray(32) { 1 }, signingPublicKey = ByteArray(32) { 2 }, + isVerified = false + ) + assertEquals(nickname, vm.meshServiceFacade.getPeerNicknames()["aaaabbbbccccdddd"]) + } + field().performTextInput("@" + nickname.dropLast(4)) + compose.waitUntil(5_000) { vm.showMentionSuggestions.value } + compose.onNodeWithText(nickname, substring = true).assertIsDisplayed() + return vm + } + @Test fun confirmedWipeDismissesMentionSuggestions() { + start(); val vm = seedMention() + openConfirmation(); confirmAndWait(); field().assertTextEquals("") + assertFalse("synthetic peer must be removed from fresh mesh", vm.meshServiceFacade.getPeerNicknames().containsKey("aaaabbbbccccdddd")) + compose.onNodeWithText("SyntheticMentionPeer", substring = true).assertDoesNotExist() + assertFalse("pre-wipe mention suggestion popup must be dismissed", vm.showMentionSuggestions.value) + seedMention("FreshSyntheticPeer") + compose.onNodeWithText("FreshSyntheticPeer", substring = true).performClick() + field().assertTextEquals("@FreshSyntheticPeer ") + } + @Test fun exitingMentionRowsCannotRefillInput() { + start() + // Exercise a captured callback before any redraw, separately from pointer timing. + val vm = seedMention() + val staleAction = compose.onNodeWithText("SyntheticMentionPeer", substring = true) + .fetchSemanticsNode().config[SemanticsActions.OnClick].action!! + openConfirmation() + val confirmAction = compose.onNodeWithText(context.getString(R.string.panic_confirm_action)) + .fetchSemanticsNode().config[SemanticsActions.OnClick].action!! + compose.mainClock.autoAdvance = false + try { + compose.runOnUiThread { + assertTrue(confirmAction()) + assertFalse(vm.showMentionSuggestions.value) + assertTrue(staleAction()) + } + compose.mainClock.advanceTimeByFrame() + field().assertTextEquals("") + } finally { + compose.mainClock.autoAdvance = true + } + compose.waitUntil(30_000) { + !ChatViewModel::class.java.getDeclaredField("panicClearInProgress") + .apply { isAccessible = true }.getBoolean(vm) + } + for (frames in listOf(1, 2)) { + field().performTextReplacement("") + seedMention() + openConfirmation() + compose.mainClock.autoAdvance = false + try { + confirmAndWait() + repeat(frames) { compose.mainClock.advanceTimeByFrame() } + val retained = compose.onAllNodesWithText("SyntheticMentionPeer", substring = true) + val count = retained.fetchSemanticsNodes().size + println("EXIT_FRAME=$frames retainedRows=$count") + assertEquals("fixture must click a retained mention exit row at frame $frames", 1, count) + retained[0].performClick() + compose.mainClock.advanceTimeBy(500) + field().assertTextEquals("") + } finally { + compose.mainClock.autoAdvance = true + } + } + } +} diff --git a/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerOutcomeInstrumentedTest.kt b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerOutcomeInstrumentedTest.kt new file mode 100644 index 000000000..e96ed6322 --- /dev/null +++ b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerOutcomeInstrumentedTest.kt @@ -0,0 +1,178 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.ViewModelProvider +import androidx.test.platform.app.InstrumentationRegistry +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ConversationRepository +import com.bitchat.android.services.ConversationStoreState +import kotlinx.coroutines.CoroutineDispatcher +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ComposerOutcomeInstrumentedTest { + @get:Rule val compose = createAndroidComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val editor = hasSetTextAction() and SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send) + private fun field() = compose.onNode(editor) + private fun start(): ChatViewModel { + compose.waitUntil(30_000) { compose.onAllNodes(editor).fetchSemanticsNodes().size == 1 } + return ViewModelProvider(compose.activity)[ChatViewModel::class.java] + } + private fun confirmation() { + compose.mainClock.autoAdvance = false + try { repeat(3) { compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about)).performClick() } } + finally { compose.mainClock.autoAdvance = true } + compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed() + } + private fun confirm() = compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick() + private fun ids() = AppStateStore.publicMessages.value.map { it.id }.toSet() + private fun awaitOutcome(prior: Set, resource: Int) { + compose.waitUntil(30_000) { AppStateStore.publicMessages.value.any { it.id !in prior && it.content == context.getString(resource) } } + } + private fun inProgress(vm: ChatViewModel): Boolean = ChatViewModel::class.java.getDeclaredField("panicClearInProgress").apply { isAccessible = true }.getBoolean(vm) + + private fun withCleanup(vararg cleanup: () -> Unit, block: () -> Unit) { + var failure: Throwable? = null + try { + block() + } catch (error: Throwable) { + failure = error + } finally { + for (action in cleanup) { + try { + action() + } catch (error: Throwable) { + val primary = failure + if (primary == null) failure = error + else if (primary !== error) primary.addSuppressed(error) + } + } + } + failure?.let { throw it } + } + + @Test fun failedConversationWipeStillClearsConfirmedDraft() { + val vm = start() + val original = ConversationRepository.getInstance(context) + val name = "synthetic-composer-failed-store.db" + val path = context.getDatabasePath(name) + path.deleteRecursively(); assertTrue(path.mkdirs()) + val repository = ConversationRepository(context, databaseName = name) + withCleanup( + { compose.waitUntil(30_000) { !inProgress(vm) } }, + { AppStateStore.setConversationRepositoryForTest(original) }, + { repository.closeForTest() }, + { path.deleteRecursively() }, + { + val prior = ids() + compose.runOnIdle { + check(!inProgress(vm)) { "previous panic wipe has not finished" } + vm.panicClearAllData() + } + awaitOutcome(prior, R.string.system_panic_completed) + } + ) { + AppStateStore.setConversationRepositoryForTest(repository) + field().performTextInput("SYNTHETIC_FAILED_WIPE") + confirmation(); val prior = ids(); confirm() + awaitOutcome(prior, R.string.system_panic_incomplete) + compose.waitUntil(5_000) { !inProgress(vm) } + field().assertTextEquals("") + assertTrue(repository.storeState.value is ConversationStoreState.Error) + assertFalse(AppStateStore.publicMessages.value.any { it.id !in prior && it.content == context.getString(R.string.system_panic_completed) }) + } + } + + @Test fun secondConfirmationClearsDraftWhileFirstWipeWaitsThenDrains() { + val vm = start() + val original = ConversationRepository.getInstance(context) + val executor = Executors.newSingleThreadExecutor() + val entered = CountDownLatch(1); val release = CountDownLatch(1) + val queued = AtomicInteger() + val dispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { queued.incrementAndGet(); executor.execute(block) } + } + val repository = ConversationRepository(context, dispatcher, "synthetic-composer-held-store.db") + withCleanup( + { release.countDown() }, + { compose.waitUntil(30_000) { !inProgress(vm) } }, + { AppStateStore.setConversationRepositoryForTest(original) }, + { repository.closeForTest() }, + { executor.shutdown() }, + { assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) }, + { context.deleteDatabase("synthetic-composer-held-store.db") } + ) { + executor.execute { entered.countDown(); check(release.await(30, TimeUnit.SECONDS)) } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + AppStateStore.setConversationRepositoryForTest(repository) + field().performTextInput("SYNTHETIC_FIRST") + confirmation(); val prior = ids(); confirm() + compose.waitUntil(5_000) { queued.get() == 1 && inProgress(vm) } + field().assertTextEquals("") + assertEquals(1L, release.count) + field().performTextInput("SYNTHETIC_WHILE_WAITING") + confirmation(); confirm(); field().assertTextEquals("") + assertEquals("second confirmation must not enqueue another wipe", 1, queued.get()) + assertTrue(inProgress(vm)) + release.countDown() + awaitOutcome(prior, R.string.system_panic_completed) + compose.waitUntil(5_000) { !inProgress(vm) } + field().assertTextEquals("") + field().performTextInput("SYNTHETIC_AFTER_DRAIN") + field().assertTextEquals("SYNTHETIC_AFTER_DRAIN") + } + } + + @Test fun sendButtonAcceptsOrdinaryAndFreshPostWipeTextIntoLocalSink() { + val vm = start() + val captured = CopyOnWriteArrayList() + var delegateToRestore = vm.meshServiceFacade + var installedSink: MeshService? = null + fun installSink() { + val delegate = vm.meshServiceFacade + delegateToRestore = delegate + val sink = object : MeshService by delegate { + override fun sendMessage(content: String, mentions: List, channel: String?) { captured.add(content) } + } + installedSink = sink + compose.runOnIdle { ChatViewModel::class.java.getDeclaredField("unifiedMeshService").apply { isAccessible = true }.set(vm, sink) } + } + fun send(value: String) { + field().performTextReplacement(value) + compose.onNodeWithContentDescription(context.getString(R.string.send_message)).performClick() + compose.waitUntil(5_000) { captured.contains(value) } + field().assertTextEquals("") + assertTrue(AppStateStore.publicMessages.value.any { it.content == value }) + } + withCleanup({ + // A successful wipe replaced the service; unwrap the currently installed sink. + compose.runOnIdle { + if (vm.meshServiceFacade === installedSink) { + ChatViewModel::class.java.getDeclaredField("unifiedMeshService").apply { isAccessible = true }.set(vm, delegateToRestore) + } + } + }) { + installSink(); send("SYNTHETIC_ORDINARY_SEND") + field().performTextInput("SYNTHETIC_DISCARDED") + confirmation(); val prior = ids(); confirm(); awaitOutcome(prior, R.string.system_panic_completed) + field().assertTextEquals("") + assertFalse(AppStateStore.publicMessages.value.any { it.content == "SYNTHETIC_ORDINARY_SEND" }) + installSink(); send("SYNTHETIC_FRESH_SEND") + assertEquals(listOf("SYNTHETIC_ORDINARY_SEND", "SYNTHETIC_FRESH_SEND"), captured.toList()) + } + } +} diff --git a/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicInstrumentedTest.kt b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicInstrumentedTest.kt new file mode 100644 index 000000000..c7d17f7ba --- /dev/null +++ b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicInstrumentedTest.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.ViewModelProvider +import com.bitchat.android.services.AppStateStore +import androidx.test.platform.app.InstrumentationRegistry +import com.bitchat.android.BitchatApplication +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ComposerPanicInstrumentedTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val editor = hasSetTextAction() and + SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send) + + @Test + fun confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText() { + compose.waitUntil(30_000) { + compose.onAllNodes(editor).fetchSemanticsNodes().size == 1 + } + assertTrue(context.applicationContext is BitchatApplication) + compose.onNode(editor).assertTextEquals("") + // Retain a real earlier completion so the confirmation cannot pass on a stale banner. + val previousIds = AppStateStore.publicMessages.value.map { it.id }.toSet() + val viewModel = ViewModelProvider(compose.activity)[ChatViewModel::class.java] + compose.runOnIdle { viewModel.panicClearAllData() } + awaitNewCompletion(previousIds) + compose.onNode(editor).performTextInput("SYNTHETIC_UNSENT_DRAFT") + + openPanicConfirmation() + compose.onNodeWithText(context.getString(android.R.string.cancel)).performClick() + compose.onNode(editor).assertTextEquals("SYNTHETIC_UNSENT_DRAFT") + + openPanicConfirmation() + val beforeConfirmation = AppStateStore.publicMessages.value.map { it.id }.toSet() + compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick() + awaitNewCompletion(beforeConfirmation) + compose.onNode(editor).assertTextEquals("") + compose.onNode(editor).performTextInput("SYNTHETIC_FRESH_DRAFT") + compose.onNode(editor).assertTextEquals("SYNTHETIC_FRESH_DRAFT") + } + + private fun awaitNewCompletion(previousIds: Set) { + compose.waitUntil(30_000) { + AppStateStore.publicMessages.value.any { + it.id !in previousIds && it.content == context.getString(R.string.system_panic_completed) + } + } + } + + private fun openPanicConfirmation() { + compose.mainClock.autoAdvance = false + try { + repeat(3) { + compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about)) + .performClick() + } + } finally { + compose.mainClock.autoAdvance = true + } + compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed() + } +} diff --git a/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicTestRunner.kt b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicTestRunner.kt new file mode 100644 index 000000000..6b30c4d6b --- /dev/null +++ b/app/src/composerAndroidTest/java/com/bitchat/android/ui/ComposerPanicTestRunner.kt @@ -0,0 +1,25 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner + +/** Synthetic offline configuration; preserves the real Application and ViewModel startup. */ +class ComposerPanicTestRunner : AndroidJUnitRunner() { + override fun callApplicationOnCreate(app: Application) { + check(app.packageName == "com.bitchat.droid.composerTest") { + "Panic tests require the dedicated composer test installation" + } + app.getSharedPreferences("bitchat_permissions", Context.MODE_PRIVATE).edit() + .putBoolean("first_time_onboarding_complete", true) + .putBoolean("optional_permission_requested_android.permission.POST_NOTIFICATIONS", true).commit() + app.getSharedPreferences("bitchat_debug_settings", Context.MODE_PRIVATE).edit() + .putBoolean("ble_enabled", false).putBoolean("wifi_aware_enabled", false).commit() + app.getSharedPreferences("bitchat_settings", Context.MODE_PRIVATE).edit() + .putBoolean("battery_optimization_skipped", true) + .putBoolean("background_location_skipped", true).putString("tor_mode", "OFF").commit() + app.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE).edit() + .putBoolean("location_services_enabled", false).commit() + super.callApplicationOnCreate(app) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 2a689d236..3d0d67d7c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -437,11 +437,15 @@ fun ChatScreen(viewModel: ChatViewModel) { ) }, onMentionSuggestionClick = { mention: String -> - val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text) - messageText = TextFieldValue( - text = mentionText, - selection = TextRange(mentionText.length) - ) + // A retained callback may run before visibility has recomposed. + if (viewModel.showMentionSuggestions.value && + mention in viewModel.mentionSuggestions.value) { + val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text) + messageText = TextFieldValue( + text = mentionText, + selection = TextRange(mentionText.length) + ) + } }, selectedPrivatePeer = null, currentChannel = currentChannel, @@ -571,6 +575,8 @@ fun ChatScreen(viewModel: ChatViewModel) { TextButton( onClick = { showPanicConfirmation = false + messageText = TextFieldValue("") + viewModel.clearSuggestions() viewModel.panicClearAllData() }, colors = ButtonDefaults.textButtonColors( @@ -739,7 +745,9 @@ fun ChatInputSection( MentionSuggestionsBox( suggestions = displayedMentionSuggestions, mentionPeerIdentities = mentionPeerIdentities, - onSuggestionClick = onMentionSuggestionClick, + onSuggestionClick = { nickname -> + if (mentionPickerVisible) onMentionSuggestionClick(nickname) + }, modifier = Modifier.fillMaxWidth() ) HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) diff --git a/docs/composer-panic-test.md b/docs/composer-panic-test.md new file mode 100644 index 000000000..75366477a --- /dev/null +++ b/docs/composer-panic-test.md @@ -0,0 +1,96 @@ +# Public composer panic regression + +These nine app-screen tests exercise the real application, Activity and panic-wipe confirmation. They check that confirming a wipe clears the old draft and suggestions, and that stale mention selections cannot restore text. + +## 1. Prepare a disposable emulator environment + +These tests trigger destructive cleanup. Use a fresh disposable emulator with no user data and block outbound network access. Do not use a personal device. The supplied runner creates its own emulator and refuses physical devices. + +Before starting, have: + +- An Android SDK with a rootable emulator system image already installed. +- `ANDROID_HOME` set to the local Android SDK directory. +- JDK 21 available for `avdmanager`. +- Matching app and test APKs, built as described below. +- An unused results directory and available emulator/ADB ports. + +The example command uses the API 35 Google APIs ARM image. The runner does not build APKs or download dependencies. + +## 2. Build the app and test APKs + +Run from the repository root: + +```sh +./gradlew -PcomposerUiTests=true :app:assembleDebug :app:assembleDebugAndroidTest +``` + +This property includes the composer test sources and selects the dedicated test installation, `com.bitchat.droid.composerTest`. Normal debug builds keep their usual application ID and runner. Do not combine `composerUiTests` with `releaseUiTests`. + +## 3. Run all nine app-screen tests + +After the build, run this command from the repository root. The results directory must not already exist. + +```sh +python3 tools/composer/run_regression.py \ + --sdk "$ANDROID_HOME" \ + --image 'system-images;android-35;google_apis;arm64-v8a' \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk \ + --test-apk app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + --output composer-regression-results +``` + +For another emulator ABI, select a matching installed system image and app APK. Use the test APK built with that app. + +The default emulator port is `5558`, and the private ADB server port is `5088`. If either is occupied, supply unused values with `--port` and `--adb-port`. The emulator port must be even, and the following port must also be available. The runner checks port availability before starting. + +### What the runner does + +- Creates a temporary Android Virtual Device (AVD) and deletes it during cleanup. +- Installs IPv4 and IPv6 rules that reject external network traffic before installing the app. It checks that each rejection rule is first in its OUTPUT chain before installation and after the tests. These endpoint checks do not continuously monitor rule changes or traffic. +- Turns off device Wi-Fi, mobile data and Bluetooth. +- Enables the disposable emulator's device-wide location setting for onboarding and grants the test app Bluetooth and location permissions. The test app's own location feature remains disabled. +- Keeps the screen awake, dismisses the keyguard and stops the dedicated test app before starting instrumentation. +- Runs all nine tests and requires a successful nine-test result. +- Retains emulator and instrumentation logs locally. Failure summaries include a test identifier, recognized framework exception type and test-source location when available; they omit raw assertion values and device logs. + +The test configuration also disables the app's BLE and Wi-Fi Aware features. Those app settings alone do not isolate network traffic. Use the supplied runner's setup instead of running instrumentation alone on an unprepared device. The dedicated package keeps normal app installations separate. + +## What the app-screen tests cover + +- An empty initial input; Cancel preserving a synthetic draft; confirmed wipe completing and clearing that draft; and typing afterward. +- Back dismissing confirmation without changing selected multiline Unicode text. +- Empty and repeated wipes. +- Clearing command and mention suggestions, and using fresh mentions afterward. +- Clicking mention rows during their closing animation. +- Invoking a captured mention callback immediately after confirmation, before redraw. This checks a controlled callback invocation, not the timing of an ordinary screen tap. +- Clearing the confirmed draft when conversation storage cleanup fails. +- Confirming a second wipe while the first waits for storage, then checking completion after releasing the queue. +- Using the actual Send button before and after wipe, with messages captured by a local test transport. + +The mention fixture inserts an unverified synthetic nickname into the local peer map; no real peer is involved. + +## Check that the tests detect missing protections + +Remove one protection at a time, run the affected tests, then restore it and rerun: + +1. Remove the input reset from the real panic-confirmation callback. The main regression test should fail its empty-input assertion after wipe completion. +2. Remove the suggestion-clear call. The command and mention popup checks should fail. +3. In `ChatScreen`, remove the current-state check of `viewModel.showMentionSuggestions.value` and membership in `viewModel.mentionSuggestions.value` from `onMentionSuggestionClick`, leaving its mention-selection body in place. In `exitingMentionRowsCannotRefillInput`, the empty-input assertion after invoking the captured callback before redraw should fail. Keep the separate `mentionPickerVisible` guard unchanged for this check. + +Restore every protection before running the full suite. + +## 4. Run the test-script checks + +From the repository root, run the eleven Python checks: + +```sh +python3 tools/composer/test_run_regression.py +``` + +These check result parsing, failure reporting, cleanup error handling and firewall-rule order. They do not run the nine app-screen tests and do not require an emulator. + +## Testing limits + +- This suite is opt-in. Default JVM tests and CI commands do not run it. +- The send test checks local acceptance at a capturing transport. It does not establish radio transmission or delivery to another device. +- The tests do not establish storage erasure or cancellation of outgoing transfers. diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md index b2da4e7b9..97029da6e 100644 --- a/docs/testing-conventions.md +++ b/docs/testing-conventions.md @@ -88,3 +88,9 @@ bridges, and vendored Noise code are excluded from first-party coverage metrics. - A temporary quarantine must include an issue and removal condition. - Unexpected skips fail review. Existing skips must be restored or replaced by equivalent coverage. + +## Composer UI regression + +The [composer panic regression](composer-panic-test.md) runs locally in a +disposable emulator with a separate installation and blocked external egress. +This destructive UI suite is opt-in; default JVM and CI commands do not execute it. diff --git a/tools/composer/run_regression.py b/tools/composer/run_regression.py new file mode 100644 index 000000000..87fd3f210 --- /dev/null +++ b/tools/composer/run_regression.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Run composer regressions on an owned disposable, network-blocked emulator.""" +import argparse +import os +from pathlib import Path +import re +import socket +import subprocess +import sys +import tempfile +import time + +PACKAGE = "com.bitchat.droid.composerTest" +CLASSES = ",".join("com.bitchat.android.ui." + name for name in ( + "ComposerPanicInstrumentedTest", "ComposerEdgeCasesInstrumentedTest", "ComposerOutcomeInstrumentedTest")) + + +TEST_CLASSES = {name.rsplit(".", 1)[-1] for name in CLASSES.split(",")} + + +def failure_summary(output): + """Expose test identifiers and exception types, never raw assertion values or paths.""" + details = [] + current_class = current_test = "" + for line in output.splitlines(): + if line.startswith("INSTRUMENTATION_STATUS: class="): + candidate = line.split("=", 1)[1].rsplit(".", 1)[-1] + current_class = candidate if candidate in TEST_CLASSES else "" + elif line.startswith("INSTRUMENTATION_STATUS: test="): + candidate = line.split("=", 1)[1] + current_test = candidate if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,160}", candidate) else "" + elif line.startswith("INSTRUMENTATION_STATUS_CODE: -") and current_class and current_test: + details.append(current_class + "." + current_test + " (failed or skipped)") + exception = re.match(r"(?:INSTRUMENTATION_STATUS: stack=)?((?:java|android|androidx|kotlin|org\.junit)\.[A-Za-z0-9_.$]*(?:Exception|Error|ComparisonFailure))(?::|$)", line.strip()) + if exception: details.append(exception.group(1)) + location = re.search(r"\((Composer[A-Za-z]+Test\.kt:\d+)\)", line) + if location and location.group(1).split(":", 1)[0][:-3] in TEST_CLASSES: + details.append(location.group(1)) + if not details and current_class and current_test: + details.append("last test: " + current_class + "." + current_test) + return "; ".join(dict.fromkeys(details)) or "no completed test details; inspect the local instrumentation log" + + +def require_success(output): + if not re.search(r"^OK \(9 tests\)$", output, re.MULTILINE) or any( + marker in output for marker in ("FAILURES!!!", "INSTRUMENTATION_FAILED", "Process crashed", "INSTRUMENTATION_ABORTED", "INSTRUMENTATION_STATUS_CODE: -3", "INSTRUMENTATION_STATUS_CODE: -4") + ): + raise RuntimeError("Composer instrumentation failed: " + failure_summary(output)) + + +def run_instrumentation(command, log_path): + try: + with log_path.open("w") as test_log: + subprocess.run(command, stdout=test_log, stderr=subprocess.STDOUT, check=True, timeout=600) + except subprocess.TimeoutExpired: + raise RuntimeError("Composer instrumentation timed out after 600s: " + failure_summary(log_path.read_text())) from None + except subprocess.CalledProcessError as error: + raise RuntimeError(f"Composer instrumentation exited {error.returncode}: " + failure_summary(log_path.read_text())) from None + output = log_path.read_text() + require_success(output) + return output + + +def require_first_output_reject(rules): + """Check rule order at this instant; this is not continuous traffic monitoring.""" + output_rules = [line.strip() for line in rules.splitlines() if line.startswith("-A OUTPUT ")] + expected = "-A OUTPUT ! -o lo -j REJECT" + accepted = {expected, expected + " --reject-with icmp-port-unreachable", + expected + " --reject-with icmp6-port-unreachable"} + if not output_rules or output_rules[0] not in accepted: + raise RuntimeError("External egress rejection is not the first OUTPUT rule") + + +def finish_cleanup(actions, primary_failure): + """Attempt each teardown step without replacing an active test/setup failure.""" + failures = [] + for action in actions: + try: + action() + except Exception as error: + failures.append(type(error).__name__) + if failures: + message = "Composer cleanup failed: " + ", ".join(failures) + if primary_failure is not None: + print(message, file=sys.stderr) + else: + raise RuntimeError(message) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk", type=Path, required=True) + parser.add_argument("--image", required=True, help="Installed system image package, without Play Store") + parser.add_argument("--apk", type=Path, required=True) + parser.add_argument("--test-apk", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=5558) + parser.add_argument("--adb-port", type=int, default=5088) + args = parser.parse_args() + if args.port % 2 or not 5554 <= args.port <= 65534: + parser.error("Use an even emulator port between 5554 and 65534") + for apk in (args.apk, args.test_apk): + if not apk.is_file(): parser.error("Build both matching APKs before starting the emulator") + for port in (args.port, args.port + 1, args.adb_port): + with socket.socket() as probe: + probe.bind(("127.0.0.1", port)) + args.output.mkdir(parents=True, exist_ok=False) + adb = [str(args.sdk / "platform-tools/adb"), "-P", str(args.adb_port), "-s", f"emulator-{args.port}"] + def call(*items, timeout=60): + return subprocess.check_output(adb + list(items), stderr=subprocess.STDOUT, text=True, timeout=timeout).strip() + with tempfile.TemporaryDirectory(prefix="composer-regression-") as scratch: + env = dict(os.environ, ANDROID_AVD_HOME=scratch, ANDROID_ADB_SERVER_PORT=str(args.adb_port)) + manager = args.sdk / "cmdline-tools/latest/bin/avdmanager" + subprocess.run([str(manager), "create", "avd", "-n", "composer_regression", "-k", args.image, + "-p", str(Path(scratch) / "device.avd")], input="no\n", text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True, env=env, timeout=90) + with (args.output / "emulator.log").open("w") as log: + process = subprocess.Popen([str(args.sdk / "emulator/emulator"), "-avd", "composer_regression", + "-port", str(args.port), "-no-window", "-no-audio", "-no-snapshot", "-no-boot-anim", + "-gpu", "swiftshader_indirect", "-memory", "2048", "-no-metrics", "-crash-report-mode", "disabled"], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 180 + while True: + if process.poll() is not None: raise RuntimeError("Owned emulator exited during startup") + try: + if call("shell", "getprop", "sys.boot_completed", timeout=5) == "1": break + except (subprocess.SubprocessError, OSError): pass + if time.monotonic() >= deadline: raise TimeoutError("Owned emulator did not boot") + time.sleep(1) + if call("shell", "getprop", "ro.kernel.qemu") != "1": raise RuntimeError("Refusing a physical device") + call("root"); call("wait-for-device") + if call("shell", "id", "-u") != "0": raise RuntimeError("A rootable disposable image is required") + def verify_firewalls(stage): + for firewall in ("iptables", "ip6tables"): + rules = call("shell", firewall, "-S", "OUTPUT") + (args.output / f"{firewall}-{stage}.log").write_text(rules) + require_first_output_reject(rules) + + # Block external egress before installing or starting either package. + for firewall in ("iptables", "ip6tables"): + call("shell", firewall, "-I", "OUTPUT", "1", "!", "-o", "lo", "-j", "REJECT") + verify_firewalls("before") + call("shell", "svc", "wifi", "disable") + call("shell", "svc", "data", "disable") + call("shell", "cmd", "bluetooth_manager", "disable") + call("shell", "cmd", "location", "set-location-enabled", "true") + for apk in (args.apk, args.test_apk): call("install", "-r", str(apk.resolve()), timeout=90) + for permission in ("BLUETOOTH_SCAN", "BLUETOOTH_CONNECT", "BLUETOOTH_ADVERTISE", "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"): + call("shell", "pm", "grant", PACKAGE, "android.permission." + permission) + call("shell", "settings", "put", "system", "screen_off_timeout", "2147483647") + call("shell", "input", "keyevent", "KEYCODE_WAKEUP") + call("shell", "wm", "dismiss-keyguard") + call("shell", "am", "force-stop", PACKAGE) + output = run_instrumentation(adb + ["shell", "am", "instrument", "-w", "-r", "-e", "class", CLASSES, + PACKAGE + ".test/com.bitchat.android.ui.ComposerPanicTestRunner"], args.output / "instrumentation.log") + verify_firewalls("after") + print("Composer regression: 9 tests passed; outbound reject rule first before and after tests") + finally: + primary_failure = sys.exc_info()[1] + def stop_emulator(): + try: call("emu", "kill", timeout=10) + except (subprocess.SubprocessError, OSError): pass + try: process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.terminate() + try: process.wait(timeout=10) + except subprocess.TimeoutExpired: process.kill(); process.wait(timeout=10) + def stop_adb(): + subprocess.run([str(args.sdk / "platform-tools/adb"), "-P", str(args.adb_port), "kill-server"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, check=True) + finish_cleanup((stop_emulator, stop_adb), primary_failure) + + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + print(str(error), file=sys.stderr) + raise SystemExit(1) + except (OSError, subprocess.SubprocessError) as error: + # Keep local paths, subprocess arguments and raw device output out of CI output. + print("Composer setup or cleanup failed: " + type(error).__name__, file=sys.stderr) + raise SystemExit(1) diff --git a/tools/composer/test_run_regression.py b/tools/composer/test_run_regression.py new file mode 100644 index 000000000..695e99321 --- /dev/null +++ b/tools/composer/test_run_regression.py @@ -0,0 +1,121 @@ +import contextlib +import io +import importlib.util +from pathlib import Path +import unittest +from unittest import mock +import subprocess +import sys +import tempfile + +spec = importlib.util.spec_from_file_location("runner", Path(__file__).with_name("run_regression.py")) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +class InstrumentationOutcomeTest(unittest.TestCase): + def test_first_output_reject_passes(self): + for suffix in ("", " --reject-with icmp-port-unreachable", " --reject-with icmp6-port-unreachable"): + with self.subTest(suffix=suffix): + runner.require_first_output_reject( + "-P OUTPUT ACCEPT\n-A OUTPUT ! -o lo -j REJECT" + suffix + "\n-A OUTPUT -j ACCEPT\n") + + def test_missing_or_displaced_output_reject_fails(self): + for rules in ("-P OUTPUT ACCEPT\n", "-A OUTPUT -j ACCEPT\n-A OUTPUT ! -o lo -j REJECT\n", + "-A OUTPUT ! -o lo -j ACCEPT\n"): + with self.subTest(rules=rules), self.assertRaises(RuntimeError): + runner.require_first_output_reject(rules) + + def test_cleanup_timeout_preserves_primary_and_attempts_remaining_actions(self): + primary = RuntimeError("synthetic instrumentation failure") + actions = [] + def fail(): + actions.append("first") + raise subprocess.TimeoutExpired("SYNTHETIC_PRIVATE_COMMAND", 10) + captured = io.StringIO() + with contextlib.redirect_stderr(captured), self.assertRaises(RuntimeError) as caught: + try: + raise primary + finally: + runner.finish_cleanup((fail, lambda: actions.append("second")), sys.exc_info()[1]) + self.assertIs(caught.exception, primary) + self.assertEqual(actions, ["first", "second"]) + self.assertIn("TimeoutExpired", captured.getvalue()) + self.assertNotIn("SYNTHETIC_PRIVATE_COMMAND", captured.getvalue()) + + def test_cleanup_failure_after_success_is_not_silenced(self): + def fail(): + raise subprocess.TimeoutExpired("SYNTHETIC_PRIVATE_COMMAND", 10) + with self.assertRaisesRegex(RuntimeError, "Composer cleanup failed: TimeoutExpired"): + runner.finish_cleanup((fail,), None) + runner.finish_cleanup((lambda: None,), None) + + def test_complete_run_passes(self): + runner.require_success("Time: 1\nOK (9 tests)\n") + + def test_short_empty_crashed_or_failed_runs_reject(self): + for output in ("", "OK (1 test)", "OK (8 tests)", "OK (9 tests)\nFAILURES!!!", + "OK (9 tests)\nINSTRUMENTATION_FAILED", "OK (9 tests)\nProcess crashed", + "OK (9 tests)\nINSTRUMENTATION_ABORTED", + "OK (9 tests)\nINSTRUMENTATION_STATUS_CODE: -3", + "OK (9 tests)\nINSTRUMENTATION_STATUS_CODE: -4"): + with self.subTest(output=output), self.assertRaises(RuntimeError): runner.require_success(output) + + def test_failure_summary_names_test_without_raw_assertion_or_path(self): + output = self.failure_output() + with self.assertRaises(RuntimeError) as caught: + runner.require_success(output) + message = str(caught.exception) + self.assertIn("ComposerPanicInstrumentedTest.confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText", message) + self.assertIn("java.lang.AssertionError", message) + self.assertIn("ComposerPanicInstrumentedTest.kt:54", message) + self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", message) + self.assertNotIn("/synthetic/private", message) + + @staticmethod + def failure_output(): + return ("INSTRUMENTATION_STATUS: class=com.bitchat.android.ui.ComposerPanicInstrumentedTest\n" + "INSTRUMENTATION_STATUS: test=confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText\n" + "INSTRUMENTATION_STATUS: stack=java.lang.AssertionError: SYNTHETIC_PRIVATE_VALUE /synthetic/private\n" + " at synthetic.test(ComposerPanicInstrumentedTest.kt:54)\n" + "INSTRUMENTATION_STATUS_CODE: -2\nFAILURES!!!\n") + + def test_timeout_and_nonzero_exit_keep_concise_details_and_raw_local_log(self): + for error in (subprocess.TimeoutExpired("synthetic-command", 600), + subprocess.CalledProcessError(1, "synthetic-command")): + with self.subTest(error=type(error).__name__), tempfile.TemporaryDirectory() as scratch: + log = Path(scratch) / "instrumentation.log" + def fail(*args, **kwargs): + kwargs["stdout"].write(self.failure_output()) + kwargs["stdout"].flush() + raise error + with mock.patch.object(runner.subprocess, "run", side_effect=fail), self.assertRaises(RuntimeError) as caught: + runner.run_instrumentation(["synthetic-command"], log) + self.assertIn("ComposerPanicInstrumentedTest", str(caught.exception)) + self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", str(caught.exception)) + self.assertIn("SYNTHETIC_PRIVATE_VALUE", log.read_text()) + + def test_interrupted_run_identifies_last_started_test(self): + output = ("INSTRUMENTATION_STATUS: class=com.bitchat.android.ui.ComposerPanicInstrumentedTest\n" + "INSTRUMENTATION_STATUS: test=confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText\n") + self.assertIn("last test: ComposerPanicInstrumentedTest.", runner.failure_summary(output)) + + def test_real_subprocess_failure_reports_test_without_raw_values(self): + with tempfile.TemporaryDirectory() as scratch: + command = [sys.executable, "-c", "import sys; print(" + repr(self.failure_output()) + "); sys.exit(1)"] + with self.assertRaises(RuntimeError) as caught: + runner.run_instrumentation(command, Path(scratch) / "instrumentation.log") + self.assertIn("exited 1", str(caught.exception)) + self.assertIn("ComposerPanicInstrumentedTest", str(caught.exception)) + self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", str(caught.exception)) + + def test_junit_comparison_failure_type_without_expected_or_actual_values(self): + output = self.failure_output().replace("java.lang.AssertionError: SYNTHETIC_PRIVATE_VALUE /synthetic/private", + "org.junit.ComparisonFailure: expected: but was:") + summary = runner.failure_summary(output) + self.assertIn("org.junit.ComparisonFailure", summary) + self.assertNotIn("SYNTHETIC_EXPECTED", summary) + self.assertNotIn("SYNTHETIC_ACTUAL", summary) + + +if __name__ == "__main__": unittest.main()