Продолжаем серию статей про автоматизацию десктопных приложений. В первой части мы разбирали основы автоматизации.
Во второй мы сосредоточимся на практике: поиске элементов во FlaUI, умных ожиданиях, безопасной работе с контролами и приемах для динамического UI. Все примеры — из нашего UIAutomationTestKit.

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

Содержание

  1. Как мы ищем элементы с помощью FlaUI - Стратегии приоритетов локаторов

  2. Наши подходы к ожиданию элементов - WaitExtensions и умные ожидания

  3. Работа с разными типами UI элементов - TypeExtensions и безопасные взаимодействия

  4. Обработка динамических элементов - Лучшие практики для нестабильного UI

Как мы ищем элементы с помощью FlaUI

Поиск элементов - это фундамент любого UI-автотеста. Во FlaUI это делается через древо автоматизации (UI Automation Tree), которое строится из всех видимых элементов интерфейса. Наша задача - найти нужную «ветку» или «лист» в этом дереве.

Базовый инструмент: FindFirstDescendant и FindAllDescendants

Это два основных метода для поиска. Они вызываются у любого элемента-контейнера (обычно окна) и принимают условие поиска (Condition).

// Находим первый элемент с указанным AutomationId
var textBox = window.FindFirstDescendant(cf => cf.ByAutomationId("UserIdTextBox"));

// Находим ВСЕ кнопки на форме
var allButtons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button));

По каким признакам мы ищем? (Стратегия приоритетов)

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

1. ByAutomationId (Идеальный вариант)

  • Что это: Уникальный идентификатор, который разработчик присваивает элементу в коде (например, в WPF - свойство x:Name или AutomationProperties.AutomationId).

  • Почему это лучший способ: Он почти всегда уникален в пределах окна и не меняется при локализации или смене текста.

  • Когда использовать: Всегда в первую очередь! Это ваш главный и самый стабильный локатор.

  • Пример из нашего кода: Мы используем это как основной способ поиска в классе MainWindowLocators.

    // UiAutoTests/Locators/MainWindowLocators.cs
    public TextBox UserIdTextBox => FindFirst("UserIdTextBox").AsTextBox();
    public TextBox UserLastNameTextBox => FindFirst("UserLastNameTextBox").AsTextBox();
    

2. ByName / ByText (Хороший вариант, но с оговорками)

  • Что это: Текст, который пользователь видит на элементе (Заголовок кнопки, текст метки и т.д.).

  • Почему нужно быть осторожным: Текст может меняться (локализация, редизайн), он может быть не уникальным (несколько кнопок "ОК" в разных местах).

  • Когда использовать: Когда у элемента нет AutomationId, но его текст статичен и уникален в контексте.

3. ByControlType (Самый ненадежный вариант)

  • Что это: Тип элемента (Кнопка, Текстовое поле, Чекбокс). FlaUI определяет его через свойство ControlType.

  • Почему это ненадежно: В окне может быть десяток кнопок или текстовых полей. Такой поиск почти всегда возвращает несколько элементов.

  • Когда использовать: Только в комбинации с другими условиями или когда нужно найти все элементы одного типа.

4. ByClassName (Для системных и сложных элементов)

  • Что это: Внутреннее имя класса элемента, которое присваивает операционная система или фреймворк.

  • Почему это специфично: Эти имена часто generic-ские (например, "Button", "Edit") и одинаковы для всех элементов одного типа.

  • Когда использовать: Для сложных кастомных контролов, где разработчик не выставил AutomationId. Например, для элементов стандартного календаря WPF.

  • Пример из нашего кода: Мы используем этот способ для поиска ячеек календаря, у которых нет стабильных AutomationId.

    // UiAutoTests/Locators/MainWindowLocators.cs
    public AutomationElement CalendarDayButton => FindFirstByClassName("CalendarDayButton");
    public AutomationElement[] CalendarDayButtons => FindAllByClassName("CalendarDayButton");
    

Комбинирование условий

Часто один признак недостаточен. FlaUI позволяет комбинировать условия с помощью методов And и Or.

// Найти элемент, который является либо кнопкой, либо текстовым полем
var orCondition = cf.ByControlType(ControlType.Button).Or(cf.ByControlType(ControlType.Edit));

Наша практика: Класс-локатор

Мы не используем поиск напрямую в тестах. Вместо этого мы инкапсулируем всю логику поиска в отдельные классы-локаторы. Это делает код тестов чище, а изменение локатора в одном месте автоматически применяется ко всем тестам.

// Вместо этого в тесте (ПЛОХО):
var textBox = window.FindFirstDescendant(cf => cf.ByAutomationId("UserIdTextBox")).AsTextBox();

// Мы делаем так (ХОРОШО):
// 1. Локатор знает, как найти элемент
public TextBox UserIdTextBox => FindFirst("UserIdTextBox").AsTextBox();

// 2. В тесте мы просто используем свойство
var textBox = _locators.UserIdTextBox;

Золотая стратегия поиска элементов выглядит так:

  • Всегда стараться использовать ByAutomationId. Это требует договоренности с разработчиками, но окупается стабильностью тестов на 100%. Это наш основной метод, как видно в MainWindowLocators.

  • Если AutomationId нет, пробовать ByName, если текст статичен и уникален.

  • Для системных и кастомных компонентов (как календарь) использовать ByClassName.

  • Избегать "хрупких" локаторов, основанных только на тексте или позиции элемента.

  • Создание классов-локаторов.


Наши подходы к ожиданию элементов

Ожидание - это искусство дать приложению достаточно времени отреагировать, но не ждать дольше необходимого. Наша философия: явно ждать нужного состояния элемента, а не просто его наличия.

Мы отказались от "жестких" пауз Thread.Sleep() в пользу умных ожиданий, основанных на условиях.

Проблема "жестких" пауз:

// ПЛОХО: Хрупко и неэффективно
element.Click();
Thread.Sleep(3000); // Ждем 3 секунды ВСЕГДА, даже если элемент появился через 100мс
DoNextAction();

Наше решение: Умные ожидания с помощью Retry-логики

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

Базовые строительные блоки (Live в нашем коде):

// UiAutoTests/Extensions/WaitExtensions.cs
public static class WaitExtensions
{
    private const int DefaultTimeout = 5000; // 5 секунд по умолчанию

    /// <summary>
    /// Ожидание появления элемента
    /// </summary>
    public static bool WaitUntilExists(this AutomationElement parent, Func<AutomationElement> findFunc, int timeoutMs = DefaultTimeout)
    {
        _logger.Info($"Ожидание появления элемента");
        // Retry.WhileNull будет вызывать findFunc с заданным интервалом, пока тот не вернет не null
        var result = Retry.WhileNull(findFunc, TimeSpan.FromMilliseconds(timeoutMs));
        return result.Result != null;
    }

    /// <summary>
    /// Ожидание кликабельности элемента
    /// </summary>
    public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = DefaultTimeout)
    {
        _logger.Info($"Ожидание кликабельности элемента: {element.Properties.AutomationId}");
        // Ждем не просто появления, а нужного состояния для взаимодействия!
        return Retry.WhileFalse(
            () => element?.IsEnabled == true && element?.IsOffscreen == false, // Условие: включен и видим на экране
            TimeSpan.FromMilliseconds(timeoutMs)).Success;
    }
}

Типы ожиданий, которые мы используем:

1. Ожидание общего состояния элемента (самые частые):

  • WaitUntilEnabled / WaitUntilClickable: Элемент доступен для взаимодействия.

  • WaitUntilDisabled: Элемент заблокирован (например, кнопка "Сохранить" пока форма не валидна).

  • WaitUntilVisible: Элемент не скрыт и находится на экране.

2. Ожидание конкретных значений и текста:

  • WaitUntilTextAppears: Ожидание появления определенного текста.

    // Ожидаем, что в текстовом поле появится текст "Успешно!"
    element.WaitUntilTextAppears("Успешно!");
    
  • WaitUntilValueChanged: Ожидание изменения значения (для полей ввода, прогресс-баров).

