Selenium content supplier, или как автоматизировать процесс поставки внешних ресурсов

Как всегда, оригинал в блоге на буржуйском. :blush:

В одной из прошлогодних тем был затронут вопрос автоматизации поставки драйверов в проект.

Тогда я активно отговаривал господина @dzhariy от этой, как мне казалось, безнадежной затеи.

Прошел год, и вот в один из теплых летних вечеров, просматривая очередные интересные исходники - docker-selenium, мне стало очень интересно, как же ребята поставляют драйвера контейнеру. Не сильно углубляясь в детали, я сразу же наткнулся на весьма интересный скрипт, а именно строку:

wget --no-verbose http://selenium-release.storage.googleapis.com/2.47/selenium-server-standalone-2.47.1.jar -O /opt/selenium/selenium-server-standalone.jar

Т.е. нужный артефакт выгребается обычным GET запросом. Достаточно лишь знать прямой URL. Это подогрело мой интерес и я заглянул в рут ресурса. И что же я там нашел? С виду - какая-то бесполезная для рядовых юзеров XML. Но только не для нас - QAA-инженеров, которые бы сразу заприметили целый кладезь полезной информации. И что же тут полезного? - спросите вы. А вот что:

<Contents>
    <Key>2.47/IEDriverServer_Win32_2.47.0.zip</Key>
</Contents>
<Contents>
    <Key>2.47/IEDriverServer_x64_2.47.0.zip</Key>
</Contents>
<Contents>
    <Key>2.47/selenium-server-standalone-2.47.1.jar</Key>
</Contents>

Наша XML содержит недостающую часть мозаики - Key, который путем конкатенации с рутом дает нам прямой URL для закачки нужного компонента, будь то драйвер, или грид. Я тут же потопал в рут хром сторэджа, где увидел точно такую же картину.

И тут у меня в голове созрела идея: а почему бы нам не парсить эту самую XML для выгребания самой свежей версии драйверов и грида? Получая нужный URL, мы бы смогли без особого труда наладить постоянную поставку артефактов.

Итак, цель ясна, желание есть, значит пишем Selenium content supplier. :relaxed:

Начнем, пожалуй, с модели. Содержимое XML то нужно куда-то записывать, ведь так? Благо, у нас под рукой есть наша любимая IntelliJ IDEA, которой подавай лишь XML, а взамен она нам совершенно бескорыстно сгенерит и XSD схему, а там - и сами entities по новоиспеченной схеме.


.

Итого, всего в несколько кликов мы имеем готовую модель, которая с радостью примет наш респонс на GET запрос к руту → http://selenium-release.storage.googleapis.com.

Начало неплохое, не так ли? Теперь было бы неплохо подготовить и клиентскую часть:

    private Optional<String> getLatestPath(final Content contentType) {
        try {
            return Optional.ofNullable(client.target(contentType.getUrl())
                    .request(MediaType.APPLICATION_XML)
                    .get()
                    .readEntity(ListBucketResultType.class))
                    .map(contentContainer -> getLatestPath(contentContainer, contentType))
                    .orElse(Optional.<String>empty());
        } catch (Exception e) {
            CLIENT_LOGGER.severe("Unable to get resource content: " + e.getMessage());
            return Optional.empty();
        }
    }

    private Optional<String> getLatestPath(final ListBucketResultType contentContainer, final Content contentType) {
        return contentContainer.getContents().stream()
                .map(ContentsType::getKey)
                .filter(key -> key.contains(contentType.getName()))
                .reduce((key1, key2) -> key2);
    }

На выходе у нас получилось 2 метода, задача которых - получить содержимое XML, отфильтровать его по ключу и вернуть имя самого свежего артефакта.

Вам наверное интересно, что же за тип такой - Content? По сути, это обычный интерфейс, разработанный для предоставления доступа к оберткам над основными Selenium ресурсами - ChromeDriver, IEDriver и SeleniumServer. Ввиду обилия доступных версий и платформ без дополнительных оберток мы потеряли бы всю универсальность.

public interface Content {

    enum SeleniumContent {
        IE_DRIVER_X32(new IEDriver(IE_DRIVER, OS.WIN32)),
        IE_DRIVER_X64(new IEDriver(IE_DRIVER, OS.WIN64)),
        CHROME_DRIVER_WIN32(new ChromeDriver(CHROME_DRIVER, OS.WIN32)),
        CHROME_DRIVER_MAC32(new ChromeDriver(CHROME_DRIVER, OS.MAC32)),
        CHROME_DRIVER_LINUX32(new ChromeDriver(CHROME_DRIVER, OS.LINUX32)),
        CHROME_DRIVER_LINUX64(new ChromeDriver(CHROME_DRIVER, OS.LINUX64)),
        SELENIUM_SERVER(new SeleniumServer(SELENIUM_SERVER_STANDALONE));

