ci(capturas): workflow propio para las capturas, con snapshot en iOS y screengrab en Android - #154
Merged
Merged
Conversation
`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
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
Contributor
📸 Capturas de pantallaAsí 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. 📱 iPhone7 capturas:
📦 Descargar el artefacto (incluye también las enmarcadas para la tienda) 📱 iPad7 capturas:
📦 Descargar el artefacto (incluye también las enmarcadas para la tienda) 🤖 Android6 capturas:
📦 Descargar el artefacto (incluye también las enmarcadas para la tienda) 🖥️ macOS6 capturas:
📦 Descargar el artefacto (incluye también las enmarcadas para la tienda) |
Im-Fran
marked this pull request as draft
August 14, 2026 19:55
Im-Fran
marked this pull request as ready for review
August 14, 2026 19:55
…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
Im-Fran
marked this pull request as draft
August 15, 2026 05:02
Im-Fran
marked this pull request as ready for review
August 15, 2026 05:02
…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
Im-Fran
marked this pull request as draft
August 15, 2026 05:47
Im-Fran
marked this pull request as ready for review
August 15, 2026 05:47
…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
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 passedy las seis capturas escritas, y recién ahí reventabascripts/generate-screenshot-backgrounds:46:Bash declara todos los locales de una misma sentencia antes de evaluar las asignaciones, así que
$basetodavía no existe cuando se expande${5:-$base/$locale}, y conset -ueso 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 esoscreenshots-macosestá en verde en los tres intentos del run.Qué cambia
1.
screenshots.yml. Los tres jobs de capturas salen dedeploy.ymly quedan en un workflow propio, con los mismos disparadores de antes (push adevyprod, másworkflow_dispatch) y las mismas condiciones de[skip screenshots].deploy.ymlqueda sólo condetermine-environmenty losdeploy-*.2. El recorrido de cada plataforma pasa a la herramienta de fastlane que le corresponde, siguiendo docs.fastlane.tools:
ios/RunnerUITests/ScreenshotsUITests.swiftandroid/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.ktflutter drive(sin cambios)integration_test/screenshots_test.dartEn iOS se siguió el Quick Start de snapshot tal cual: target de UI Tests nuevo (
RunnerUITests),SnapshotHelper.swiftoficial dentro del target,setupSnapshot(app)+app.launch(), unsnapshot("...")por pantalla y la configuración fija enfastlane/Snapfile. En Android, la dependenciatools.fastlane:screengrab, los permisos que pide la guía en el manifest de debug,LocaleTestRule+Screengrab.screenshot(...)en un test JUnit4, yfastlane/Screengrabfile.macOS se queda con
flutter driveporque snapshot recorre la app con un simulador de iOS y para el escritorio no hay equivalente en la documentación.3. Los
--dart-definellegan por otro camino. snapshot compila conxcodebuild, que no entiende--dart-define, así que el lane deja antes los defines enios/Flutter/Generated.xcconfigconflutter build ios --config-only. En Android el APK de la app lo compilaflutter 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 demodoCapturas.Lo que no cambia: las capturas siguen quedando en la misma carpeta y con el mismo nombre (
<Dispositivo>-<pantalla>.pngen iOS), y encima siguen corriendo los fondos,frameity 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:
textField/secureTextField) y que el botón de volver de laAppBaraparezca con alguna de las etiquetas que se prueban (Atrás,Back,Volver); si no, cae al gesto de arrastrar desde el borde.setupSnapshot(app, waitForAnimations: false), pero la espera de inactividad que hace XCUITest por su cuenta es lo primero que podría dar problemas.UiObject2.setTextescriba en los campos de Flutter y que las etiquetas de los accesos rápidos lleguen comocontentDescription.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