3. Ожидание исчезновения элементов:

  • WaitUntilNotExists / WaitUntilDisappears: Элемент был, а теперь его нет (исчезло модальное окно, пропал лоадер).

    // Ждем, когда исчезнет индикатор загрузки
    loadingIndicator.WaitUntilDisappears();
    

Где мы применяем ожидания? В слое Type Extensions!

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

Как это работает на практике:

// UiAutoTests/Extensions/TextBoxExtensions.cs
public static void EnterText(this TextBox textBox, string inputText)
{
    var element = textBox.EnsureTextBox(); // 1. Убедились, что это текстбокс
    if (!element.WaitUntilEnabled(5000))    // 2. Подождали, пока он станет доступен
        throw new TimeoutException($"TextBox {element.AutomationId} не стал активным");

    element.Focus();
    element.Text = inputText; // 3. Совершили действие
    _logger.Info($"Введен текст: {inputText}");
}

// UiAutoTests/Extensions/ButtonExtensions.cs
public static void ClickButton(this Button button, int timeoutMs = 5000)
{
    var element = button.EnsureButton(); // 1. Убедились, что это кнопка
    if (!element.WaitUntilClickable(timeoutMs)) // 2. Подождали, пока можно будет кликнуть
        throw new TimeoutException($"Кнопка {element.AutomationId} не стала кликабельной");

    button.Invoke(); // 3. Совершили действие
    _logger.Info($"[{button.AutomationId}] is Invoked");
}

Итог по ожиданиям:

  1. Мы явно ждем состояния, а не просто наличия. IsEnabled && !IsOffscreen - наша магия.

  2. Мы встраиваем ожидание в само действие. Это избавляет тесты от кусков кода с ожиданиями.

  3. Мы используем централизованную Retry-логику. Легко настроить таймауты и интервалы для всего проекта.

  4. Мы логируем каждое ожидание. Это критически важно для отладки падающих тестов.

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

// Чистый и стабильный тест
_mainWindowController
    .EnterLogin("user") // внутри есть ожидание
    .EnterPassword("pass") // внутри есть ожидание
    .ClickLogin() // внутри есть ожидание
    .AssertWelcomeMessage(); // и здесь тоже есть ожидание

Наша система ожиданий - это не просто замена Thread.Sleep. Это инструмент прогнозирования поведения приложения.
Мы не ждем произвольное время, а точно знаем, какого состояния должен достичь элемент, чтобы тест мог продолжить работу.
Это, в сочетании с детальным логированием и визуальной диагностикой, превращает процесс отладки из рутины в быстрое и даже приятное занятие.


Работа с разными типами UI элементов

Одна из главных сильных сторон FlaUI - это единый API для работы с разными технологиями (WinForms, WPF, UWP) и типами элементов. Наша задача - использовать эту силу, создав универсальные и безопасные методы взаимодействия.

Наш главный принцип: "Один элемент - один метод расширения"

Мы не используем "голые" вызовы типа element.AsTextBox().Enter("text") прямо в тестах. Вместо этого мы создали для каждого типа элементов свой класс расширений с проверенными и стабильными методами.

Базовый паттерн: Ensure + Wait + Act

Каждое наше взаимодействие с элементом строится по этой схеме:

  1. Ensure - убедиться, что элемент действительно того типа, который мы ожидаем.

  2. Wait - дождаться его готовности к взаимодействию.

  3. Act - выполнить действие.

// UiAutoTests/Extensions/TextBoxExtensions.cs
public static void EnterText(this TextBox textBox, string text, int timeoutMs = 5000)
{
    // 1. ENSURE
    var element = textBox.EnsureTextBox();
    // 2. WAIT
    if (!element.WaitUntilEnabled(timeoutMs))
        throw new TimeoutException($"TextBox {element.AutomationId} не стал активным");
    // 3. ACT
    element.Focus();
    element.Text = text;
}

Примеры работы с ключевыми элементами

1. Текстовые поля (TextBox)
Особенности: Нужно управлять фокусом, очищать перед вводом, обрабатывать многострочный текст.

