W3C Actions en Appium: gestos programáticos con Drawing

DrawingPage con W3C Actions para dibujar en canvas, menú lateral, permisos del sistema, Inspector Gestures y Recorder. 19 tests, 10 pages.

Recorder de Appium Inspector mostrando código Java generado con PointerInput y Sequence para swipe y taps
El Recorder capturó el dibujo, Save y Clear. Genera W3C Actions para todo, incluso taps. En mi framework usé findElement().click() para botones y reservé W3C Actions para el canvas.

CONTEXTO

Hasta el post anterior Checkout completo en Appium: del carrito a la confirmación de compra, tenía 16 tests que cubrían el flujo de compra completo: catálogo, detalle, carrito, checkout. Todos usando findElement().click(), sendKeys(), UiScrollable.

Pero hay interacciones en mobile que no se resuelven con clicks ni con scroll: dibujar en un canvas, hacer swipe con control de velocidad, arrastrar elementos. Para eso existen las W3C Actions.

My Demo App tiene una pantalla "Drawing" con un canvas donde podés dibujar con el dedo. Es el caso de uso perfecto: no hay un botón que hacer click, no hay un campo donde escribir. Hay que simular el dedo tocando la pantalla, moviéndose, y soltando.


Por qué W3C Actions y no TouchAction

TouchAction fue la API de gestos de Appium durante años. Pero está deprecated desde Appium 2.x. El reemplazo es W3C Actions, que es el estándar del W3C WebDriver.

La diferencia:

// TouchAction (deprecated)
new TouchAction(driver)
    .press(point(200, 600))
    .moveTo(point(800, 1200))
    .release()
    .perform();

// W3C Actions (actual)
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence trazo = new Sequence(finger, 1);
trazo.addAction(finger.createPointerMove(Duration.ZERO,
        PointerInput.Origin.viewport(), 200, 600));
trazo.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
trazo.addAction(finger.createPointerMove(Duration.ofMillis(1000),
        PointerInput.Origin.viewport(), 800, 1200));
trazo.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(trazo));

Más verboso, sí. Pero es el estándar actual y no va a desaparecer. TouchAction puede dejar de funcionar en cualquier actualización de Appium.


W3C Actions vs UiScrollable

Ya usaba UiScrollable para scroll en el catálogo:

driver.findElement(AppiumBy.androidUIAutomator(
    "new UiScrollable(new UiSelector().scrollable(true))" +
    ".scrollTextIntoView(\"Sauce Labs Backpack (yellow)\")"
));

¿Cuándo usar cada uno?

UiScrollable: cuando necesitás llegar a un elemento por texto. Android lo busca solo, scrollea las veces que haga falta. No controlás coordenadas ni velocidad.

W3C Actions: cuando necesitás control total del gesto. Definís exactamente dónde empieza el dedo, dónde termina, y cuánto tarda. Sirve para dibujar, arrastrar, swipe con velocidad específica, gestos complejos.

En este post uso W3C Actions para dibujar en el canvas de Drawing. UiScrollable no sirve ahí — no hay texto que buscar ni scroll que hacer. Hay que simular un dedo moviéndose por la pantalla.


Explorando Drawing con Appium Inspector

Antes de codear necesitaba los locators de la pantalla Drawing. Nunca la había explorado con Inspector.

El menú lateral

Appium Inspector con menú lateral de My Demo App y locator del item Drawing seleccionado
Menú lateral. Todas las opciones comparten el resource-id itemTV. Para seleccionar Drawing hay que filtrar por texto.

El menú se abre con el ícono hamburguesa (accessibilityId: "View menu"). Cada item del menú tiene el mismo resource-id: com.saucelabs.mydemoapp.android:id/itemTV. Para seleccionar "Drawing" hay que buscar por texto entre todos los items.

La pantalla Drawing

Appium Inspector mostrando pantalla Drawing con canvas signature_pad seleccionado y bounds visibles
El canvas es un View con id signature_pad. Bounds: (38, 471) → (1042, 2142). Coordenadas seguras para dibujar: entre (200, 600) y (800, 1200).

Locators capturados:

Elemento Estrategia Selector
Título id drawingTV
Canvas id signature_pad
Clear id clearBtn
Save id saveBtn

Los bounds del canvas son enormes: ~1000×1670 px. Coordenadas seguras para dibujar: entre (200, 600) y (800, 1200).

Un detalle: el canvas no cambia ningún atributo cuando tiene un dibujo. Inspector muestra los mismos valores antes y después de dibujar. Eso significa que no puedo hacer assertion sobre el canvas directamente. La assertion va por el flujo completo: dibujar → Save → verificar mensaje de confirmación.

