Перейти к содержимому

Тестирование десктоп-приложений

tapstep водит десктоп-приложения так же, как браузер: снимает экран, тапает по селектору, проверяет появившееся. Целью может быть любое Tauri/webview- приложение со встроенным мостом tapstep, а флоу выглядят точно как веб-флоу — селекторы разбираются тем же обходом DOM.

Мост работает через собственный eval Tauri, а не через CDP, поэтому один и тот же файл флоу идёт на macOS, Windows и Linux без отдельного драйвера под каждую ОС.

Окно не водит само себя. Всегда есть цель — приложение под тестом, поднятое с включённым мостом, — и контрол, который её водит: CLI tapstep или ваше обычное окно десктопа.

Terminal window
# цель: ваше приложение с включённым мостом
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 в списке устройств десктопа.

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.

В чате нет выбора устройства. Прогоните флоу один раз на устройстве Desktop app (или попросите агента взять устройство app) — агент водит устройство последнего прогона и переключается своим инструментом select_device. Он снимет экран цели, напишет .flow.yaml, прогонит его против цели и починит селекторы — всё в окне цели, не в своём. Та же цель доступна внешнему агенту по MCP:

Terminal window
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 на своей стороне, так что приложению не нужно ничего знать о селекторах и флоу.

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.