// Очистка и ввод текста
public static void EnterText(this TextBox textBox, string text) { ... }

// Ожидание конкретного текста в поле (для валидаций)
public static bool WaitForText(this TextBox textBox, string expectedText, int timeoutMs = 5000)
{
    return Retry.WhileFalse(
        () => textBox.Text == expectedText,
        TimeSpan.FromMilliseconds(timeoutMs)).Success;
}

2. Сложные элементы: DataGridView (Таблица)
Особенности: Самая сложная работа - поиск по строке и колонке.

// Поиск строки по значению в конкретной колонке
public static DataGridViewRow FindRowByCellValue(this DataGridView grid, string columnName, string value)
{
    return grid.Rows.FirstOrDefault(row => 
        row.Cells[columnName].Value == value);
}

// Клик по ячейке с определенным значением
public static void ClickCellWithValue(this DataGridView grid, string columnName, string value)
{
    var row = grid.FindRowByCellValue(columnName, value);
    row?.Cells[columnName].Click();
}

Универсальные методы для любых элементов

Мы также создали методы, которые работают для любого элемента:

// Скриншот конкретного элемента
public static void CaptureElementScreenshot(this AutomationElement element, string fileName)
{
    var screenshot = element.Capture();
    screenshot.ToFile(fileName);
}

// Получение всех свойств элемента (для отладки)
public static string GetElementInfo(this AutomationElement element)
{
    return $"Id: {element.Properties.AutomationId}, Name: {element.Properties.Name}, Type: {element.ControlType}";
}

Итог подхода:

  1. Инкапсуляция сложности: Вся низкоуровневая работа с FlaUI спрятана в методах расширений.

  2. Безопасность: Каждое действие начинается с проверки типа и состояния элемента.

  3. Переиспользование: Написанный один раз метод для работы с таблицей используется в десятках тестов.

  4. Читаемость: Тесты выглядят как последовательность бизнес-действий:

    // Вместо непонятного кода:
    grid.Rows[3].Cells["Name"].Click();
    
    // Мы пишем ясный сценарий:
    grid.ClickCellWithValue("Name", "Иван Иванов");
    

Итог: Почему разделение на классы - это важно

Представьте, что ваш фреймворк - это кухня ресторана.

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

  • Разделенная кухня (ваш подход): Есть зона для нарезки, зона для готовки, мойка, холодильник. Каждый инструмент и продукт на своем месте. Шеф-повар (тест) не чистит рыбу, он просто дает команды ("приготовить это") и получает готовые компоненты.

Конкретные плюсы разделения:

1. Для Разработки (Прямо сейчас)

  • Скорость: не нужно каждый раз вспоминать, как работать с ComboBox во FlaUI. Вы просто вызываете SelectItemByText - это в 10 раз быстрее.

  • Безопасность: методы Ensure не дадут вам случайно попытаться кликнуть по текстовому полю как по кнопке. Ошибки отлавливаются на этапе написания теста.

  • Поиск: где искать логику для чекбокса? В CheckBoxExtensions. Все очевидно и предсказуемо.

2. Для Тестирования (Стабильность)

  • Централизованный контроль: если в приложении изменилось поведение (например, все элементы теперь дольше становятся активными), вам нужно поменять всего одно число в параметре по умолчанию в WaitExtensions, а не 100500 Thread.Sleep по всему коду.

  • Надежность: отработанная и протестированная логика ожиданий гарантированно применяется к каждому действию. Вы не зависите от внимательности автора теста.

3. Для Поддержки (Через 6 месяцев)

  • Читаемость: новый человек в команде смотрит на тест и сразу понимает, что он делает: EnterLogin, ClickSubmit. Ему не нужно разбираться в дебрях FlaUI.

  • Внесение изменений: если разработчики поменяли AutomationId у кнопки, вы правите его в одном месте - в классе Locators. Все тесты, использующие эту кнопку, продолжат работать.

  • Отладка: если падает ClickButton, вы точно знаете, где искать проблему - в ButtonExtensions. Вы изолировали проблему до одного маленького метода.