        private Content content;

        SeleniumContent(Content content) {
            this.content = content;
        }

        public Content getContent() {
            return content;
        }

        public String toString() {
            return content.getName();
        }
    }

    enum OS {
        WIN32("Win32"),
        WIN64("x64"),
        LINUX32("linux32"),
        LINUX64("linux64"),
        MAC32("mac32");

        private String name;

        OS(final String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }

    String IE_DRIVER = "IEDriverServer";
    String CHROME_DRIVER = "chromedriver";
    String SELENIUM_SERVER_STANDALONE = "selenium-server-standalone";

    default String getUrl() {
        return "http://selenium-release.storage.googleapis.com";
    }

    String getName();
}

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

Содержимое оберток весьма примитивно, посему листинг приводить не стану. Сорсы будут доступны по ссылке ниже.

Итак, теперь самое время познакомиться с клиентской частью загрузчика:

    public String downloadFile(final SeleniumContent contentType, final String localFolder) {
        final String[] latestPath = getLatestPath(contentType.getContent())
                .map(path -> path.split("/"))
                .orElse(new String[0]);

        if (latestPath.length < 2) {
            throw new IllegalArgumentException("Unable to parse provided end-point path: " + Arrays.asList(latestPath));
        }

        final String version = latestPath[0];
        final String fileName = latestPath[1];
        String prettifiedFileName = separatorsToSystem(Optional.ofNullable(localFolder)
                .orElse(System.getProperty("user.home")) + File.separator + fileName);

        CLIENT_LOGGER.info("Detected '" + fileName + "' -> Trying to download " + version + " version...");

        try (final InputStream inputStream = client.target(contentType.getContent().getUrl())
                .path(version)
                .path(fileName)
                .request()
                .get(InputStream.class);
             final FileOutputStream fileOutputStream = new FileOutputStream(new File(prettifiedFileName))) {

            copy(inputStream, fileOutputStream);
            fileOutputStream.flush();

            CLIENT_LOGGER.info("'" + prettifiedFileName + "' has been successfully saved!");
        } catch (Exception e) {
            CLIENT_LOGGER.severe("Unable to save a file: " + e.getMessage());
            prettifiedFileName = "";
        }

        return prettifiedFileName;
    }

Алгоритм весьма прост и уже обсуждался выше:

  • Получаем значение ключа по заданной маске (содержимое наших оберток).
  • Парсим ключ, получая версию и собственно само имя файла.
  • Формируем путь к сохраняемому файлу.
  • Отправляем GET запрос на получение файла по переданному ключу.
  • Результат читаем в InputStream, который в последствии копируем в FileOutputStream при помощи апачевского метода copy.
  • Возвращаем имя файла для последующей обработки.

Возникает логичный вопрос - а какой может быть последующая обработка? Файл мы скачали. Чего еще надо для счастья? А надо бы добавить фичу распаковки zip архива, ибо драйвера то у нас поставляются в запакованном виде. Не долго думая, можно применить уже существующую библиотеку zip4j для реализации сего коварного плана. Можно конечно обойтись и стандартными API, но хотелось все же минимизировать кол-во писанины.

    public static Status unZipItems(final String filePath) {
        return unZipItems(filePath, getFullPath(filePath) + getBaseName(filePath));
    }

    public static Status unZipItems(final String filePath, final String outputFolder) {
        Status response;

        if (getExtension(filePath).equals("zip")) {
            try {
                new ZipFile(filePath).extractAll(outputFolder);
                IO_LOGGER.info("All files have been successfully extracted!");
                response = Status.OK;
            } catch (ZipException e) {
                IO_LOGGER.severe("Unable extract files from " + filePath + ": " + e.getMessage());
                response = Status.INTERNAL_SERVER_ERROR;
            }
        } else {
            response = Status.NOT_FOUND;
        }

        return response;
    }

API достаточно примитивный и в особом представлении, я думаю, не нуждается.

Супер, теперь у нас есть полноценный загрузчик последних версий Selenium ресурсов. Но чего-то все равно не хватает… Вспоминая о гриде, мы наверняка задумались бы о целом батальоне подключенных к хабу нодов, которые тоже было бы неплохо обновлять новыми артефактами. Не вопрос. Пишем небольшой file upload сервис → profit:

