Skip to content

Instantly share code, notes, and snippets.

@null-dev
Last active April 17, 2018 01:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save null-dev/cd697826055925c2d24f17b752764162 to your computer and use it in GitHub Desktop.
Save null-dev/cd697826055925c2d24f17b752764162 to your computer and use it in GitHub Desktop.
Automated Tachiyomi source tester
import android.content.Context
import android.graphics.Color
import android.preference.PreferenceManager
import android.support.design.widget.TabLayout
import android.support.test.InstrumentationRegistry
import android.support.test.espresso.Espresso
import android.support.test.espresso.Espresso.onIdle
import android.support.test.espresso.Espresso.onView
import android.support.test.espresso.UiController
import android.support.test.espresso.ViewAction
import android.support.test.espresso.action.ViewActions.click
import android.support.test.espresso.action.ViewActions.typeText
import android.support.test.espresso.assertion.ViewAssertions.matches
import android.support.test.espresso.contrib.DrawerActions
import android.support.test.espresso.contrib.NavigationViewActions
import android.support.test.espresso.contrib.RecyclerViewActions
import android.support.test.espresso.matcher.ViewMatchers.*
import android.support.test.filters.LargeTest
import android.support.test.rule.ActivityTestRule
import android.support.test.runner.AndroidJUnit4
import android.support.v7.widget.RecyclerView
import android.support.v7.widget.Toolbar
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.AutoCompleteTextView
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.ui.main.MainActivity
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.TypeSafeMatcher
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* To use, add the following lines to the `dependencies` section of the app's `build.gradle`:
* ```
* androidTestImplementation 'com.android.support.test:runner:1.0.1'
* androidTestUtil 'com.android.support.test:orchestrator:1.0.1'
* androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
* androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.1'
* ```
*
* Place this file in: `app/src/androidTest/java/TachiTest.kt` (make new folders if you need to)
*
* Finally edit the `source` variable below to hold the name of the source you want to test (case-sensitive).
*
* Non-critical test errors can be found in the logcat with the tag "<<<TACHI-TEST>>>". Critical test errors can be found directly in the test console
*
* It also recommended that you disable animations and run the following adb command on your device before you begin:
* ```
* adb shell settings put secure long_press_timeout 2000
* ```
*/
@RunWith(value = AndroidJUnit4::class)
@LargeTest
class TachiTest {
private val TAG = "<<<TACHI-TEST>>>"
// Edit this field with the name of the source you want to test
private val source = "Sea Otter Scans"
private val freezeTestOnFailure = false
// Test timeout represented in chunks of 100 ms. Example: 10 timeout units = 1000ms
private val testTimeout = 150
@Rule
@JvmField
val mActivityRule = ActivityTestRule(MainActivity::class.java, false, false)
// Limit a matcher to the first item found
private fun <T> first(matcher: Matcher<T>): Matcher<T> {
return object : BaseMatcher<T>() {
internal var isFirst = true
override fun matches(item: Any): Boolean {
if (isFirst && matcher.matches(item)) {
isFirst = false
return true
}
return false
}
override fun describeTo(description: Description) {
description.appendText("should return first matching item")
}
}
}
// Matcher to find the nth child of a RecyclerView
private fun nthChildOf(parentMatcher: Matcher<View>, childPosition: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with $childPosition child view of type parentMatcher")
}
override fun matchesSafely(view: View): Boolean {
if (view.parent !is ViewGroup) {
return parentMatcher.matches(view.parent)
}
val group = view.parent as ViewGroup
return parentMatcher.matches(view.parent) && group.getChildAt(childPosition) == view
}
}
}
// Get the view matched by a matcher
fun <T : View> Matcher<T>.get(): T? {
var result: T? = null
try {
onView(this as Matcher<View>).check { view, _ ->
result = view as T?
}
} catch(t: Throwable) {
return null
}
return result
}
fun clickChildViewWithId(id: Int)
= object : ViewAction {
override fun getConstraints() = null
override fun getDescription()
= "Click on a child view with specified id."
override fun perform(uiController: UiController, view: View) {
val v = view.findViewById<View>(id)
v.performClick()
}
}
// The actual test itself
@Test
fun testSource() {
// Reset app preferences
val root = InstrumentationRegistry.getTargetContext().filesDir.parentFile
File(root, "shared_prefs").list()?.forEach {
InstrumentationRegistry.getTargetContext()
.getSharedPreferences(it.replace(".xml", ""), Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
// Enable all languages
PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getTargetContext())
.edit()
.putStringSet(PreferenceKeys.enabledLanguages, setOf(
"aa", "ab", "ac", "ad", "ae", "af", "ag", "ah", "ai", "aj", "ak", "al", "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay", "az",
"ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl", "bm", "bn", "bo", "bp", "bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by", "bz",
"ca", "cb", "cc", "cd", "ce", "cf", "cg", "ch", "ci", "cj", "ck", "cl", "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv", "cw", "cx", "cy", "cz",
"da", "db", "dc", "dd", "de", "df", "dg", "dh", "di", "dj", "dk", "dl", "dm", "dn", "do", "dp", "dq", "dr", "ds", "dt", "du", "dv", "dw", "dx", "dy", "dz",
"ea", "eb", "ec", "ed", "ee", "ef", "eg", "eh", "ei", "ej", "ek", "el", "em", "en", "eo", "ep", "eq", "er", "es", "et", "eu", "ev", "ew", "ex", "ey", "ez",
"fa", "fb", "fc", "fd", "fe", "ff", "fg", "fh", "fi", "fj", "fk", "fl", "fm", "fn", "fo", "fp", "fq", "fr", "fs", "ft", "fu", "fv", "fw", "fx", "fy", "fz",
"ga", "gb", "gc", "gd", "ge", "gf", "gg", "gh", "gi", "gj", "gk", "gl", "gm", "gn", "go", "gp", "gq", "gr", "gs", "gt", "gu", "gv", "gw", "gx", "gy", "gz",
"ha", "hb", "hc", "hd", "he", "hf", "hg", "hh", "hi", "hj", "hk", "hl", "hm", "hn", "ho", "hp", "hq", "hr", "hs", "ht", "hu", "hv", "hw", "hx", "hy", "hz",
"ia", "ib", "ic", "id", "ie", "if", "ig", "ih", "ii", "ij", "ik", "il", "im", "in", "io", "ip", "iq", "ir", "is", "it", "iu", "iv", "iw", "ix", "iy", "iz",
"ja", "jb", "jc", "jd", "je", "jf", "jg", "jh", "ji", "jj", "jk", "jl", "jm", "jn", "jo", "jp", "jq", "jr", "js", "jt", "ju", "jv", "jw", "jx", "jy", "jz",
"ka", "kb", "kc", "kd", "ke", "kf", "kg", "kh", "ki", "kj", "kk", "kl", "km", "kn", "ko", "kp", "kq", "kr", "ks", "kt", "ku", "kv", "kw", "kx", "ky", "kz",
"la", "lb", "lc", "ld", "le", "lf", "lg", "lh", "li", "lj", "lk", "ll", "lm", "ln", "lo", "lp", "lq", "lr", "ls", "lt", "lu", "lv", "lw", "lx", "ly", "lz",
"ma", "mb", "mc", "md", "me", "mf", "mg", "mh", "mi", "mj", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz",
"na", "nb", "nc", "nd", "ne", "nf", "ng", "nh", "ni", "nj", "nk", "nl", "nm", "nn", "no", "np", "nq", "nr", "ns", "nt", "nu", "nv", "nw", "nx", "ny", "nz",
"oa", "ob", "oc", "od", "oe", "of", "og", "oh", "oi", "oj", "ok", "ol", "om", "on", "oo", "op", "oq", "or", "os", "ot", "ou", "ov", "ow", "ox", "oy", "oz",
"pa", "pb", "pc", "pd", "pe", "pf", "pg", "ph", "pi", "pj", "pk", "pl", "pm", "pn", "po", "pp", "pq", "pr", "ps", "pt", "pu", "pv", "pw", "px", "py", "pz",
"qa", "qb", "qc", "qd", "qe", "qf", "qg", "qh", "qi", "qj", "qk", "ql", "qm", "qn", "qo", "qp", "qq", "qr", "qs", "qt", "qu", "qv", "qw", "qx", "qy", "qz",
"ra", "rb", "rc", "rd", "re", "rf", "rg", "rh", "ri", "rj", "rk", "rl", "rm", "rn", "ro", "rp", "rq", "rr", "rs", "rt", "ru", "rv", "rw", "rx", "ry", "rz",
"sa", "sb", "sc", "sd", "se", "sf", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sp", "sq", "sr", "ss", "st", "su", "sv", "sw", "sx", "sy", "sz",
"ta", "tb", "tc", "td", "te", "tf", "tg", "th", "ti", "tj", "tk", "tl", "tm", "tn", "to", "tp", "tq", "tr", "ts", "tt", "tu", "tv", "tw", "tx", "ty", "tz",
"ua", "ub", "uc", "ud", "ue", "uf", "ug", "uh", "ui", "uj", "uk", "ul", "um", "un", "uo", "up", "uq", "ur", "us", "ut", "uu", "uv", "uw", "ux", "uy", "uz",
"va", "vb", "vc", "vd", "ve", "vf", "vg", "vh", "vi", "vj", "vk", "vl", "vm", "vn", "vo", "vp", "vq", "vr", "vs", "vt", "vu", "vv", "vw", "vx", "vy", "vz",
"wa", "wb", "wc", "wd", "we", "wf", "wg", "wh", "wi", "wj", "wk", "wl", "wm", "wn", "wo", "wp", "wq", "wr", "ws", "wt", "wu", "wv", "ww", "wx", "wy", "wz",
"xa", "xb", "xc", "xd", "xe", "xf", "xg", "xh", "xi", "xj", "xk", "xl", "xm", "xn", "xo", "xp", "xq", "xr", "xs", "xt", "xu", "xv", "xw", "xx", "xy", "xz",
"ya", "yb", "yc", "yd", "ye", "yf", "yg", "yh", "yi", "yj", "yk", "yl", "ym", "yn", "yo", "yp", "yq", "yr", "ys", "yt", "yu", "yv", "yw", "yx", "yy", "yz",
"za", "zb", "zc", "zd", "ze", "zf", "zg", "zh", "zi", "zj", "zk", "zl", "zm", "zn", "zo", "zp", "zq", "zr", "zs", "zt", "zu", "zv", "zw", "zx", "zy", "zz"))
.commit()
// Clear files
val filesToClear = mutableListOf<File>()
InstrumentationRegistry.getTargetContext().filesDir.listFiles()?.let { filesToClear += it }
InstrumentationRegistry.getTargetContext().cacheDir.listFiles()?.let { filesToClear += it }
File(root, "databases").listFiles()?.let { filesToClear += it }
filesToClear.forEach { it.deleteRecursively() }
mActivityRule.launchActivity(null)
Log.d(TAG, "========== TESTING: $source ==========")
// Open nav drawer
onView(withId(R.id.drawer))
.perform(DrawerActions.open())
// Go to catalogues
onView(withId(R.id.nav_view))
.perform(NavigationViewActions.navigateTo(R.id.nav_drawer_catalogues))
// Ensure we are on catalogues screen
onView(allOf(isAssignableFrom(TextView::class.java), withParent(isAssignableFrom(Toolbar::class.java))))
.check(matches(withText(R.string.label_catalogues)))
fun catalogueItemButton(id: Int) = first(allOf(withParent(allOf(withId(R.id.card),
withChild(allOf(withId(R.id.title),
withText(source))))), withId(id)))
// The next click does not work reliably without sleeping bit for a sec (no idea why...)
Thread.sleep(500)
// Test latest catalogue
onView(withId(R.id.recycler))
.perform(RecyclerViewActions
.actionOnItem<RecyclerView.ViewHolder>(hasDescendant(withText(source)),
clickChildViewWithId(R.id.source_latest)))
testCatalogueScreen()
Espresso.pressBack()
// Test browse catalogue
// Now we use normal click as it's the most recent source but we must scroll to top first
onView(withId(R.id.recycler)).perform(RecyclerViewActions
.scrollToPosition<RecyclerView.ViewHolder>(0))
onView(catalogueItemButton(R.id.source_browse)).perform(click())
testCatalogueScreen()
// Search for the most common letter in English language: 'e'
onView(withId(R.id.action_search)).perform(click())
onView(allOf(withResourceName("search_src_text"),
isAssignableFrom(AutoCompleteTextView::class.java))).perform(typeText("e"))
onIdle()
// Give 3s for search debounce handler to trigger
Thread.sleep(3000)
// Wait for results
testCatalogueScreen()
Espresso.pressBack()
}
fun redLongToast(message: String) {
mActivityRule.runOnUiThread {
val toast = Toast.makeText(InstrumentationRegistry.getTargetContext(),
"TEST: $message",
Toast.LENGTH_LONG)
toast.view.setBackgroundColor(Color.RED)
toast.show()
}
}
/**
* Wait 10s for the block to return true
*
* Warns through logcat if block did not return true
*
* @return Whether or not the block returned true
*/
fun assertLoaded(item: String, block: () -> Boolean): Boolean {
var broke = false
for(i in 1 .. testTimeout) {
if(block()) {
broke = true
break
}
Thread.sleep(100)
}
if(!broke) {
val message = "$item did not load after 10s!"
Log.w(TAG, message)
redLongToast(message)
if(freezeTestOnFailure) {
Log.w(TAG, "Freezing test as test failed!")
while(true) {
redLongToast("Test frozen!")
Thread.sleep(3500)
}
}
}
return broke
}
// Test a catalogue screen
fun testCatalogueScreen() {
// Wait for main progress bar to disappear
onIdle()
// Click on first six items in recycler view
val catalogueRecyler = withId(R.id.catalogue_grid)
// Try first 4 manga
for(i in 0 .. 3) {
if(!assertLoaded("Manga #$i in catalogue screen") {
nthChildOf(catalogueRecyler, i).get() != null
}) {
break
}
// Wait for imageView to load
assertLoaded("Catalogue thumbnail for manga") {
val item = nthChildOf(catalogueRecyler, i).get()?.findViewById<ImageView>(R.id.thumbnail)
item?.drawable != null
}
// Open manga
onView(nthChildOf(catalogueRecyler, i)).perform(click())
testManga()
// Close manga
Espresso.pressBack()
}
}
// Function to grab a reference to the private TabView class
fun tabViewClass() = TabLayout::class.java.declaredClasses.find { it.simpleName == "TabView" } as Class<out View>
// Test a single manga
fun testManga() {
// Wait for thumbnail
assertLoaded("Thumbnail for manga") {
val cover = mActivityRule.activity.findViewById<ImageView>(R.id.manga_cover)
cover?.drawable != null
}
run {
// Wait for description
if(!assertLoaded("Description for manga") {
(mActivityRule
.activity
.findViewById<TextView>(R.id.manga_summary)?.text
?: "").isNotBlank()
}) return@run
// Wait for artist
if(!assertLoaded("Artist for manga") {
(mActivityRule
.activity
.findViewById<TextView>(R.id.manga_artist)?.text
?: "").isNotBlank()
}) return@run
}
// Go to chapter tab
onView(allOf(withParent(isAssignableFrom(tabViewClass())),
withText(R.string.chapters))).perform(click())
// Read most recent chapter
fun chap() = nthChildOf(withId(R.id.recycler), 0)
// Wait for first chapter
if(!assertLoaded("Manga chapters", {
chap().get() != null
})) {
return
}
// Enter reader
onView(chap()).perform(click())
// Wait for first image to load
assertLoaded("First manga page") {
((first(withId(R.id.image_view)).get()
as? SubsamplingScaleImageView)?.hasImage() ?: false)
}
// Exit reader
Espresso.pressBack()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment