Skip to content

ci(capturas): workflow propio para las capturas, con snapshot en iOS y screengrab en Android - #154

Merged
Im-Fran merged 22 commits into
devfrom
claude/cicd-screenshots-workflow-lzf6ui
Aug 16, 2026
Merged

ci(capturas): workflow propio para las capturas, con snapshot en iOS y screengrab en Android#154
Im-Fran merged 22 commits into
devfrom
claude/cicd-screenshots-workflow-lzf6ui

Conversation

@Im-Fran

@Im-Fran Im-Fran commented Aug 14, 2026

Copy link
Copy Markdown
Member

Qué rompía el CI

Los tres jobs de capturas que fallan en el run 31809396713 —iPhone, iPad y Android— morían en el mismo punto, y no era ni fastlane ni el recorrido: los tres terminaban con All tests passed y las seis capturas escritas, y recién ahí reventaba scripts/generate-screenshot-backgrounds:46:

/home/runner/work/MiUTEM/MiUTEM/scripts/generate-screenshot-backgrounds: line 46: base: unbound variable
local base=$1 locale=$2 ancho=$3 alto=$4 textos=${5:-$base/$locale}

Bash declara todos los locales de una misma sentencia antes de evaluar las asignaciones, así que $base todavía no existe cuando se expande ${5:-$base/$locale}, y con set -u eso corta el script. macOS se salvaba de casualidad: su llamada (línea 91) pasa el quinto argumento, y ahí el valor por defecto nunca se evalúa. Por eso screenshots-macos está en verde en los tres intentos del run.

Qué cambia

1. screenshots.yml. Los tres jobs de capturas salen de deploy.yml y quedan en un workflow propio, con los mismos disparadores de antes (push a dev y prod, más workflow_dispatch) y las mismas condiciones de [skip screenshots]. deploy.yml queda sólo con determine-environment y los deploy-*.

2. El recorrido de cada plataforma pasa a la herramienta de fastlane que le corresponde, siguiendo docs.fastlane.tools:

Plataforma Herramienta Recorrido
iPhone y iPad snapshot ios/RunnerUITests/ScreenshotsUITests.swift
Android screengrab android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt
macOS flutter drive (sin cambios) integration_test/screenshots_test.dart

En iOS se siguió el Quick Start de snapshot tal cual: target de UI Tests nuevo (RunnerUITests), SnapshotHelper.swift oficial dentro del target, setupSnapshot(app) + app.launch(), un snapshot("...") por pantalla y la configuración fija en fastlane/Snapfile. En Android, la dependencia tools.fastlane:screengrab, los permisos que pide la guía en el manifest de debug, LocaleTestRule + Screengrab.screenshot(...) en un test JUnit4, y fastlane/Screengrabfile.

macOS se queda con flutter drive porque snapshot recorre la app con un simulador de iOS y para el escritorio no hay equivalente en la documentación.

3. Los --dart-define llegan por otro camino. snapshot compila con xcodebuild, que no entiende --dart-define, así que el lane deja antes los defines en ios/Flutter/Generated.xcconfig con flutter build ios --config-only. En Android el APK de la app lo compila flutter build apk (por los defines) y el de los tests, gradle.

4. La app publica su semántica en modo capturas. Los dos recorridos nativos manejan la app por el árbol de accesibilidad, y Flutter sólo lo publica cuando el sistema declara un cliente de accesibilidad activo: SemanticsBinding.instance.ensureSemantics(), detrás de modoCapturas.

Lo que no cambia: las capturas siguen quedando en la misma carpeta y con el mismo nombre (<Dispositivo>-<pantalla>.png en iOS), y encima siguen corriendo los fondos, frameit y la composición de las dos primeras.

Qué falta verificar

Esto necesita una corrida real: acá no hay macOS, Xcode ni emulador, así que el arreglo del script está probado, pero los dos recorridos nuevos no. Lo que hay que mirar en el primer run:

  • iOS: que XCUITest encuentre los campos del login (Flutter los publica como textField / secureTextField) y que el botón de volver de la AppBar aparezca con alguna de las etiquetas que se prueban (Atrás, Back, Volver); si no, cae al gesto de arrastrar desde el borde.
  • iOS: la app anima siempre (video del login, skeletons). Por eso va setupSnapshot(app, waitForAnimations: false), pero la espera de inactividad que hace XCUITest por su cuenta es lo primero que podría dar problemas.
  • Android: que UiObject2.setText escriba en los campos de Flutter y que las etiquetas de los accesos rápidos lleguen como contentDescription.

Los mensajes de error de los dos recorridos dicen qué se estaba esperando, así que un fallo debería quedar claro en el log sin tener que adivinar.


Generated by Claude Code

claude added 2 commits August 14, 2026 19:03
`generar()` declaraba `textos=${5:-$base/$locale}` en la misma sentencia `local`
que `base`. Bash declara todos los locales de una sentencia antes de evaluar sus
asignaciones, así que con `set -u` la expansión leía `base` sin definir y el
script moría con "base: unbound variable".

Cortaba las capturas de iPhone, iPad y Android justo después del recorrido, con
todas las pantallas ya tomadas. macOS se salvaba de casualidad: su llamada pasa
el quinto argumento, y ahí el valor por defecto nunca se evalúa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…a screengrab

Las capturas salen del workflow de despliegue y quedan en screenshots.yml, con
los mismos disparadores de antes (push a dev y prod, más ejecución manual) y los
tres jobs: iPhone/iPad, Android y macOS.

El recorrido de cada plataforma pasa a la herramienta que le corresponde en
docs.fastlane.tools:

- iOS y iPad usan snapshot. Se agrega el target de UI Tests RunnerUITests al
  proyecto de Xcode con el SnapshotHelper.swift oficial y el recorrido en
  ios/RunnerUITests/ScreenshotsUITests.swift, y la configuración fija vive en
  fastlane/Snapfile. Como snapshot compila con xcodebuild, que no entiende
  --dart-define, el lane deja antes los defines en Generated.xcconfig con
  `flutter build ios --config-only`.
- Android usa screengrab. Se agrega la dependencia tools.fastlane:screengrab,
  los permisos que pide la guía en el manifest de debug y el test instrumentado
  ScreenshotsTest.kt con LocaleTestRule y Screengrab.screenshot(); la
  configuración fija vive en fastlane/Screengrabfile. El APK de la app lo compila
  flutter (por los defines) y el de los tests, gradle.
- macOS sigue con `flutter drive`: snapshot recorre la app con un simulador de
  iOS y para el escritorio no hay equivalente.

Como los dos recorridos nativos manejan la app por el árbol de accesibilidad, en
modo capturas la app fuerza la semántica de Flutter, que si no sólo se publica
cuando el sistema declara un cliente de accesibilidad activo.

El resto del pipeline no cambia: las capturas siguen quedando en la misma carpeta
y con el mismo nombre, y encima siguen corriendo los fondos, frameit y la
composición de las dos primeras.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
github-actions Bot and others added 2 commits August 14, 2026 19:04
El workflow se dispara también al abrir una pull request y al sacarla de
borrador, además del push a dev y prod. Queda fuera `synchronize`: cada corrida
se lleva runners de macOS por casi una hora y repetirla en cada push a la rama no
compensa.

Y en las pull requests un job final deja un comentario con las capturas que salió
de cada plataforma —nombre y tamaño de cada una— y el enlace para descargarlas.
Las imágenes no van embebidas porque GitHub no deja adjuntar archivos a un
comentario desde su API, y el comentario se actualiza en vez de duplicarse en
cada corrida.

El job corre aunque una plataforma falle, para que la PR también muestre cuál se
cayó, y no corre en PR desde un fork, donde el token es de sólo lectura y tampoco
hay secretos con los que generar las capturas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
… en Android

El permiso WRITE_EXTERNAL_STORAGE que pide screengrab lo copié de la guía tal
cual, con `android:maxSdkVersion="18"`, y el plugin file_saver declara el mismo
permiso con 28: el merge de manifests corta el build con

    Attribute uses-permission#android.permission.WRITE_EXTERNAL_STORAGE@maxSdkVersion
    value=(18) ... is also present at [:file_saver] value=(28).

Pisarlo con tools:replace, que es lo que sugiere el error, le sacaría a
file_saver el permiso entre las APIs 19 y 28. Sin el atributo no le quita nada a
nadie y screengrab funciona igual: en las APIs que corren hoy escribe en la
carpeta de la app, que no necesita permiso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…uild de iOS

