С тех пор как Гугл выпустил в свет инструмент для автоматизации тестирования monkeyrunner прошло немало времени, а улучшений в нем не видно. Тем не менее, для задачи регулярной проверки веб-страниц на корректность верстки лучшего инструмента не нашлось. Те, кому просто нужен готовый скрипт для сравнения скриншотов страниц на андроиде с поддержкой прокрутки, могут сразу скачать его по ссылке. Под катом же будет рассказано, какие проблемы таит манкейраннер, и как их преодолевать.

Задача

Кратко, задача стояла так: пройтись по страницам по списку и убедиться, что они выглядят так же, как раньше (или почти так же). Само собой необходимо прокручивать длинные страницы до конца.

Алгоритм решения

Сам по себе он выглядит достаточно просто:
  1. Открыть страницу из списка
  2. Сделать скриншот, сравнить его с рефренсным
  3. Если скриншот не сильно отличается, прокрутить страницу на один экран и повторить пункт 2
  4. Если страница закончилась, или результат сильно отличается от оригинала, перейти к следующей странице

Для снятия скриншотов monkeyrunner имеет готовую функцию takeSnapshot, а для их сравнения есть совершенно прекрасный ImageMagick, который позволяет оценить насколько картинки похожи, а не просто требовать 100% совпадения. У ИмейджМеджика множество метрик сравнения, я использую -metric RMSE.
Чтобы определить, когда пора закончить скролить станицу вниз, достаточно сравнить два последних скрина, если они совпали, значит конец достигнут.
Для экспорта результатов в CI или любимую IDE используется python-junit-xml

Подводные камни

Теперь самое интересное: если сделать все это «в лоб», то ничего не будет работать. Причин несколько.

1. Windows
monkeyrunner имеет интересную «фичу», monkeyrunner.bat под Windows в процессе исполнения подменяет текущий каталог на тот, где лежит сам манкейраннер, потому что ему так удобно. В итоге в нашем скрипте перестают работать все относительные пути и директивы import
Чтобы побороть это поведение, срипт должен сам определить свое местоположение и дальше действовать только по абсолютным путям. Делается это примерно так:
filepath = path.split(os.path.realpath(__file__))[0]
BASE_PATH = path.split(filepath)[0].encode(FILENAMES_ENCODING)

try:
    import config
    from junit_xmls import TestSuite, TestCase
except ImportError:
    #dirty hack that loads 3rd party modules from script's dir not from working dir, which is always changed by windows monkeyrynner
    import imp
    
    config = imp.load_source('config', BASE_PATH+'/src/config.py')
    junit_xml = imp.load_source('junit_xml', BASE_PATH+'/src/junit_xml/__init__.py')
    TestSuite = junit_xml.TestSuite
    TestCase = junit_xml.TestCase


2. Русские символы
Первая же попытка протестировать русскую википедию провалилась, так как манкейраннер основан на втором питоне и наследует все известные проблемы unicode и национальными символами. Пришлось в явной форме указывать кодировку везде, где возможно, а также немного модифицировать junit_xml, автор которой не подозревал о национальных символах.
Например, для корректного создания файлов нужно перевести имя в unicode явно
filePath.decode("utf-8")

Кроме того на русскоязычных версиях Windows скрипт просто будет падать, если задан неверный путь к ImageMagick, потому что Popen просто не ожидает получить русские символы в сообщении об ошибке, а Windows свои сообщения локализует.

3. Скрол
Если 10 раз проскролировать одну и ту же страницу в одном и том же браузере на одном и том же девайсе с помощью MonkeyDevice.drag() мы получим 10 разных результатов. Drag просто не гарантирует, что он будет скролировать страницу одинаково. Чтобы решить эту проблему, пришлось применить следующий трюк: сравнивая новую страницу со старой, я отрезаю по несколько пикселей сверху и снизу страницы и ищу ее место ее вхождения в оригинал (к счастью ImageMagick умеет даже такое), то, насколько страница ниже или выше предполагаемой позиции, и есть погрешность скрола, нужно вычесть ее при следующем скроле. Такая обратная связь позволяет скрипту не отвалиться на третей итерации и благополучно дожить до конца страницы.

4. Расход памяти
Если открыть 10-20 страниц подряд любой браузер просто создаст 10-20 вкладок, со временем они сожрут всю память и браузер просто перестанет нормально реагировать на команды. В лучшем случае загрузка страниц замедлится, но обычно это приводит к тому, что monkeyrunner просто отваливается по таймауту. Чтобы избежать этого я пошел на небольшой хак: перед открытием новой страницы, текущий процесс браузера просто убивается через adb, примерно так
device.shell('am force-stop ' + BROWSER_PACKAGE_NAME)

Хак этот неплохо помогает стандартному AOSP браузеру в эмуляторе, но не очень действует на Хром, Оперу и ФФ, поэтому в итоге просто пришлось написать свою обертку на web-view, по сути — легковесный браузер без вкладок. Попутно решились и две других проблемы: самописный браузер не спрашивает подтверждения на доступ к местоположению пользователя и не портит скриншоты, а кроме того его можно включить в состав скрипта и он будет устанавливаться автоматически через MonkeyDevice.installPackage()

5. Неудачные подключения
Если завершить работу скрипта и тут же перезапустить его, скорее всего MonkeyRunner.waitForConnection() просто свалится с ошибкой, при этом повторный вызов обычно проходит уже без проблем. Поэтому финальный вариант скрипта всегда пробует подключиться к устройству дважды.

6. Блокировка экрана
Если девайс был заблокирован в момент запуска, скрипт просто будет работать некорректно. Этого можно избежать, сымитировав нажатие на аппаратную кнопку меню (по неизвестной причине это разблокирует Андроид аппараты)
MonkeyDevice.press("KEYCODE_MENU", MonkeyDevice.DOWN_AND_UP)

Но поскольку на разблокированном устройстве это приведет к появлению меню, лучше предварительно убедиться заблокирован ли девайс, сделать это можно через dumpsys
   lockScreenRegexp = re.compile('mShowingLockscreen=(true|false)')
   result = lockScreenRegexp.search(device.shell('dumpsys window policy'))
   if result:
       return (result.group(1) == 'true')
   raise RuntimeError("Couldn't determine screen lock state")


Разблокировка сама по себе не включит экран на «спящем» устройстве, так что перед всеми этими операциями стоит сделать принудительный вызов MonkeyRunner.wake()
После того к��к устройство успешно разблокировано, можно полагаться на то, что остальные функции начнут работать корректно.

Итого

Со всеми правками уже можно проверять корректность отображения страниц, не опасаясь падений через каждый 5 минут, но для лучшего результата стоит выбирать либо тестовый браузер, либо AOSP Browser из ванильного андроида.

П.С. Код лежит на битбакете и открыт для копирования и модификаций.