Save con canvas vacío

Probé tocar Save sin dibujar nada. Resultado: aparece el mismo mensaje "Drawing saved successfully to gallery". La app no distingue entre un canvas vacío y uno con dibujo. Eso limita las assertions posibles, pero para un post de gestos el valor está en la técnica del gesto, no en la lógica de negocio.


Appium Inspector: Gestures y Recorder

Descubrí dos pestañas de Inspector que no había usado:

Gestures — editor visual de gestos

Gesture Builder de Appium Inspector con secuencia Move, Pointer Down, Move y Pointer Up sobre el canvas
Gesture Builder: la secuencia W3C Actions armada visualmente. Move → Down → Move (249ms) → Up. Las coordenadas (206, 595) → (802, 1200) caen dentro del canvas.

El Gesture Builder permite armar gestos W3C Actions de forma visual. Definís los puntos de inicio y fin, la duración, y lo ejecutás directamente en el dispositivo. La línea se dibujó en la app real.

Dato que descubrí: W3C Actions trabaja a nivel del sistema operativo, no de la app. Las coordenadas son absolutas del viewport. Si las coordenadas caen fuera de la app, Android responde con gestos del sistema. Por eso es crítico calcular bien los bounds del canvas.

Recorder — grabador de acciones

Recorder de Appium Inspector mostrando código Java generado con PointerInput y Sequence para swipe y taps
El Recorder capturó el dibujo, Save y Clear. Genera W3C Actions para todo, incluso taps. En mi framework usé findElement().click() para botones y reservé W3C Actions para el canvas.

El Recorder graba las acciones que hacés en el dispositivo y genera código en Java (o Python, JS). El código que generó para el swipe:

final var finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
var start = new Point(212, 742);
var end = new Point(725, 1624);
var swipe = new Sequence(finger, 1);
swipe.addAction(finger.createPointerMove(Duration.ofMillis(0),
    PointerInput.Origin.viewport(), start.getX(), start.getY()));
swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
swipe.addAction(finger.createPointerMove(Duration.ofMillis(1000),
    PointerInput.Origin.viewport(), end.getX(), end.getY()));
swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(swipe));

Usé este código como referencia para mi DrawingPage. La diferencia: el Recorder genera taps por coordenadas para todo (Save, Clear). En mi framework uso findElement().click() para botones — es más robusto y legible. W3C Actions las reservé para el canvas, que es donde realmente las necesito.


swipe() en BasePage

Agregué un método genérico de swipe en BasePage. W3C Actions reutilizable:

public void swipe(int startX, int startY, int endX, int endY, int durationMs) {
    PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
    Sequence swipe = new Sequence(finger, 1);

    swipe.addAction(finger.createPointerMove(Duration.ZERO,
            PointerInput.Origin.viewport(), startX, startY));
    swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
    swipe.addAction(finger.createPointerMove(Duration.ofMillis(durationMs),
            PointerInput.Origin.viewport(), endX, endY));
    swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));

    driver.perform(Arrays.asList(swipe));
}

La secuencia es siempre la misma: mover el puntero al punto inicial → bajar el dedo → mover al punto final (con duración) → soltar. durationMs controla la velocidad del gesto.

Imports nuevos en BasePage:

import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import java.time.Duration;
import java.util.Arrays;

Hasta ahora toda la navegación era por taps en elementos de la app: productos, botones, links. Drawing requiere abrir el menú lateral — algo nuevo en el framework.

Agregué abrirMenu() e irADrawing() en ProductsPage:

private static final String MENU_ITEM_ID = "com.saucelabs.mydemoapp.android:id/itemTV";

public void abrirMenu() {
    driver.findElement(AppiumBy.accessibilityId("View menu")).click();
    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id(MENU_ITEM_ID)
    ));
}

public DrawingPage irADrawing() {
    abrirMenu();
    driver.findElements(AppiumBy.id(MENU_ITEM_ID)).stream()
            .filter(e -> e.getText().equals("Drawing"))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("No se encontró 'Drawing' en el menú"))
            .click();

    // Manejar permiso si aparece ANTES de esperar drawingTV
    try {
        WebDriverWait shortWait = new WebDriverWait(driver, Duration.ofSeconds(3));
        shortWait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id("com.android.permissioncontroller:id/permission_allow_button")
        )).click();
    } catch (Exception e) {
        // Permiso ya otorgado, no aparece
    }

    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/drawingTV")
    ));
    return new DrawingPage(driver, wait);
}

El menú tiene todos los items con el mismo resource-id (itemTV). Usé streams de Java para filtrar por texto. Podría haber usado XPath, pero .stream().filter() es más limpio.


Error 1: el permiso que bloquea todo

Los 4 tests fallaron en la primera corrida. Todos con el mismo error: TimeoutException esperando drawingTV o clearBtn.

El problema: al entrar a Drawing, la app pide permiso de acceso a fotos. El diálogo del sistema bloquea toda la pantalla. Appium no puede encontrar ningún elemento de Drawing porque el diálogo está encima.

Diálogo de permisos de Android sobre la pantalla Drawing de My Demo App pidiendo acceso a fotos
El permiso aparece al entrar a Drawing, no al tocar Save. Bloqueó los 4 tests en la primera corrida hasta que lo manejé dentro de irADrawing().

Había asumido que el permiso aparecería al tocar Save. No. Aparece al entrar a la pantalla. Mi irADrawing() original esperaba drawingTV inmediatamente después del click, pero el diálogo estaba encima.

La solución: manejar el permiso dentro de irADrawing(), antes de esperar drawingTV. Usé un WebDriverWait corto (3 segundos) con try/catch — si el permiso ya fue otorgado en una ejecución anterior, el catch lo ignora y continúa.

Después de este fix, el autoGrantPermissions del setUp importa:

options.setCapability("appium:autoGrantPermissions", false);

Con false, el diálogo aparece y el test lo maneja. Con true (o sin ponerlo), Android otorga permisos automáticamente y el try/catch cae en el catch silenciosamente. Ambos funcionan, pero con false el test es más completo.


Error 2: el test de swipe que no tenía sentido

Armé un cuarto test: swipeEnCatalogoConW3CActions. Hacía un swipe con W3C Actions en el catálogo y verificaba que apareciera un producto.

Falló porque buscaba un producto con texto incorrecto. Pero al analizarlo, el problema era más profundo: ya hacía scroll en el catálogo con UiScrollable en otros tests. El swipe con W3C Actions sobre el catálogo era hacer lo mismo con otra técnica, sin agregar valor.

Lo eliminé. Los 3 tests de Drawing demuestran W3C Actions con un caso de uso real — dibujar en canvas, donde no hay otra forma de interactuar. El catálogo ya está cubierto con UiScrollable.


DrawingPage

La page nueva completa:

package pages;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;
import java.util.Arrays;

public class DrawingPage extends BasePage {

    // Pantalla
    private static final String TITLE = "com.saucelabs.mydemoapp.android:id/drawingTV";
    private static final String CANVAS = "com.saucelabs.mydemoapp.android:id/signature_pad";
    private static final String CLEAR_BTN = "com.saucelabs.mydemoapp.android:id/clearBtn";
    private static final String SAVE_BTN = "com.saucelabs.mydemoapp.android:id/saveBtn";

    // Diálogo de confirmación
    private static final String ALERT_TITLE = "com.saucelabs.mydemoapp.android:id/alertTitle";
    private static final String ALERT_MESSAGE = "android:id/message";
    private static final String ALERT_OK = "android:id/button1";

    public DrawingPage(AndroidDriver driver, WebDriverWait wait) {
        super(driver, wait);
    }

    public String obtenerTitulo() {
        return driver.findElement(AppiumBy.id(TITLE)).getText();
    }

    public void dibujar(int startX, int startY, int endX, int endY) {
        PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
        Sequence trazo = new Sequence(finger, 1);

        trazo.addAction(finger.createPointerMove(Duration.ZERO,
                PointerInput.Origin.viewport(), startX, startY));
        trazo.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
        trazo.addAction(finger.createPointerMove(Duration.ofMillis(1000),
                PointerInput.Origin.viewport(), endX, endY));
        trazo.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));

        driver.perform(Arrays.asList(trazo));
    }

    public void limpiarCanvas() {
        driver.findElement(AppiumBy.id(CLEAR_BTN)).click();
    }

    public void tapSave() {
        driver.findElement(AppiumBy.id(SAVE_BTN)).click();
    }

    public String obtenerMensajeConfirmacion() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id(ALERT_MESSAGE)
        )).getText();
    }

    public void cerrarAlerta() {
        driver.findElement(AppiumBy.id(ALERT_OK)).click();
    }

    public String guardarYObtenerMensaje() {
        tapSave();
        String mensaje = obtenerMensajeConfirmacion();
        cerrarAlerta();
        return mensaje;
    }
}

El método dibujar() recibe coordenadas absolutas. Es intencional: las coordenadas dependen del dispositivo y de los bounds del canvas. En mi Motorola G51, el canvas va de (38, 471) a (1042, 2142). Coordenadas (200, 600) → (800, 1200) caen dentro del canvas con margen.


Los 3 tests

@Test
public void navegarADrawing() {
    DrawingPage drawingPage = productsPage.irADrawing();
    Assert.assertEquals(drawingPage.obtenerTitulo(), "Drawing");
}

Verifica que el menú lateral funciona y que la navegación llega a Drawing. Incluye el manejo del permiso de fotos.

@Test
public void dibujarYGuardar() {
    DrawingPage drawingPage = productsPage.irADrawing();
    drawingPage.dibujar(200, 600, 800, 1200);

    String mensaje = drawingPage.guardarYObtenerMensaje();
    Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
}

El test E2E de gestos: navegar → dibujar con W3C Actions → Save → verificar confirmación. Si "Drawing saved successfully to gallery" aparece, el gesto funcionó y el flujo completo pasó.

@Test
public void dibujarLimpiarYGuardar() {
    DrawingPage drawingPage = productsPage.irADrawing();
    drawingPage.dibujar(200, 600, 800, 1200);
    drawingPage.limpiarCanvas();

    String mensaje = drawingPage.guardarYObtenerMensaje();
    Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
}

Dibujar, limpiar con Clear, guardar. Verifica que Clear funciona y que el flujo Save sigue funcionando después de limpiar.


19 tests verdes

IntelliJ mostrando 19 tests pasados incluyendo GestosTest con navegarADrawing, dibujarYGuardar y dibujarLimpiarYGuardar
19 de 19. Los 3 tests de gestos no rompieron nada de lo existente.
Tests passed: 19 of 19 tests – 11 min 45 sec

CartTest                    2 min 16 sec
  agregarDosProductosDistintos
  eliminarProductoDelCarrito
  verificarCantidadMultipleEnCarrito
  verificarProductoEnCarrito

LoginTest                   2 min 9 sec
  loginConCamposVacios
  loginConClearYReingreso
  loginExitoso
  scrollHastaProducto

GestosTest                  1 min 34 sec
  dibujarLimpiarYGuardar         6 sec
  dibujarYGuardar                5 sec
  navegarADrawing                3 sec

CheckoutTest                3 min 48 sec
  checkoutCompleto
  continuarComprandoVuelveAProducts
  verificarCheckoutComplete
  verificarDatosEnReview

ProductDetailTest           1 min 57 sec
  agregarAlCarrito
  modificarCantidad
  seleccionarColor
  verificarDetalleProducto

Los tests de gestos son rápidos (~3-6 sec) comparados con checkout (~30 sec). No hay flujo de compra, no hay formularios. Solo navegación, gesto y verificación.


Errores del post

Error Causa Solución
TimeoutException en todos los tests al entrar a Drawing Diálogo de permisos bloquea la pantalla antes de que aparezca drawingTV Manejar permiso con try/catch y shortWait dentro de irADrawing()
swipeEnCatalogoConW3CActions fallaba y no aportaba valor UiScrollable ya cubre scroll en catálogo; W3C Actions para catálogo es redundante Eliminar el test; W3C Actions demostradas en Drawing donde realmente se necesitan

Estructura actual

appium-java-framework/
├── src/
│   ├── main/java/
│   │   └── pages/
│   │       ├── BasePage.java              (+ swipe con W3C Actions)
│   │       ├── CartPage.java
│   │       ├── CheckoutCompletePage.java
│   │       ├── CheckoutPaymentPage.java
│   │       ├── CheckoutShippingPage.java
│   │       ├── DrawingPage.java           ← nuevo
│   │       ├── LoginPage.java
│   │       ├── ProductDetailPage.java
│   │       ├── ProductsPage.java          (+ abrirMenu, irADrawing)
│   │       └── ReviewOrderPage.java
│   └── test/java/
│       └── tests/
│           ├── CartTest.java
│           ├── CheckoutTest.java
│           ├── GestosTest.java            ← nuevo
│           ├── LoginTest.java
│           ├── PrimerTest.java
│           └── ProductDetailTest.java
├── apk/
│   └── mda-2.2.0-25.apk
└── pom.xml

10 pages, 6 test classes, 19 tests. Primera interacción fuera del flujo de compra: menú lateral, canvas, permisos del sistema, diálogos de confirmación.


Estado actual

  • 10 pages: BasePage, ProductsPage, LoginPage, ProductDetailPage, CartPage, CheckoutShippingPage, CheckoutPaymentPage, ReviewOrderPage, CheckoutCompletePage, DrawingPage
  • 19 tests: 4 login, 4 product detail, 4 cart, 4 checkout, 3 gestos
  • Nuevo: W3C Actions, menú lateral, permisos del sistema, Appium Inspector Gestures/Recorder
  • Próximo post: permisos y ciclo de vida de la app

Repo: github.com/cesarbeassuarez/appium-java-framework