Comentando "@miutem screenshot <ios|macos|android|all>" en una pull request se
generan las capturas de esa plataforma; sin argumento salen las tres. Como el
evento llega por cualquier comentario del repositorio, el filtro vive en el `if`
de determine-environment, que además exige permiso de escritura a quien comenta:
la corrida usa los secretos del repositorio sobre el código de la rama de la PR.
El comando se acusa con una reacción 👀, porque el run de un comentario no
aparece entre los checks de la PR.

Y se arregla lo que dejó rojos a iPhone y iPad en la primera corrida: la fase de
símbolos de Crashlytics que agrega flutterfire saca la carpeta de paquetes de
Swift de $BUILD_ROOT recortándole todo lo que venga después de "DerivedData/",
y snapshot compila en /var/folders/.../snapshot_derived*, que no tiene ese tramo.
El recorte no hacía nada y el script terminaba buscando los checkouts dentro de
Build/Products, así que la compilación moría antes de correr un solo test. Ahora
el lane fija la carpeta de derived data y deja el enlace donde el script lo
espera, igual que hace el lane de macOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

📸 Capturas de pantalla

Así quedó la app con los datos de demostración. Las imágenes no se pueden mostrar acá —GitHub no deja adjuntarlas desde la API—, así que van en los artefactos del run.

📱 iPhone

7 capturas:

Captura Tamaño
iPhone 17 Pro Max-03_oscuro.png 1320×2868
iPhone 17 Pro Max-04_calculadora.png 1320×2868
iPhone 17 Pro Max-05_inicio.png 1320×2868
iPhone 17 Pro Max-06_credencial.png 1320×2868
iPhone 17 Pro Max-07_horario.png 1320×2868
iPhone 17 Pro Max-08_malla.png 1320×2868
Apple MacBook Pro 16 Space Gray.png 3910×2241

📦 Descargar el artefacto (incluye también las enmarcadas para la tienda)

📱 iPad

7 capturas:

Captura Tamaño
iPad Air 13-inch (M4)-03_oscuro.png 2048×2732
iPad Air 13-inch (M4)-04_calculadora.png 2048×2732
iPad Air 13-inch (M4)-05_inicio.png 2048×2732
iPad Air 13-inch (M4)-06_credencial.png 2048×2732
iPad Air 13-inch (M4)-07_horario.png 2048×2732
iPad Air 13-inch (M4)-08_malla.png 2048×2732
Apple MacBook Pro 16 Space Gray.png 3910×2241

📦 Descargar el artefacto (incluye también las enmarcadas para la tienda)

🤖 Android

6 capturas:

Captura Tamaño
03_oscuro.png 1080×2160
04_calculadora.png 1080×2160
05_inicio.png 1080×2160
06_credencial.png 1080×2160
07_horario.png 1080×2160
08_malla.png 1080×2160

📦 Descargar el artefacto (incluye también las enmarcadas para la tienda)

🖥️ macOS

6 capturas:

Captura Tamaño
03_oscuro.png 2880×1806
04_calculadora.png 2880×1806
05_inicio.png 2880×1806
06_credencial.png 2880×1806
07_horario.png 2880×1806
08_malla.png 2880×1806

📦 Descargar el artefacto (incluye también las enmarcadas para la tienda)

…e la PR

Con `pattern: capturas-*`, download-artifact extrae el artefacto directamente en
`path` cuando calza uno solo, sin crearle la carpeta con su nombre:

    Starting download of artifact to: /home/runner/work/MiUTEM/MiUTEM/capturas
    Artifact download completed successfully.

El script buscaba capturas/capturas-macos/ y el comentario terminó diciendo "no
quedó ningún artefacto" con las seis capturas de macOS al lado. Pidiendo cada
artefacto por su nombre la ruta es siempre la misma, calce uno o los cuatro.

De paso, una carpeta vacía —la deja el paso de descarga cuando el artefacto no
existe— ya no se reporta distinto a que no esté: en los dos casos el motivo sale
del resultado del job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
screengrab instalaba los dos APK sin problema y recién después se caía:

    pm grant ***.dev android.permission.CHANGE_CONFIGURATION
      Failure [package not found]
    am instrument ***.dev.test/androidx.test.runner.AndroidJUnitRunner
      Unable to find instrumentation info

