Тестирование десктоп-приложений
tapstep водит десктоп-приложения так же, как браузер: снимает экран, тапает по селектору, проверяет появившееся. Целью может быть любое Tauri/webview- приложение со встроенным мостом tapstep, а флоу выглядят точно как веб-флоу — селекторы разбираются тем же обходом DOM.
Мост работает через собственный eval Tauri, а не через CDP, поэтому один и тот
же файл флоу идёт на macOS, Windows и Linux без отдельного драйвера под каждую ОС.
Модель двух копий
Заголовок раздела «Модель двух копий»Окно не водит само себя. Всегда есть цель — приложение под тестом, поднятое с включённым мостом, — и контрол, который её водит: CLI tapstep или ваше обычное окно десктопа.
# цель: ваше приложение с включённым мостомTAPSTEP_TEST_BRIDGE=9223 /path/to/your-app# контрол: гоняем флоуtapstep test flows/login.flow.yaml --driver app:9223Просто --driver app означает порт 9223. Без переменной окружения мост мёртв —
порт не открывается и ничего не инжектится, так что released-сборку нельзя
случайно увести.
Ключа в шапке флоу для этой цели нет: выбирайте её через --driver app[:port]
в CLI или строкой Desktop app в списке устройств десктопа.
Объявление в config.yaml
Заголовок раздела «Объявление в config.yaml»app: port: 9223 # порт моста (по умолчанию 9223) launch: ["./my-app"] # чем поднимать цель; без него — только attachРаботающий мост подхватывается в любом проекте: диалог Run пишет Desktop app
detected on :9223, а в списке устройств появляется строка Desktop app
(:9223). app.launch лишь добавляет кнопку Launch app, которая поднимет
цель за вас; у цели, которую запустил десктоп, есть кнопка Stop, у
присоединённой — нет. app.port переносит пробу с 9223.
Тесты пишет AI
Заголовок раздела «Тесты пишет AI»В чате нет выбора устройства. Прогоните флоу один раз на устройстве Desktop
app (или попросите агента взять устройство app) — агент водит устройство
последнего прогона и переключается своим инструментом select_device. Он
снимет экран цели, напишет .flow.yaml, прогонит его против цели и починит
селекторы — всё в окне цели, не в своём. Та же цель доступна внешнему агенту по
MCP:
tapstep mcp --driver app:9223Встроить мост в своё приложение
Заголовок раздела «Встроить мост в своё приложение»Мост — маленький loopback eval-RPC, который включается только при заданной
TAPSTEP_TEST_BRIDGE=<порт>. Протокол: построчный JSON по TCP на
127.0.0.1:<порт>, один объект на строку, id — целое число:
{id, js}→ выполнитьjsв вебвью с меткойmain; ответ —{id, result}с JSON-сериализуемым значением (undefinedстановитсяnull) или{id, error}с текстом исключения.{id, screenshot: true}→ ответ{id, result}, гдеresult— base64 PNG окна; пустая строка означает, что снимок недоступен.
Минимальная реализация под Tauri из двух частей. Агент, инжектируемый на каждой загрузке страницы, выполняет JS и возвращает результат по IPC:
// инжектится раз на загрузку страницы (только когда задана переменная окружения)window.__tapstepBridge = { run(id, js) { let result = null, error = null; try { result = (0, eval)(js); } catch (e) { error = String(e && e.stack || e); } if (result === undefined) result = null; window.__TAURI_INTERNALS__.invoke("bridge_result", { id, result, error }); },};Слушатель отдаёт каждую строку этому агенту и отвечает, когда вернётся
bridge_result:
// только при заданной TAPSTEP_TEST_BRIDGE: 127.0.0.1:<порт>, один JSON на строкуasync fn handle_conn(app: AppHandle, stream: TcpStream) { let (read, mut write) = stream.into_split(); let mut lines = BufReader::new(read).lines(); while let Ok(Some(line)) = lines.next_line().await { let Ok(req) = serde_json::from_str::<Value>(&line) else { continue }; let Some(id) = req.get("id").and_then(Value::as_u64) else { continue }; let resp = if req.get("screenshot").and_then(Value::as_bool) == Some(true) { json!({ "id": id, "result": capture_window_png_base64().unwrap_or_default() }) } else if let Some(js) = req.get("js").and_then(Value::as_str) { let win = app.get_webview_window("main").expect("main window"); let (tx, rx) = oneshot::channel(); PENDING.lock().unwrap().insert(id, tx); // завершается в bridge_result win.eval(&format!("window.__tapstepBridge.run({id}, {})", json!(js))).ok(); match tokio::time::timeout(Duration::from_secs(15), rx).await { Ok(Ok((result, None))) => json!({ "id": id, "result": result }), Ok(Ok((_, Some(error)))) => json!({ "id": id, "error": error }), _ => json!({ "id": id, "error": "webview eval timed out" }), } } else { continue }; if write.write_all(format!("{resp}\n").as_bytes()).await.is_err() { break; } }}
#[tauri::command]fn bridge_result(id: u64, result: Value, error: Option<String>) { if let Some(tx) = PENDING.lock().unwrap().remove(&id) { let _ = tx.send((result, error)); }}Это весь контракт — драйвер собирает каждую операцию как JS на своей стороне, так что приложению не нужно ничего знать о селекторах и флоу.
tapstep тестирует этим сам себя
Заголовок раздела «tapstep тестирует этим сам себя»launch: [self] поднимает копию tapstep как цель: изолированная папка данных,
унаследованная сессия, без экранов первого запуска. На этой копии гоняется
собственный набор тестов десктопа — и это же честный ответ на вопрос «а сами
пользуетесь?».
Ограничения
Заголовок раздела «Ограничения»У app-драйвера только базовая поверхность — снимок, тап, ввод, свайп, несколько клавиш, скриншот — и ни одной из опциональных возможностей других драйверов:
- Селекторы
css:,openLink,longPressOn,clearText,hideKeyboard,clearState,grantPermissions,setLocationи остальные команды на этих возможностях падают сnot supported. - Нет захвата сети и консоли — в отчётах не будет контекста упавших запросов.
- Нет скринкаста: live-зеркало — это опрашиваемые скриншоты, а
--video— монтаж покадровых снимков по шагам, а не настоящая запись. pressKeyпокрываетEnter,Backspace,Tab,Escape; комбинации клавиш не поддерживаются.swipe/scrollэмулируются событием wheel плюсscrollBy.- Тапы — синтетические DOM-события по элементу под точкой; нативные меню, системные диалоги и выбор файлов недостижимы.
- Водится только вебвью с меткой
main; каждый запрос к мосту обрывается по таймауту через 15 с. - Драйвер сообщает
platform: web, поэтому условияplatform:идут по веб-ветке.
Заметки
Заголовок раздела «Заметки»- Только там, где приложение рисуется. Окно должно реально открыться — десктоп или CI-раннер с графической сессией. Headless-вебвью не бывает.
- Скриншоты снимаются нативно с окна и питают live-зеркало и визуальные ассерты. На macOS первый снимок попросит разрешение Screen Recording; если отказать, флоу продолжат идти, просто без кадров.
- Идентификатор устройства —
appдля порта по умолчанию иapp:<порт>в остальных случаях: ровно та же строка, что понимает CLI.