4. Для Масштабирования (Когда проект растет)

  • Добавление нового функционала: вам нужно протестировать новый слайдер? Вы создаете SliderExtensions с методами SetSliderValue, GetSliderValue. И все тесты сразу получают доступ к этому стабильному методу.

  • Переиспользование: написанный метод для поиска строки в таблице (FindRowInGrid) становится доступен всем. Не нужно его копировать.


Обработка динамических элементов: Лучшие практики

Динамические элементы - это отличный пример того, где классические подходы к автоматизации дают сбой. Давайте разберем, как правильно работать с элементами, которые меняются, появляются с задержкой или обновляются асинхронно.

Почему Thread.Sleep - это антипаттерн?

// ПЛОХО: Хрупкий и неэффективный подход
Thread.Sleep(5000); // Ждем 5 секунд всегда
element.Click();    // Внезапно элемент может быть еще не готов

Проблемы:

  • Потеря времени: Если элемент появился через 100мс, мы теряем 4.9 секунды

  • Ненадежность: Если элемент не появился за 5 секунд - тест падает

  • Непредсказуемость: В разных окружениях нужны разные таймауты

Правильный подход: Умные ожидания состояний

Основная идея: ждать не время, а конкретное состояние элемента.

// ХОРОШО: Ждем конкретного состояния
public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = 5000)
{
    return Retry.WhileFalse(
        () => element?.IsEnabled == true && element?.IsOffscreen == false,
        TimeSpan.FromMilliseconds(timeoutMs)).Success;
}

// Использование:
if (element.WaitUntilClickable(5000))
{
    element.Click(); // Гарантированно безопасный клик
}

Паттерн: "Ожидание → Действие → Верификация"

Этот паттерн делает тесты стабильными и предсказуемыми:

// 1. ОЖИДАНИЕ: Ждем, пока элемент станет готов
progressBar.WaitUntilValueIs(targetValue);

// 2. ДЕЙСТВИЕ: Выполняем операцию (опционально)
dataGrid.GetRowCount();

// 3. ВЕРИФИКАЦИЯ: Проверяем результат
Assert.That(actualCount, Is.EqualTo(expectedCount));

Типовые сценарии и их решения

Сценарий 1: Ожидание завершения длительной операции

// Ждем заполнения прогресс-бара
public void WaitForGenerationComplete(int expectedCount)
{
    var progressBar = _locators.UserGenerationProgressBar;
    progressBar.WaitUntilValueIs(expectedCount); // Ждем конкретного значения
}

Сценарий 2: Работа с асинхронно подгружаемыми данными

// Ждем появления данных в таблице
public int GetValidRowCount()
{
    var dataGrid = _locators.UsersCollectionDataGrid;
    
    // Ждем, пока таблица не будет готова
    if (dataGrid.WaitUntilEnabled(10000))
    {
        return dataGrid.GetRowCount(); // Только теперь получаем данные
    }
    
    throw new TimeoutException("Таблица не загрузилась за отведенное время");
}

Сценарий 3: Заполнение формы с валидацией

// Последовательное заполнение с ожиданиями
public void FillFormSafely(FormData data)
{
    // Каждое действие ждет готовности элемента
    SetUserId(data.UserId);        // WaitUntilEnabled внутри
    SetLastName(data.LastName);    // WaitUntilEnabled внутри
    SetFirstName(data.FirstName);  // WaitUntilEnabled внутри
    
    // Ждем применения валидации
    WaitForValidation();
    
    // Только теперь пытаемся отправить
    ClickSubmitButton();           // WaitUntilClickable внутри
}

Ключевые принципы обработки динамики

Принцип 1: Явное лучше неявного
Всегда явно указывайте, чего вы ждете:

// Вместо неявного ожидания:
Thread.Sleep(1000);

// Используйте явное:
element.WaitUntilTextAppears("Завершено");

Принцип 2: Локальность ожиданий
Ожидайте минимально необходимого состояния:

// Вместо ожидания всей страницы:
WaitForPageLoad();

