Saqué mi plugin de Moodle del árbol de Moodle (y se me rompió el git push)

Saqué mi plugin de Moodle del árbol de Moodle (y se me rompió el git push)
ContenidoContents

Durante años, mi plugin mod_jitsi vivió donde viven casi todos: dentro del propio Moodle, en mod/jitsi. Funciona, pero arrastra un problema silencioso: el plugin queda atado a ese checkout de Moodle. Si quiero probarlo en otra versión, toca copiar ficheros, o mantener el código duplicado, o acordarme de sincronizar a mano. Un lío.

Así que decidí sacarlo a su propio repositorio, fuera del árbol de Moodle, y enlazarlo con symlinks dentro de las instalaciones donde lo necesito. Un único repo, varias versiones de Moodle apuntando al mismo código. Estaba decidido a atajar de una vez ese lío de tener el plugin atado a un solo checkout, así que hice un refactor grande, git add, git commit, git push… y el push se rompió.

Lo que parecía un cambio de fontanería inofensivo destapó tres cosas. Te las cuento.

Un repo, varias versiones de Moodle

La idea del repo standalone es sencilla: el plugin vive en un sitio neutro, por ejemplo ~/moodles/plugins/mod_jitsi, y desde cada Moodle creo un symlink que apunta ahí:

  • Moodle 4.5 → ~/moodles/stable_405/moodle/mod/jitsi
  • Moodle 5.2 → ~/moodles/stable_502/moodle/public/mod/jitsi

(Sí, en Moodle 5.x el código se ha mudado bajo public/. Lo retomo más abajo, porque da guerra.)

La ventaja es enorme: edito una vez y el cambio está, a la vez, en las dos instalaciones. Puedo probar el mismo plugin contra dos versiones de Moodle sin duplicar nada. Y justo eso es lo que, sin saberlo todavía, iba a salvarme un rato después.

El push que se rompió

El repo trae un hook de git pre-push (en scripts/pre-push) que, antes de dejar subir nada, pasa dos filtros: el code sniffer (phpcs) y la testsuite de PHPUnit. Si algo falla, no hay push. Es mi red de seguridad.

Para encontrar PHPUnit, el hook hacía esto:

1PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
2MOODLE_ROOT="$(cd "${PLUGIN_DIR}/../.." && pwd)"   # asume mod/jitsi
3PHPUNIT="${MOODLE_ROOT}/vendor/bin/phpunit"

¿Ves el ../..? Cuando el plugin vivía en mod/jitsi, subir dos niveles desde el plugin te dejaba justo en la raíz de Moodle, donde está vendor/bin/phpunit. Funcionaba perfecto… mientras el plugin viviera ahí.

Pero ahora el repo vive en ~/moodles/plugins/mod_jitsi. Subir dos niveles desde ahí te lleva a ~/moodles, que no es un Moodle: no tiene vendor/bin/phpunit ni nada parecido. El hook no encontraba PHPUnit, salía con exit 1 y cancelaba el push. (El sniffer pasaba sin problema; lo único roto era localizar los tests.)

La lección de fondo ya asoma aquí: el tooling que asume la estructura de directorios se rompe en cuanto cambias esa estructura. Ese ../.. era una bomba de relojería esperando a que yo moviera el plugin de sitio.

Solución 1: detectar en vez de asumir

Reescribí la detección para que el hook busque un Moodle con PHPUnit, en lugar de dar por hecho dónde está. Con prioridades:

  1. Una variable de entorno, MOD_JITSI_MOODLE_ROOT, por si quiero forzar a mano contra qué instalación testear.
  2. El layout clásico ../.., por si algún día el plugin vuelve a vivir dentro de un Moodle.
  3. Una lista de instalaciones conocidas, prefiriendo la versión más nueva que tenga PHPUnit instalado (5.2 antes que 4.5).
 1detect_moodle_root() {
 2    [ -n "$MOD_JITSI_MOODLE_ROOT" ] && [ -f "$MOD_JITSI_MOODLE_ROOT/vendor/bin/phpunit" ] \
 3        && { echo "$MOD_JITSI_MOODLE_ROOT"; return 0; }
 4    local classic; classic="$(cd "$PLUGIN_DIR/../.." 2>/dev/null && pwd)"
 5    [ -f "$classic/vendor/bin/phpunit" ] && [ -f "$classic/version.php" ] \
 6        && { echo "$classic"; return 0; }
 7    for c in ~/moodles/stable_502/moodle ~/moodles/stable_405/moodle; do
 8        [ -f "$c/vendor/bin/phpunit" ] && { echo "$c"; return 0; }
 9    done
10    return 1
11}