    @POST
    @Path("/upload")
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces(MediaType.APPLICATION_JSON)
    public Response uploadFile(final FormDataMultiPart form, @QueryParam("saveTo") final String saveToPath,
                               @QueryParam("unZip") final boolean unZip) {
        return Response.status(form.getFields("file")
                .parallelStream()
                .flatMap(filePart -> saveFile(filePart.getValueAs(InputStream.class),
                        formatPath(filePart.getContentDisposition().getFileName(), saveToPath), unZip).stream())
                .filter(response -> response.equals(Response.Status.INTERNAL_SERVER_ERROR))
                .findAny()
                .orElse(Response.Status.OK))
                .build();
    }

Сразу же расширяем возможности сервиса поддержкой multi-part данных (для множественного аплоада), разархивации, ну и конечно же было бы неплохо все это дело оптимизировать, распараллелив процесс сохранения файлов при помощи Java 8 parallelStream() фичи.

Теперь посмотрим на клиентскую часть:

 public void sendFileToRemoteAndUnZip(final List<String> filesPath, final String saveToPath) {
        Response response = null;
        final MultiPart multiPart = new MultiPart(MediaType.MULTIPART_FORM_DATA_TYPE);

        filesPath.stream().forEach(path -> multiPart.bodyPart(new FileDataBodyPart("file",
                new File(separatorsToSystem(path)), MediaType.APPLICATION_OCTET_STREAM_TYPE)));

        try {
            response = client.target("http://" + ip + ":" + port + "/selenium")
                    .path("content")
                    .path("upload")
                    .queryParam("saveTo", separatorsToSystem(saveToPath))
                    .queryParam("unZip", true)
                    .request(MediaType.APPLICATION_JSON_TYPE)
                    .post(Entity.entity(multiPart, multiPart.getMediaType()));

            final int status = response.getStatus();

            if (status == Response.Status.OK.getStatusCode()) {
                CLIENT_LOGGER.info("File(-s) " + filesPath + " has been saved to " + separatorsToSystem(saveToPath) + " on " + ip);
            } else {
                CLIENT_LOGGER.severe("Unable to save or unZip file(-s) " + filesPath + " to " + separatorsToSystem(saveToPath) + " on " + ip + "; status = " + status);
            }
        } catch (Exception e) {
            CLIENT_LOGGER.info("An error occurred while file(-s) uploading: " + e);
            response = null;
        } finally {
            if (response != null) {
                response.close();
            }
        }
    }

Ничего военного, нечто подобное уже было когда-то предложено в SikuliX.

Вот собственно и все. Т.к. сей библиотеки нет в официальном maven репозитории, вам придется самостоятельно собрать проект при помощи команды:

mvn clean install

На выходе получите сразу и рабочий сервер, и готовую клиентскую dependency в локальном репозитории.

Ну и конечно же результат:

    @Test
    public void downloadAndUnZipFilesLocally() {
        Stream.of(SeleniumContent.values())
                .parallel()
                .forEach(content -> localSupplier.downloadAndUnZipFile(content, OUTPUT_PATH));
    }

    @Test
    public void downloadFilesLocallyAndSendToRemote() {
        Arrays.asList(SeleniumContent.SELENIUM_SERVER, SeleniumContent.CHROME_DRIVER_WIN32, SeleniumContent.IE_DRIVER_X32)
                .parallelStream()
                .forEach(content -> remoteSupplier.sendFileToRemoteAndUnZip(Collections.singletonList(
                        remoteSupplier.downloadAndUnZipFile(content, OUTPUT_PATH)), OUTPUT_PATH));
    }

При этом, первый тест зальет все доступные артефакты последних версий, и тут же их распакует.
Второй - скачает 3 артефакта локально (аля поставка хабу), а затем отправит их прямиком удаленному сервису (аля ноду), и также распакует. Для ремоутной отправки естественно на ноде должен быть поднят selenium-supplier-server, ждущий дальнейших указаний.

В целом, данную библиотеку можно скомбинировать с EnvironmentWatcher service для реализации следующего сценария: прибиваем все процессы (хабы / ноды / драйвера), обновляем ресурсы последними версиями, запускаем процессы уже с новыми артефактами.

Код selenium-supplier, и отдельно парочка примеров на GitHub.

Если данная библиотека окажется вам полезной, не забываем ставить лайки. :wink:

Ну и собственно мини-опрос о целесообразности развития данной библиотеки:

  • Текущей реализации вполне достаточно.
  • Не хватает определенных фич, я поделюсь идеями с автором.
  • Я хочу принять участие в ее дальнейшем развитии.
  • Хотелось бы вывести библиотеку на уровень CI плагина (включая любой из предыдущих пунктов).
  • Это очень сложно, мне проще выкачивать все ручками.

0 участников

7 лайков

Это я тот один вотер, который проголосовал за то, что это слишком сложно.
Для достижения этой задачи: обновления тестов и другого ПО есть множество путей, которые я предпочитаю больше, чем создание собственных велосипедов.

