From f1d4235ba788f638a35614121f28b2f275c98bfd Mon Sep 17 00:00:00 2001 From: Brian Nicholson Date: Fri, 15 Nov 2013 22:59:17 -0800 Subject: [PATCH] Bug 874985 - Part 3: Session restore tests. r=gbrown --- mobile/android/base/tests/BaseTest.java | 121 +++++- mobile/android/base/tests/SessionTest.java | 404 ++++++++++++++++++ mobile/android/base/tests/robocop.ini | 2 + mobile/android/base/tests/robocop_dynamic.sjs | 18 + .../base/tests/testSessionOOMRestore.java | 56 +++ .../base/tests/testSessionOOMSave.java | 83 ++++ 6 files changed, 672 insertions(+), 12 deletions(-) create mode 100644 mobile/android/base/tests/SessionTest.java create mode 100644 mobile/android/base/tests/robocop_dynamic.sjs create mode 100644 mobile/android/base/tests/testSessionOOMRestore.java create mode 100644 mobile/android/base/tests/testSessionOOMSave.java diff --git a/mobile/android/base/tests/BaseTest.java b/mobile/android/base/tests/BaseTest.java index c3aae3610cd..b9cac6e7578 100644 --- a/mobile/android/base/tests/BaseTest.java +++ b/mobile/android/base/tests/BaseTest.java @@ -25,14 +25,18 @@ import android.util.DisplayMetrics; import android.view.inputmethod.InputMethodManager; import android.view.KeyEvent; import android.view.View; +import android.widget.AdapterView; import android.widget.Button; import android.widget.EditText; import android.widget.ListAdapter; import android.widget.ListView; import android.widget.TextView; + import java.io.File; import java.io.InputStream; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; @@ -110,6 +114,17 @@ abstract class BaseTest extends ActivityInstrumentationTestCase2 { String rootPath = FennecInstrumentationTestRunner.getFennecArguments().getString("deviceroot"); String configFile = FennecNativeDriver.getFile(rootPath + "/robotium.config"); HashMap config = FennecNativeDriver.convertTextToTable(configFile); + mLogFile = (String)config.get("logfile"); + mBaseUrl = ((String)config.get("host")).replaceAll("(/$)", ""); + mRawBaseUrl = ((String)config.get("rawhost")).replaceAll("(/$)", ""); + // Initialize the asserter + if (getTestType() == TEST_TALOS) { + mAsserter = new FennecTalosAssert(); + } else { + mAsserter = new FennecMochitestAssert(); + } + mAsserter.setLogFile(mLogFile); + mAsserter.setTestName(this.getClass().getName()); // Create the intent to be used with all the important arguments. Intent i = new Intent(Intent.ACTION_MAIN); mProfile = (String)config.get("profile"); @@ -124,17 +139,6 @@ abstract class BaseTest extends ActivityInstrumentationTestCase2 { // Start the activity setActivityIntent(i); mActivity = getActivity(); - mLogFile = (String)config.get("logfile"); - mBaseUrl = ((String)config.get("host")).replaceAll("(/$)", ""); - mRawBaseUrl = ((String)config.get("rawhost")).replaceAll("(/$)", ""); - // Initialize the asserter - if (getTestType() == TEST_TALOS) { - mAsserter = new FennecTalosAssert(); - } else { - mAsserter = new FennecMochitestAssert(); - } - mAsserter.setLogFile(mLogFile); - mAsserter.setTestName(this.getClass().getName()); // Set up Robotium.solo and Driver objects mSolo = new Solo(getInstrumentation(), mActivity); mDriver = new FennecNativeDriver(mActivity, mSolo, rootPath); @@ -548,7 +552,7 @@ abstract class BaseTest extends ActivityInstrumentationTestCase2 { imm.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0); } - public void addTab(String url) { + public void addTab() { mSolo.clickOnView(mSolo.getView("tabs")); // wait for addTab to appear (this is usually immediate) boolean success = waitForCondition(new Condition() { @@ -564,11 +568,92 @@ abstract class BaseTest extends ActivityInstrumentationTestCase2 { mAsserter.ok(success, "waiting for add tab view", "add tab view available"); final View addTabView = mSolo.getView("add_tab"); mSolo.clickOnView(mSolo.getView("add_tab")); + } + + public void addTab(String url) { + addTab(); // Adding a new tab opens about:home, so now we just need to load the url in it. inputAndLoadUrl(url); } + /** + * Gets the AdapterView of the tabs list. + * + * @return List view in the tabs tray + */ + private final AdapterView getTabsList() { + Element tabs = mDriver.findElement(getActivity(), "tabs"); + tabs.click(); + Element listElem = mDriver.findElement(getActivity(), "normal_tabs"); + int listId = listElem.getId(); + return (AdapterView) getActivity().findViewById(listId); + } + + /** + * Gets the view in the tabs tray at the specified index. + * + * @return View at index + */ + private View getTabViewAt(final int index) { + final View[] childView = { null }; + + final AdapterView view = getTabsList(); + + runOnUiThreadSync(new Runnable() { + @Override + public void run() { + view.setSelection(index); + + // The selection isn't updated synchronously; posting a + // runnable to the view's queue guarantees we'll run after the + // layout pass. + view.post(new Runnable() { + @Override + public void run() { + // getChildAt() is relative to the list of visible + // views, but our index is relative to all views in the + // list. Subtract the first visible list position for + // the correct offset. + childView[0] = view.getChildAt(index - view.getFirstVisiblePosition()); + } + }); + } + }); + + boolean result = waitForCondition(new Condition() { + @Override + public boolean isSatisfied() { + return childView[0] != null; + } + }, MAX_WAIT_MS); + + mAsserter.ok(result, "list item at index " + index + " exists", null); + + return childView[0]; + } + + /** + * Selects the tab at the specified index. + * + * @param index Index of tab to select + */ + public void selectTabAt(final int index) { + mSolo.clickOnView(getTabViewAt(index)); + } + + /** + * Closes the tab at the specified index. + * + * @param index Index of tab to close + */ + public void closeTabAt(final int index) { + Element close = mDriver.findElement(getActivity(), "close"); + View closeButton = getTabViewAt(index).findViewById(close.getId()); + + mSolo.clickOnView(closeButton); + } + public final void runOnUiThreadSync(Runnable runnable) { RobocopUtils.runOnUiThreadSync(mActivity, runnable); } @@ -752,4 +837,16 @@ abstract class BaseTest extends ActivityInstrumentationTestCase2 { } } } + + /** + * Gets the string representation of a stack trace. + * + * @param t Throwable to get stack trace for + * @return Stack trace as a string + */ + public static String getStackTraceString(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/mobile/android/base/tests/SessionTest.java b/mobile/android/base/tests/SessionTest.java new file mode 100644 index 00000000000..53e27def39c --- /dev/null +++ b/mobile/android/base/tests/SessionTest.java @@ -0,0 +1,404 @@ +package org.mozilla.gecko.tests; + +import org.mozilla.gecko.*; + +import java.io.IOException; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public abstract class SessionTest extends BaseTest { + private File mSessionDir; + protected Navigation mNavigation; + + @Override + final protected int getTestType() { + return TEST_MOCHITEST; + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + mNavigation = new Navigation(mDevice); + } + + /** + * A generic session object representing a collection of items that has a + * selected index. + */ + protected abstract class SessionObject { + private final int mIndex; + private final T[] mItems; + + public SessionObject(int index, T... items) { + mIndex = index; + mItems = items; + } + + public int getIndex() { + return mIndex; + } + + public T[] getItems() { + return mItems; + } + } + + protected class PageInfo { + private String url; + private String title; + + public PageInfo(String key) { + if (key.startsWith("about:")) { + url = key; + } else { + url = getPage(key); + } + title = key; + } + } + + protected class SessionTab extends SessionObject { + public SessionTab(int index, PageInfo... items) { + super(index, items); + } + } + + protected class Session extends SessionObject { + public Session(int index, SessionTab... items) { + super(index, items); + } + } + + /** + * Walker for visiting items in a browser-like navigation order. + */ + protected abstract class NavigationWalker { + private final T[] mItems; + private final int mIndex; + + public NavigationWalker(SessionObject obj) { + mItems = obj.getItems(); + mIndex = obj.getIndex(); + } + + /** + * Walks over the list of items, calling the onItem() callback for each. + * + * The selected item is the first item visited. Each item after the + * selected item is then visited in ascending index order. Finally, the + * list is iterated in reverse, and each item before the selected item + * is visited in descending index order. + */ + public void walk() { + onItem(mItems[mIndex], mIndex); + for (int i = mIndex + 1; i < mItems.length; i++) { + goForward(); + onItem(mItems[i], i); + } + if (mIndex > 0) { + for (int i = mItems.length - 2; i >= 0; i--) { + goBack(); + if (i < mIndex) { + onItem(mItems[i], i); + } + } + } + } + + /** + * Callback when an item is visited during a walk. + * + * Only one callback is executed per item. + */ + public abstract void onItem(T item, int currentIndex); + + /** + * Callback executed for each back step of the walk. + */ + public void goBack() {} + + /** + * Callback executed for each forward step of the walk. + */ + public void goForward() {} + } + + /** + * Loads a set of tabs in the browser specified by the given session. + * + * @param session Session to load + */ + protected void loadSessionTabs(Session session) { + // Verify initial about:home tab + verifyTabCount(1); + verifyUrl("about:home"); + + SessionTab[] tabs = session.getItems(); + for (int i = 0; i < tabs.length; i++) { + final SessionTab tab = tabs[i]; + final PageInfo[] pages = tab.getItems(); + + // New tabs always start with about:home, so make sure about:home + // is always the first entry. + mAsserter.is(pages[0].url, "about:home", "first page in tab is about:home"); + + // If this is the first tab, the tab already exists, so no need to + // create a new one. Otherwise, create a new tab if we're loading + // the first the first page in the set. + if (i > 0) { + Actions.EventExpecter pageShowExpecter = mActions.expectGeckoEvent("Content:PageShow"); + addTab(); + pageShowExpecter.blockForEvent(); + pageShowExpecter.unregisterListener(); + } + + for (int j = 1; j < pages.length; j++) { + Actions.EventExpecter pageShowExpecter = mActions.expectGeckoEvent("Content:PageShow"); + + loadUrl(pages[j].url); + + pageShowExpecter.blockForEvent(); + pageShowExpecter.unregisterListener(); + } + + final int index = tab.getIndex(); + for (int j = pages.length - 1; j > index; j--) { + mNavigation.back(); + } + } + + selectTabAt(session.getIndex()); + } + + /** + * Verifies that the set of open tabs matches the given session. + * + * @param session Session to verify + */ + protected void verifySessionTabs(Session session) { + verifyTabCount(session.getItems().length); + + (new NavigationWalker(session) { + boolean mFirstTabVisited; + + @Override + public void onItem(SessionTab tab, int currentIndex) { + // The first tab to check should already be selected at startup + if (mFirstTabVisited) { + selectTabAt(currentIndex); + } else { + mFirstTabVisited = true; + } + + (new NavigationWalker(tab) { + @Override + public void onItem(PageInfo page, int currentIndex) { + if (page.url.equals("about:home")) { + waitForText("Enter Search or Address"); + verifyUrl(page.url); + } else { + waitForText(page.title); + verifyPageTitle(page.title); + } + } + + @Override + public void goBack() { + mNavigation.back(); + } + + @Override + public void goForward() { + mNavigation.forward(); + } + }).walk(); + } + }).walk(); + } + + /** + * Gets session restore JSON corresponding to the open session. + * + * The JSON format follows the format used in Gecko for session restore and + * should be interchangeable with the Gecko's generated sessionstore.js. + * + * @param session Session to serialize + * @return JSON string of session + */ + protected String buildSessionJSON(Session session) { + final SessionTab[] sessionTabs = session.getItems(); + String sessionString = null; + + try { + final JSONArray tabs = new JSONArray(); + + for (int i = 0; i < sessionTabs.length; i++) { + final JSONObject tab = new JSONObject(); + final JSONArray entries = new JSONArray(); + final SessionTab sessionTab = sessionTabs[i]; + final PageInfo[] pages = sessionTab.getItems(); + + for (int j = 0; j < pages.length; j++) { + final PageInfo page = pages[j]; + final JSONObject entry = new JSONObject(); + entry.put("url", page.url); + entry.put("title", page.title); + entries.put(entry); + } + + tab.put("entries", entries); + tab.put("index", sessionTab.getIndex() + 1); + tabs.put(tab); + } + + JSONObject window = new JSONObject(); + window.put("tabs", tabs); + window.put("selected", session.getIndex() + 1); + sessionString = new JSONObject().put("windows", new JSONArray().put(window)).toString(); + } catch (JSONException e) { + mAsserter.ok(false, "JSON exception", getStackTraceString(e)); + } + + return sessionString; + } + + /** + * @see SessionTest#verifySessionJSON(Session, String, Assert) + */ + protected void verifySessionJSON(Session session, String sessionString) { + verifySessionJSON(session, sessionString, mAsserter); + } + + /** + * Verifies a session JSON string against the given session. + * + * @param session Session to verify against + * @param sessionString JSON string to verify + * @param asserter Assert class to use during verification + */ + protected void verifySessionJSON(Session session, String sessionString, Assert asserter) { + final SessionTab[] sessionTabs = session.getItems(); + + try { + final JSONObject window = new JSONObject(sessionString).getJSONArray("windows").getJSONObject(0); + final JSONArray tabs = window.getJSONArray("tabs"); + final int optSelected = window.optInt("selected", -1); + + asserter.is(optSelected, session.getIndex() + 1, "selected tab matches"); + + for (int i = 0; i < tabs.length(); i++) { + final JSONObject tab = tabs.getJSONObject(i); + final int index = tab.getInt("index"); + final JSONArray entries = tab.getJSONArray("entries"); + final SessionTab sessionTab = sessionTabs[i]; + final PageInfo[] pages = sessionTab.getItems(); + + asserter.is(index, sessionTab.getIndex() + 1, "selected page index matches"); + + for (int j = 0; j < entries.length(); j++) { + final JSONObject entry = entries.getJSONObject(j); + final String url = entry.getString("url"); + final String title = entry.optString("title"); + final PageInfo page = pages[j]; + + asserter.is(url, page.url, "URL in JSON matches session URL"); + if (!page.url.startsWith("about:")) { + asserter.is(title, page.title, "title in JSON matches session title"); + } + } + } + } catch (JSONException e) { + asserter.ok(false, "JSON exception", getStackTraceString(e)); + } + } + + /** + * Exception thrown by NonFatalAsserter for assertion failures. + */ + public static class AssertException extends RuntimeException { + public AssertException(String msg) { + super(msg); + } + } + + /** + * Asserter that throws an AssertException on failure instead of aborting + * the test. + * + * This can be used in methods called via waitForCondition() where an assertion + * might not immediately succeed. + */ + public class NonFatalAsserter extends FennecMochitestAssert { + @Override + public void ok(boolean condition, String name, String diag) { + if (!condition) { + String details = (diag == null ? "" : " | " + diag); + throw new AssertException("Assertion failed: " + name + details); + } + mAsserter.ok(condition, name, diag); + } + } + + /** + * Gets a URL for a dynamically-generated page. + * + * The page will have a URL unique to the given ID, and the page's title + * will match the given ID. + * + * @param id ID used to generate page URL + * @return URL of the page + */ + protected String getPage(String id) { + return getAbsoluteUrl("/robocop/robocop_dynamic.sjs?id=" + id); + } + + protected String readProfileFile(String filename) { + try { + return readFile(new File(mProfile, filename)); + } catch (IOException e) { + mAsserter.ok(false, "Error reading" + filename, getStackTraceString(e)); + } + return null; + } + + protected void writeProfileFile(String filename, String data) { + try { + writeFile(new File(mProfile, filename), data); + } catch (IOException e) { + mAsserter.ok(false, "Error writing to " + filename, getStackTraceString(e)); + } + } + + private String readFile(File target) throws IOException { + FileReader fr = new FileReader(target); + try { + StringBuffer sb = new StringBuffer(); + char[] buf = new char[8192]; + int read = fr.read(buf); + while (read >= 0) { + sb.append(buf, 0, read); + read = fr.read(buf); + } + return sb.toString(); + } finally { + fr.close(); + } + } + + private void writeFile(File target, String data) throws IOException { + FileWriter writer = new FileWriter(target); + try { + writer.write(data); + } finally { + writer.close(); + } + } +} diff --git a/mobile/android/base/tests/robocop.ini b/mobile/android/base/tests/robocop.ini index 64566263cd5..c541835ea10 100644 --- a/mobile/android/base/tests/robocop.ini +++ b/mobile/android/base/tests/robocop.ini @@ -55,6 +55,8 @@ skip-if = processor == "x86" # disabled on x86 only; bug 936224 # skip-if = processor == "x86" [testSearchSuggestions] +[testSessionOOMSave] +[testSessionOOMRestore] [testSettingsMenuItems] [testSharedPreferences] # [testShareLink] # see bug 915897 diff --git a/mobile/android/base/tests/robocop_dynamic.sjs b/mobile/android/base/tests/robocop_dynamic.sjs new file mode 100644 index 00000000000..58ff33e9d1e --- /dev/null +++ b/mobile/android/base/tests/robocop_dynamic.sjs @@ -0,0 +1,18 @@ +/** + * Dynamically generated page whose title matches the given id. + */ + +function handleRequest(request, response) { + let id = request.queryString.match(/^id=(.*)$/)[1]; + let key = "dynamic." + id; + + response.setStatusLine(request.httpVersion, 200, null); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(''); + response.write('' + id + ''); + response.write(''); + response.write('

' + id + '

'); + response.write(''); + response.write(''); +} diff --git a/mobile/android/base/tests/testSessionOOMRestore.java b/mobile/android/base/tests/testSessionOOMRestore.java new file mode 100644 index 00000000000..7c715be7851 --- /dev/null +++ b/mobile/android/base/tests/testSessionOOMRestore.java @@ -0,0 +1,56 @@ +package org.mozilla.gecko.tests; + +import org.mozilla.gecko.*; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; + +/** + * Tests session OOM restore behavior. + * + * Loads a session and tests that it is restored correctly. + */ +public class testSessionOOMRestore extends SessionTest { + private Session mSession; + private static final String PREFS_NAME = "GeckoApp"; + private static final String PREFS_ALLOW_STATE_BUNDLE = "allowStateBundle"; + + @Override + public void setActivityIntent(Intent intent) { + PageInfo home = new PageInfo("about:home"); + PageInfo page1 = new PageInfo("page1"); + PageInfo page2 = new PageInfo("page2"); + PageInfo page3 = new PageInfo("page3"); + PageInfo page4 = new PageInfo("page4"); + PageInfo page5 = new PageInfo("page5"); + PageInfo page6 = new PageInfo("page6"); + + SessionTab tab1 = new SessionTab(0, home, page1, page2); + SessionTab tab2 = new SessionTab(1, home, page3, page4); + SessionTab tab3 = new SessionTab(2, home, page5, page6); + + mSession = new Session(1, tab1, tab2, tab3); + + String sessionString = buildSessionJSON(mSession); + writeProfileFile("sessionstore.js", sessionString); + + // This feature is pref-protected to prevent other apps from injecting + // a state bundle, so enable it here. + SharedPreferences prefs = getInstrumentation().getTargetContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().putBoolean(PREFS_ALLOW_STATE_BUNDLE, true).commit(); + + Bundle bundle = new Bundle(); + bundle.putString("privateSession", null); + intent.putExtra("stateBundle", bundle); + + super.setActivityIntent(intent); + } + + public void testSessionOOMRestore() throws Exception { + blockForGeckoReady(); + verifySessionTabs(mSession); + } +} diff --git a/mobile/android/base/tests/testSessionOOMSave.java b/mobile/android/base/tests/testSessionOOMSave.java new file mode 100644 index 00000000000..231dc735ad9 --- /dev/null +++ b/mobile/android/base/tests/testSessionOOMSave.java @@ -0,0 +1,83 @@ +package org.mozilla.gecko.tests; + +import com.jayway.android.robotium.solo.Condition; +import org.mozilla.gecko.*; + +import android.content.Intent; + +/** + * Tests session OOM save behavior. + * + * Builds a session and tests that the saved state is correct. + */ +public class testSessionOOMSave extends SessionTest { + private final static int SESSION_TIMEOUT = 25000; + + public void testSessionOOMSave() { + Actions.EventExpecter pageShowExpecter = mActions.expectGeckoEvent("Content:PageShow"); + pageShowExpecter.blockForEvent(); + pageShowExpecter.unregisterListener(); + + PageInfo home = new PageInfo("about:home"); + PageInfo page1 = new PageInfo("page1"); + PageInfo page2 = new PageInfo("page2"); + PageInfo page3 = new PageInfo("page3"); + PageInfo page4 = new PageInfo("page4"); + PageInfo page5 = new PageInfo("page5"); + PageInfo page6 = new PageInfo("page6"); + + SessionTab tab1 = new SessionTab(0, home, page1, page2); + SessionTab tab2 = new SessionTab(1, home, page3, page4); + SessionTab tab3 = new SessionTab(2, home, page5, page6); + + final Session session = new Session(1, tab1, tab2, tab3); + + // Load the tabs into the browser + loadSessionTabs(session); + + // Verify sessionstore.js written by Gecko. The session write is + // delayed for certain interactions (such as changing the selected + // tab), so the file is repeatedly read until it matches the expected + // output. Because of the delay, this part of the test takes ~9 seconds + // to pass. + VerifyJSONCondition verifyJSONCondition = new VerifyJSONCondition(session); + boolean success = waitForCondition(verifyJSONCondition, SESSION_TIMEOUT); + if (success) { + mAsserter.ok(true, "verified session JSON", null); + } else { + mAsserter.ok(false, "failed to verify session JSON", + getStackTraceString(verifyJSONCondition.getLastException())); + } + } + + private class VerifyJSONCondition implements Condition { + private AssertException mLastException; + private final NonFatalAsserter mAsserter = new NonFatalAsserter(); + private final Session mSession; + + public VerifyJSONCondition(Session session) { + mSession = session; + } + + @Override + public boolean isSatisfied() { + try { + String sessionString = readProfileFile("sessionstore.js"); + verifySessionJSON(mSession, sessionString, mAsserter); + } catch (AssertException e) { + mLastException = e; + return false; + } + return true; + } + + /** + * Gets the last AssertException thrown by verifySessionJSON(). + * + * This is useful to get the stack trace if the test fails. + */ + public AssertException getLastException() { + return mLastException; + } + } +}