Y un cambio de actitud que, para mí, es lo más importante: si no encuentra PHPUnit, el hook avisa y deja pasar el push, en vez de cancelarlo. El estilo de código sí es innegociable; los tests se ejecutan si se pueden. ¿Por qué? Porque un hook que te bloquea por algo que no es culpa de tu código acaba muerto: lo desactivas a base de git push --no-verify y, el día que de verdad importa, ya no te protege. Un guardia que da demasiados falsos positivos es un guardia al que dejas de hacer caso.

Solución 2: probar contra dos versiones a la vez (y lo que destapó)

Aquí llegó el premio inesperado del repo standalone. Como el mismo código está symlinkeado en 4.5 y en 5.2, podía correr la misma testsuite contra dos entornos distintos.

La de 4.5 ya tenía PHPUnit montado. Preparé la de 5.2 (que aún no): composer install, configurar $CFG->phpunit_prefix y $CFG->phpunit_dataroot en config.php, e inicializar el entorno de tests:

1php public/admin/tool/phpunit/cli/init.php

Ojo a ese public/: en Moodle 5.x el CLI de PHPUnit vive bajo public/, no donde estaba en 4.x. Es el tipo de detalle que te hace perder diez minutos buscando un fichero que “ha desaparecido”.

Lancé la testsuite del plugin —son más de 150 tests— y todos pasaban en ambas versiones. Pero en 5.2, PHPUnit los remataba con un aviso repetido una y otra vez:

Metadata found in doc-comment for method mod_jitsi\external_test::test_register_push_subscription_creates_record().
Metadata in doc-comments is deprecated and will no longer be supported in PHPUnit 12.
Use attributes instead.

Una deprecation por cada test con metadatos en el comentario. Como tenía un @covers por test, eran unos cuantos cientos de avisos. Algo había cambiado entre una versión y otra.

Solución 3: de @covers a #[CoversClass]

La causa era la diferencia de versión. Moodle 4.5 trae PHPUnit 9.6 y Moodle 5.2 trae PHPUnit 11.5. Y en PHPUnit 11 los metadatos en docblock (@covers, @dataProvider…) están deprecados a favor de los atributos de PHP. Como en mod_jitsi anoto la cobertura test por test, tenía un @covers en casi cada uno: de ahí la lluvia de avisos.

Antes, cada test llevaba su @covers en el comentario:

1/**
2 * @covers \mod_jitsi\external\register_push_subscription::execute
3 */
4public function test_register_push_subscription_creates_record(): void { /* ... */ }

Lo migré a un atributo a nivel de clase, que es como lo escribe el propio core de Moodle en 5.2:

1use PHPUnit\Framework\Attributes\CoversClass;
2
3#[CoversClass(\mod_jitsi\external\register_push_subscription::class)]
4final class external_test extends \advanced_testcase {
5    // ...
6}

Y aquí está el detalle bonito de compatibilidad, el que hace que esto no sea un dolor de cabeza: los atributos de PHP (#[...]) existen desde PHP 8.0, pero PHPUnit 9.6 no los lee (solo los interpreta PHPUnit 10 en adelante). Como un atributo solo “se activa” cuando alguien lo reflexiona, en 4.5 con PHPUnit 9.6 simplemente se ignoran, sin error.

Traducción: el mismo fichero de tests queda limpio en 11.5 y sigue pasando tal cual en 9.6. No necesito dos versiones de los tests, ni condicionales, ni trucos. Verificado en los dos sitios: en 5.2 con PHPUnit 11.5, toda la testsuite en verde y cero deprecations; en 4.5 con PHPUnit 9.6, los mismos tests en verde y sin un solo warning.

Si tienes plugins de Moodle, este cambio te va a tocar sí o sí cuando saltes a 5.x: migrar @covers y compañía a atributos es parte del peaje de PHPUnit 11. La buena noticia es que se puede hacer sin romper la compatibilidad con las versiones viejas.

La lección

Sacar un plugin de Moodle a su propio repo y enlazarlo a varias versiones es una idea estupenda: un único sitio donde vive el código y pruebas reales contra 4.5 y 5.2 a la vez. Lo volvería a hacer sin dudar.

Pero al desacoplar el plugin de un checkout concreto, el tooling no puede asumir nada: ni la ruta (../..), ni qué versión de PHPUnit hay, ni siquiera que PHPUnit exista. Las tres correcciones caben en una frase:

  • Detecta, no asumas: busca el Moodle, no des por hecho dónde está.
  • Degrada, no bloquees: avisa si faltan los tests, no canceles el push.
  • Escribe para lo nuevo sin romper lo viejo: atributos que 11.5 entiende y 9.6 ignora.

El ../.. parecía inofensivo durante años. Lo era… hasta que moví una carpeta. Casi todo el tooling que escribimos lleva dentro una suposición así, esperando su momento.

CompartirShare