  1. Хранить драйверы в репозитории – об этом уже упоминалось. И я придерживаюсь той мысли, что внутри конкретного проекта тесты должны запускаться без дополнительных выкачек необходимого софта, а работать “из коробки”, то есть из IDE. В предлагаемом примере кроются “корпоративные” сложности. Вот, например, виртуальная машина может быть ограничена в доступе к Интернету… Хорошо… есть прокси, но вот у меня для разных сред и виртуалок используются различные настройки прокси серверов. Все это, конечно, можно побороть… но время…

  2. Есть стандартные средства deploymnet’а на удаленные сервера. Для Windows – это msdeploy (Microsoft Web Deploy), который может синхронизировать любую папку на удаленной машине. Достаточно открытого 80-го порта . Для Linux – ssh и scp.

Я уже не говорю, о Chef и других, который, конечно же сложнее для начала, но если человек уже с ним работает – то почему бы и нет?

Создавать свои велосипеды, я считаю, оправданно только когда идет деплоймент на неконтроллируемые енвайренменты.

Например, тут на форуме проскакивали темы по поставке тестов заказчику.

2 лайка

Я второй :smile:
Это интересно в теории, но на практике все же проще использовать более простые вещи.

Если говорить например про maven, то есть плагин, который сам-скачает-распакует:

Если про грид - то что-бы поднять ноду, все-равно же придется на нее заходить, так почему бы сразу и не записать нужные ресурсы.

Авто-апдейт драйверов может “много-чего-поломать”.

1 лайк

Во многом согласен с приведенной критикой @dzhariy и @vmaximv.

Как раз по этой причине я и не стал допиливать downloader для серверной части. Т.е. идея - в закачке артефактов на 1 единственную машину, где есть доступ ко всему. А затем - распределение ресурсов по всем необходимым виртуалкам.

Согласен. В свое оправдание скажу лишь, что данная фича с удаленным file upload - не является основной, а сделана лишь для отражения целостной картины того, как может выглядеть процесс в идеале. Если на проекте уже настроены стандартные средства деплоймента, то почему нет? Никто ведь не обязывает использовать серверную часть.

А смогут ли они детектить какая версия у нас - последняя? Как они будут узнавать о том, что появился новый артефакт в удаленном репозитории? Что именно они будут GET-ать? Вот товарищи из докер-селениум каждый раз обновляют скрипт, заменяя старую версию на новую.

Я ведь на самом деле хотел подчеркнуть тот факт, что в условиях неопределенности, когда мы не знаем, что качать (какая версия - последняя), стандартные средства из коробки нам мало чем помогут. Но если эти тулы умеют нечто подобное, я конечно же возьму свои слова обратно. :blush:

Ну вот я как раз хотел добиться того, чтобы на ноду нам не пришлось заходить в принципе (кроме самого первого раза). Приблизительный сценарий был описан в посте. Сами скрипты запуска хабов / нодов можно подправить так, чтобы они искали артефакты запуска (standalone-server / drivers) по маске, а не хардкодом.

Ну как утверждают разработчики селениума, обратная совместимость самой либы должна присутствовать. А вот драйвера - да. Хотя, тот же хром драйвер не особо часто обновляется. Но в любом случае апдейты драйверов с браузерами должны быть синхронизированы. Тут не поспоришь.

П.С. Касательно сложности реализации скажу лишь вот что: вы ведь когда подключаете ту или иную библиотеку, зачастую не задумываетесь о скрытой в глубине complexity? Вам нужен лишь результат - какой-то API, который сделает ровно то, что вам нужно. Приведенные выше примеры показывают, что помимо подключения клиентской депенденси, вам понадобится всего лишь несколько строк кода, чтобы выкачать все самые свежие версии нужных вам артефактов. Действительно ли все так сложно на поверхности?

А зачем? Тестовый энвайромент должен быть для меня детерминирован, так как меньше всего я хочу задаваться вопросом “почему вчера все работало, а сегодня перестало”. Ведь в драйверах, помимо саппорта новых версий браузеров, иногда появляются жуки баги.

1 лайк

Приехали к тому, с чего все начиналось. А как это связано с основным назначением предложенного функционала? Это лишь средство загрузки и поставки последних версий требуемых артефактов (чтобы в принципе не заморачиваться о том, какая версия - последняя, где ее качать, и куда поставлять). Когда осуществлять саму поставку - совсем другой вопрос. В любом случае, если вы принимаете решение, что пора апдейтиться, вы не сможете предугадать наверняка, пройдет ли все гладко или нет. Для этого можно настроить тестовое окружение для пробных апдейтов. Если все прошло тихо и не падает - запустить поставку на все остальные окружения. Разница лишь в том, что все это будет происходить автоматически в несколько строк кода. Если вам проще делать это ручками - it’s up to you.