Last active
April 17, 2018 01:24
-
-
Save null-dev/cd697826055925c2d24f17b752764162 to your computer and use it in GitHub Desktop.
Automated Tachiyomi source tester
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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