// Ожидайте конкретный элемент:
submitButton.WaitUntilClickable();

Принцип 3: Детальное логирование
Логируйте процесс ожидания для отладки:

public static bool WaitUntilClickable(this AutomationElement element, int timeoutMs = 5000)
{
    _logger.Info($"Ожидание кликабельности: {element.Properties.AutomationId}");
    // ... логика ожидания
    _logger.Info($"Элемент стал кликабельным: {success}");
    return success;
}

Практические рекомендации

  1. Начинайте с простых ожиданий: WaitUntilEnabled, WaitUntilVisible

  2. Добавляйте сложные условия постепенно: комбинируйте несколько проверок

  3. Тестируйте на медленных окружениях: увеличивайте таймауты для production-сред

  4. Используйте разные стратегии: для разных типов элементов нужны разные подходы

  5. Анализируйте логи: смотрите, какие ожидания занимают больше всего времени

Обработка динамических элементов - это не магия, а система правильных подходов:

  • Ждите состояний, а не времени

  • Используйте явные проверки вместо предположений

  • Логируйте процесс ожидания для отладки

  • Адаптируйте таймауты под конкретные сценарии

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


Заключение: Путь от хаоса к системе

Начиная работу с UI-автотестами, многие проходят через одни и те же этапы:

  1. Хаос: Thread.Sleep() повсюду, хрупкие локаторы, тесты падают от любого чиха

  2. Осознание: Понимание, что нужны ожидания состояний, а не временные паузы

  3. Система: Построение архитектуры с четким разделением ответственности

  4. Мастерство: Создание стабильных тестов, которые работают даже в сложных условиях

Главный вывод: Написание UI-автотестов - это не про "кликнуть и проверить". Это про проектирование надежной системы, которая:

  • Знает что искать (Locators)

  • Умеет ждать нужного состояния (WaitExtensions)

  • Безопасно взаимодействует (TypeExtensions)

  • Собирается в читаемые сценарии (Controllers)

  • Предсказуемо работает даже с динамическим UI

Ваша цель - не просто написать тест, а создать саморегулирующуюся систему, которая адаптируется к изменениям и предоставляет точную обратную связь о состоянии приложения.

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

В следующих статьях мы подробно разберем:

Организация тестов

  • Как устроены наши тестовые сценарии

  • Работа с тестовыми данными

  • Обработка ошибок в тестах

  • Как мы пишем стабильные тесты

Логирование

  • Настройка и использование NLog

  • Как мы логируем действия в тестах

  • Структура наших логов

  • Анализ результатов тестов

Удачи в создании стабильных и надежных автотестов!


Традиционные советы о которых не просили

  • Начинайте с малого - внедряйте паттерны постепенно, не пытайтесь переписать все сразу. Возьмите один самый "хрупкий" тест и превратите его в эталонный.

  • Сначала пишите WaitExtensions, потом тесты - создайте библиотеку ожиданий прежде чем начинать писать много тестов. Это окупится в десятки раз.

  • Логируйте ВСЁ - каждый клик, каждое ожидание, каждый результат. Когда тест упадет через месяц, вы скажете себе спасибо.

  • Не изобретайте велосипед - смотрите как устроены успешные open-source проекты (вроде Selenium PageObjects), многое можно адаптировать под FlaUI.

? А если серьезно:
Автоматизация - это магия, превращающая рутину в искусство. Иногда нужно посмеяться над абсурдом ситуации ("опять этот элемент не найден!"), чтобы сохранить здравый рассудок.

Подписывайтесь - вместе мы превратим эти мучения в удовольствие! Ну или хотя бы в менее болезненный опыт.

**Мы тут надолго... как тот ваш тест, который никак не дождётся элемента!** ?


Полезные ресурсы

  1. UIAutomationTestKit на GitHub

  2. Документация FlaUI

  3. Предыдущая статья UI-автотесты: как правильно организовать код и не сойти с ума

Комментарии (1)


  1. AleksSharkov
    03.09.2025 16:27

    Привет, на FlaUI.WebDriver пробовали переход? Там подобие XPath прикрутили