El lane armaba el nombre con APP_PACKAGE_NAME más el sufijo del flavor, y ese
secreto —el de la ficha de Google Play— no calza con el applicationId que queda
instalado. El lane anterior nunca lo notó porque sólo lo usaba para un
`adb uninstall` que ignora el error.

Ahora los dos nombres salen del output-metadata.json que AGP deja junto a cada
APK, que es exactamente lo que quedó instalado; el secreto queda de respaldo por
si el archivo no está. El desinstalado previo se mueve después de compilar, que
es cuando se conocen los nombres de verdad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…x.test

El APK de tests no arma su manifest:

    :app:processDevelopmentDebugAndroidTestManifest FAILED
    android:exported needs to be explicitly specified for element
      <activity#androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity>

androidx.test:core 1.3.0 —la que arrastra screengrab— declara esas tres
actividades sin el atributo, y desde Android 12 el merge lo exige para cualquier
componente con intent-filter. El recorrido no las usa, son de ActivityScenario,
pero vienen en el manifest de la librería igual.

Se declaran en el manifest de androidTest con `tools:node="merge"`, que es lo
único que no depende de la versión: subir androidx.test no es alternativa,
porque AGP alinea las dependencias de los tests con las de la app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…rae en ejecución

El POM de screengrab declara todas sus dependencias con alcance `runtime`: van
en el APK de tests pero no están al compilar el recorrido, así que al dejar sólo
`screengrab` en el bloque de dependencias el Kotlin dejó de resolver
`UiDevice.displayHeight`, `UiObject2.visibleBounds` y `UiObject2.click`.

Se repiten acá UI Automator y el runner en las mismas versiones que ya trae:
subirlas no es opción porque AGP alinea las dependencias de los tests con las de
la app, y ahí androidx.test queda fijo en lo que trae integration_test.

JUnit queda `compileOnly` —sólo se usan sus anotaciones, iguales entre 4.12 y
4.13— para que no entre al classpath de ejecución, donde ya lo pone screengrab,
y no haya versión que alinear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…ests

El log completo del build aclara qué faltaba: los errores no empiezan en el
cuerpo del recorrido sino en los imports, y sólo en los de
`androidx.test.uiautomator`. Los de JUnit y los del registro de instrumentación
resuelven sin declararlos porque el plugin integration_test los expone por el
classpath de la app, del que hereda el de los tests.

Así que el runner sobraba, y JUnit con `compileOnly` en 4.13.2 era además el
mismo error de antes esperando a pasar: AGP alinea el classpath de compilación
de los tests con el de ejecución, donde JUnit llega en la versión de
integration_test, y pedir otra termina en "cannot find a version ... by
consistent resolution". UI Automator queda en 2.2.0, que es la que ya resuelve
screengrab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…había en pantalla

El recorrido ya corre en el emulador —compila, se instala y llega al login—, pero
después de tocar "Ingresar" la navegación nunca aparece. El mensaje del fallo era
sólo "no apareció la navegación principal", que no distingue entre quedarse en el
login, mostrar un error o cambiarle el nombre a la etiqueta.

Ahora cada fallo adjunta los textos y etiquetas que hay en pantalla en ese
momento. Va en el mensaje de la excepción y no en un log aparte porque el
recorrido corre en el emulador de CI y lo único que vuelve es el texto del error.

Y el tecleo deja de depender de una sola vía: se sigue intentando primero por
accesibilidad, que no abre el teclado y por lo tanto no tapa el botón, pero si el
campo queda vacío se enfoca y se mandan las teclas, cerrando después el teclado
para despejar el botón. La sospecha es justamente que Flutter no atiende la
acción de accesibilidad y los campos llegaban vacíos al submit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
claude added 5 commits August 16, 2026 00:25
`By.textMatches`/`By.descMatches` no existen: son de `UiSelector`, la API vieja.
En `By` la variante con expresión regular es `By.text(Pattern)` y `By.desc(Pattern)`,
así que el volcado de pantalla del commit anterior ni siquiera compilaba.

Va con DOTALL porque el patrón se compara contra el texto completo del nodo y una
etiqueta con salto de línea quedaría fuera.

Verificado esta vez contra el classes.jar de uiautomator 2.2.0 en lugar de
recordar la API, junto con el resto de lo que usa el recorrido: clazz, pkg,
clickable, descContains, textContains, Until.hasObject, pressBack, swipe,
displayWidth/Height, UiObject2.setText/getText/click/visibleBounds y el
Screengrab.screenshot y LocaleTestRule de screengrab 2.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
… con el botón

