Depuis trois semaines, je travaille sur ce que j’appelle un “*Big Fucking Refactoring*” de mon moteur de site statique, avec lequel je génère mon blog.
Dans le cadre de ce refactoring, il y a une fonctionnalité en particulier que je voulais améliorer : la capture d’écran pour les [liens intéressants](/liens-interessants/).
J’ai essayé toutes sortes de solutions au fil des années dans divers projets, les plus connues et utilisées étant probablement [wkhtmltoimage](https://wkhtmltopdf.org) au plus bas niveau et ses différents wrappers pour PHP ou plus spécifiquement Laravel (et notamment [Browsershot](https://spatie.be/docs/browsershot/v4/introduction)), et des solutions sous nodejs dont [puppeteer](https://pptr.dev).
J’ai aussi, beaucoup plus récemment, essayé [Browserless](https://docs.browserless.io).
Ces solutions m’ont rendu de fiers services, mais elles présentent toutes un fort désavantage : je dois modifier le `Dockerfile` de Laravel pour inclure tout un tas de librairies et de binaires qui alourdissent le container et font planer la menace d’incompatibilités lors de mises à jour, quand je ne dois pas télécharger la moitié d’Internet pour une méthode (je ne me lasse pas de la faire celle-là, désolé les nodeux 😙).
Je note également pas mal de limitations, et un manque certain de souplesse.
Et, sauf dans le cas de Browsershot, évidemment, un manque d’intégration à l’ecosystème [Laravel](https://laravel.com/).
Par exemple, puisque j’exécute Docker sur un Mac mini M2 avec Rosetta, j’ai ramé pour faire tourner puppeteer avec une version adéquate de Chrome : en cause, des binaires amd64 essayant de tourner sur une architecture arm64.
Vous voyez le genre de problèmes auxquels je me suis frotté.
Ce *BFR* était l’opportunité rêvée pour m’y mettre.
## Présentation de Laravel Dusk
Dusk est un package *first-party* pour Laravel permettant d’intégrer des tests frontend via [Selenium](https://www.selenium.dev).
Selenium fourni une instance de navigateur que l’on peut piloter par le code.
Parmi ses innombrables possibilités, il y en a donc une en particulier qui suscite mon intérêt : les captures d’écran.
Mais, étant donné que Dusk est un *framework* pour produire des tests, il n’y a pas de solution “simple” pour s’en servir *en dehors* des tests.
En cherchant sur GitHub, on pourra trouver [quelques essais](https://gist.github.com/MogulChris/6f2facf768ac3f280e9ad765e531dd55) ici ou là, parfois datés, plus maintenus et pas maintenables.
**Je veux étendre l’usage de Dusk au-delà des tests**.
Et, je ne suis pas peu fier du résultat…
## Comment j’ai procédé
Étant donné qu’il y a peu de ressources disponibles sur le web, et ça s’est traduit par des tentatives désespérées de ChatGPT pour m’aider, j’ai simplement dû faire un peu de rétro-ingénierie.
Les spécialistes du thème considéreront l’usage de ce terme comme inapproprié, considérant la simplicité avec laquelle j’ai abouti à un résultat convaincant, et surtout, parce que rien n’est vraiment caché dans Laravel.
### Installation de Dusk
On commence donc par installer Dusk, en suivant scrupuleusement la documentation fournie par [Laravel](https://laravel.com/docs/11.x/dusk).
En ce qui me concerne, puisque je travaille avec [Sail](https://laravel.com/docs/sail#laravel-dusk), je dois modifier mon `docker-compose.yml` :
```yaml
selenium:
image: 'seleniarm/standalone-chromium'
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- '/dev/shm:/dev/shm'
networks:
- sail
```
Et je rajoute la dépendance à selenium dans le `depends_on` de mon container principal :
```yaml
services:
laravel.test:
# [...]
depends_on:
- pgsql
- redis
- selenium
```
Je relance les services :
```shell
sail down && sail up -d
```
Et j’installe Dusk :
```shell
sail composer require laravel/dusk --dev
sail artisan dusk:install
```
### Exploration du code
Laravel a alors créé un dossier `tests/Browser`, dans lequel on trouve une partie de ce qui va nous intéresser.
Notamment, le fichier `ExampleTest.php`, fort simple à comprendre, et dont la principale caractéristique est d’hériter de `DuskTestCase`.
On peut en profiter pour voir qu’instancier un navigateur se résume à :
```php
$this->browse(function (Browser $browser) {
$browser->visit('https://google.com')
->assertSee('Google');
});
```
Pour prendre une capture d’écran du site de Google, il suffirait de changer ce code de cette façon :
```php
$this->browse(function (Browser $browser) {
$browser->visit('https://google.com')
->screenshot('screenshot');
});
```
Ce qui :
1. Nous oblige à lancer un test pour prendre la capture.
2. Sauvegarde la capture dans le dossier `tests/Browser/screenshots`.
On doit donc chercher comment accéder à la méthode `browse()` depuis n’importe quelle classe, c’est-à-dire, une classe que nous allons créer ultérieurement.
La classe `DuskTestCase` contient une méthode importante :
```php
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
Elle permet d’instancier un *driver* : c’est ce qui va nous permettre de communiquer avec Selenium et de le piloter.
On va donc se garder ça sous le coude, et on va continuer d’explorer le code fourni par Laravel.
La classe hérite de `BaseTestCase`, c’est-à-dire de la classe `Laravel\Dusk\TestCase`.
Et, là, les choses deviennent intéressantes.
Cette classe implémente deux traits : `ProvidesBrowser`, qui nous fourni la méthode `browse()` que l’on veut obtenir, et `SupportsChrome`, qui permet spécifiquement de piloter Chrome, vous l’aurez deviné.
J’aurais préféré éviter Chrome, mais, bon, tout est là, alors on va s’en servir.
Nous sommes prêts à commencer l’écriture de notre classe à screenshot.
### Screenshoteuse
On va partir du cahier des charges suivant : la classe doit prendre un URL en entrée, et sortir une ressource que l’on pourra stocker ou modifier à loisir.
On ne peut pas faire plus simple !
Pourtant, nous allons voir que nous avons besoin d’un peu plus de code que ce que l’on pourrait penser…
Voici l’intégralité de la classe, nous détaillerons ensuite.
```php
namespace App\Classes;
use Exception;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Exception\Internal\UnexpectedResponseException;
use Facebook\WebDriver\Exception\UnknownErrorException;
use Facebook\WebDriver\Exception\UnrecognizedExceptionException;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Str;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Chrome\SupportsChrome;
use Laravel\Dusk\Concerns\ProvidesBrowser;
class Screenshot
{
use ProvidesBrowser;
use SupportsChrome;
private $driver;
private $optionsSets = [
'normal' => [
'--start-maximized',
'--force-dark-mode',
'--no-sandbox',
'--disable-dev-shm-usage',
'--ignore-certificate-errors',
'--allow-insecure-localhost',
'--window-size=1920,1080',
],
'no-gpu' => [
'--disable-gpu',
'--headless',
'--start-maximized',
'--force-dark-mode',
'--no-sandbox',
'--disable-dev-shm-usage',
'--ignore-certificate-errors',
'--allow-insecure-localhost',
'--window-size=1920,1080',
],
];
private $tries = 0;
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
if (!isset($this->driver)) {
$options = $this->getDriverOptions();
$this->driver = RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'],
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
return $this->driver;
}
protected function getDriverOptions()
{
$options = (new ChromeOptions)
->addArguments(
collect($this->optionsSets[$this->optionsSet])
->all()
);
return $options;
}
public function __construct(public string $url, public ?string $optionsSet = 'normal')
Me voilà embarqué dans l’utilisation de Chrome avec du code fourni par facebook, je vais cramer…
Je crois que j’ai pas loin de 150 liens intéressants dans ma rubrique dédiée, je pense donc avoir un “large” panel de sites à tester.
Et, j’ai dû faire *beaucoup* de tests pour aboutir à ce résultat, parce que le code fourni par facebook est vraiment très, très académique.
Bien, donc, nous avons notre classe, et elle implémente les deux traits vus précédemment.
Ces traits nous obligent à définir quelques méthodes.
#### `name()` et `dataname()`
J’ignore ce que ces méthodes doivent retourner avec précision.
Je suppose que cela a quelque chose à voir avec les sessions créées dans Selenium à chaque instanciation des navigateurs afin d’éviter les confusions, je me contente donc de retourner le `slug` de l’URL passé en argument du constructeur.
#### `driver()`
Cette méthode doit fournir une instance de… `driver`, ici un `RemoteWebDriver` mais il est théoriquement possible de créer un driver personnalisé.
Ce n’est pas le but de ce que je suis en train de faire, donc je ne m’y suis pas intéressé plus que ça, mais ça pourrait être fun d’explorer la librairie [php-webdriver](https://github.com/php-webdriver/php-webdriver).
Pour l’heure, on se contente d’un copier-coller de ce que la classe `DuskTestCase` nous propose, juste un tout petit peu remanié.
Deux choses sont particulièrement importantes ici :
- on a créé une méthode `getDriverOptions()` qui permet de retourner un jeu d’options spécifiques (un sous-`array` de `$this->optionsSets` dont on cherche la clé fournie par `$this->optionsSet`)
- on utilise exclusivement `$_ENV['DUSK_DRIVER_URL']` pour obtenir l’URL de notre serveur Selenium
L’adresse du serveur par défaut dans les exemples de tests fournis par Laravel est `http://localhost:9515`, mais d’une part nous avons installé Selenium dans un container lié à notre projet, et d’autre part le port d’écoute est 4444.
C’est une excellente chose : cette simple ligne de configuration va nous permettre une certaine mobilité de notre service, si, un jour, on veut utiliser un serveur externe.
On peut ensuite voir ce que l’on fait dans notre méthode *custom*.
#### `take()`
C’est ici que le plus important se passe.
On commence par définir quelques options, presque exactement comme dans la classe `TestCase` : ce sont les appels à `Browser::`.
J’ai laissé la plupart des valeurs par défaut, à l’exception de `$baseUrl`.
Ça ne nous servira pas pour effectuer une capture d’écran, mais psychologiquement, je préfère spécifier l’URL que l’on s’apprête à screenshoter.
On commence par définir la variable `$shouldRetry` à `false`, parce que l’on aura parfois besoin de deux captures avec des réglages différents.
On ferme ensuite tous les autres navigateurs éventuellement ouverts dans Selenium.
Sans ça, souvent, on lèvera des exceptions de type `UnexpectedResponseException`, qui nous obligeront (ou en tout cas, qui m’ont obligé) à relancer tout le projet avec `sail down && sail up -d`.
Dans ces situations, on a beau envoyer des requêtes à Selenium, on ne verra rien dans les logs, et l’application continuera de tourner dans le vent.
On peut enfin appeler notre méthode `browse()` et obtenir une instance de navigateur.
Pour mes besoins personnels, j’ai défini une taille d’écran de 1920x1080, et j’ai ajouté un temps de pause de 1000ms afin de laisser le temps aux ressources de se charger, avant d’effectuer la capture (qui enregistrera un fichier nommé `screenshot` dans le dossier prédéfini dans `Browser::$storeScreenshotsAt`), et de quitter le navigateur.
Sauf que, [loi de Murphy](https://fr.wikipedia.org/wiki/Loi_de_Murphy) oblige, la méthode `quit()` n’est pas toute-puissante, et il lui arrive de ne pas faire ce qu’elle est censée faire.
C’est d’ailleurs principalement pour cette raison que tout le code est englobé dans un `try {} catch {}`, avec plusieurs cas prédéfinis.
Ce sont les cas que **j’ai** rencontré jusqu’à présent, il y en a **beaucoup** d’autres potentiels dans le *namespace*`Facebook\WebDriver\Exception`.
Attardons-nous simplement sur `UnrecognizedExceptionException`, qui semble survenir lorsque les options ne conviennent pas au site visité.
Comme je l’ai mis en commentaire, j’ai notamment constaté que YouTube n’aime pas quand on n’a pas activé le `headless` et donc `disable-gpu`, options que vous pouvez voir tout en haut de la classe, dans `private $optionsSets`, avec la clé `no-gpu`.
Malheureusement, un coup ça marche comme ça, un coup ça ne marche pas comme ça… C’est étonnamment peu stable pour des outils de tests, mais vous m’objecterez, avec raison, que justement, nous sommes en train de détourner ces outils de leur objectif initial, alors…
Donc, si jamais l’exception `UnrecognizedExceptionException` fini par être levée (et elle le sera), on affecte `true` à `$shouldRetry`, on applique un nouveau set d’options (`no-gpu`, sous-entendant que jusqu’à maintenant, on utilisait le set `normal`), ce qui nous permet plus loin de recommencer le processus.
Mais d’abord, on `finally``closeAll`.
Juste pour éviter d’énerver Selenium.
Sortant de cette boucle, on commence par voir si l’on doit recommencer le processus.
Arrivé à trois essais, on abandonne, on a tout tenté ou presque, il n’y a plus rien à faire, et surtout, on arrête de s’acharner sur Selenium, parce qu’à ce stade, il est tout simplement possible que l’on essaye de capturer un site… hors-ligne.
L’API risque de peu changer, donc je pense que ma classe est assez solide.
Je peux paramétrer les options assez facilement (d’ailleurs, au prochain refactoring, elles vont bouger dans le dossier `config`, c’est obligé), le driver principal est interchangeable, l’URL de Selenium est adaptable à tout moment, bref, elle risque de moins bouger que toutes mes tentatives précédentes.
Le code est assez propre (on peut toujours mieux faire, surtout en résolvant l’exercice proposé ci-dessus), un peu plus long que ce que j’espérais, mais, plus court que ce que je craignais, donc je suis content.
En outre, je pense qu’il n’est même pas requis d’installer ou utiliser Selenium : l’URL par défaut étant `http://localhost:9515` me laisse penser qu’il suffit d’avoir un Chrome disponible quelque part, paramétré pour le contrôle à distance.
Ça, c’est bien pour ceux à qui ça ne met pas la rate au court-bouillon, pas comme moi…
### Inconvénients
Si, par malheur, une exception non gérée devait être levée, il faut redémarrer la stack, ou en tout cas Selenium.
Peut-être que l’on pourrait l’éviter en interagissant directement avec le driver, mais à quoi bon utiliser un framework si c’est pour utiliser des outils de bas niveau…
## Conclusion
Je suis assez fier d’être arrivé à ce résultat du fait que rien sur Internet n’est réellement exploitable.
Ces tentatives sont au mieux mignonnes, au pire inutilisables.
Ici, on a une classe bien construite, simple, qui repose sur les fondations de Laravel.
On n’a rien hérité, rien transformé, seulement adapté.