El volcado del commit anterior mostró dónde estaba parado el recorrido: seguía en
el login, con "ecarrenos" ya escrito y el teclado abierto tapando la mitad de
abajo de la pantalla, que es donde está "Ingresar".

UI Automator no se entera de eso. Calcula lo que se ve de un nodo contra los
límites de la ventana de la app, sin considerar que el teclado va encima, así que
el botón parece visible, el toque se da por bueno y aterriza en una tecla. La
comprobación de `visibleBounds` que ya había no podía atraparlo.

Se envía entonces con la tecla de acción del teclado, que en este formulario pasa
el foco del usuario a la contraseña y desde la contraseña envía: el mismo camino
que terminó funcionando en iOS por una razón parecida. El botón queda de
respaldo, y ahí sí se cierra antes el teclado.

De paso, escribir deja de intentarlo por accesibilidad y por teclado a la vez: en
un campo con la contraseña oculta no hay forma de leer de vuelta el valor para
saber si la primera vía funcionó, y el riesgo era escribir el texto dos veces.
Y el volcado ahora incluye el contenido de cada campo de texto, que en la lista
de lo visible no aparece justo cuando está vacío, que es lo que hay que saber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
… de la app

El volcado dejó claro dónde terminaba el recorrido: en el escritorio de Android,
entre Gmail, Chrome y el icono de Mi UTEM Dev. La app ya no estaba.

Lo hacía el propio camino de respaldo. Estaba pensado para cuando la tecla de
acción no envía el formulario, y cerraba el teclado con `pressBack` antes de
tocar "Ingresar", pero la tecla de acción ya lo había cerrado al enviar: ahí
`pressBack` no cierra nada, navega, y desde el login eso es salir de la app. Se
disparaba además a los tres segundos de enviar, que es menos de lo que tarda la
app en montar la navegación aunque el ingreso haya funcionado.

Se quita: después de enviar se espera la navegación como cualquier otra pantalla,
con el mismo límite que el resto del recorrido.

El volcado ahora dice también qué paquete está en pantalla. Es lo primero que hay
que mirar —si no es el de la app, buscarle sentido al resto no lleva a ninguna
parte— y habría hecho evidente esto de una.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
El ingreso funcionaba: el volcado del fallo trae la pantalla de inicio entera con
los datos de demostración —"Ernesto", "Clases de Hoy", el horario del día— y
abajo las cinco pestañas. La navegación estaba ahí y el recorrido no la veía.

La razón está en cómo compara UI Automator. `descContains`/`textContains`
compilan `^.*texto.*$` sin DOTALL, y `.` no cruza saltos de línea. Flutter le pone
a cada pestaña la etiqueta y el índice separados por uno, así que la de inicio
llega como "Inicio\nTab 1 of 5" y no la encontraba ninguna de las dos. Con
cualquier etiqueta de una sola línea —"Ingresar", los campos del login— el
problema no se nota, que es por qué el recorrido avanzaba hasta acá.

Se arma el patrón a mano, con DOTALL. Comprobado contra el jar de uiautomator
2.2.0 y con un repro: "Inicio\nTab 1 of 5" da false con el patrón de la librería
y true con este.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
…or el nombre

El recorrido de Android pasa: "OK (1 test)" en 154s, con las capturas tomadas y
traídas del emulador. Lo que fallaba era el paso siguiente, al generar el reporte
HTML, y la causa era el nombre que le puse a la carpeta de paso.

Al armar el destino, screengrab le quita a la ruta lo que coincida con
`(app_)?screengrab/`. Está pensado para el app_screengrab/ del dispositivo, pero
también muerde `.screengrab`, así que las capturas terminaban un nivel más arriba
—en screenshots/es-419/…— mientras el reporte se escribía en la carpeta original,
que por lo mismo nunca se creó, y reventaba con ENOENT.

Con `.capturas` no hay coincidencia: las capturas y el reporte quedan en el mismo
lugar, que además es de donde el lane las recoge para moverlas.

Comprobado contra el runner.rb de screengrab 2.238.0 y con un repro que reproduce
la ruta del log tal cual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7bXmFibMMaL1ReZCAHF4n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants