Formation AngularJS

Découverte du framework

Bertrand GAILLARD
Sapiens RWD - @SapiensRWD

Déclaratif plutôt qu’impératif

jQuery/BackboneJS = impératif, le JS commande l'HTML

$('#mapBtn').on('click', function(event) {
  // do something
});

AngularJS = déclaratif, le HTML commande le JS

$scope.showMap = function() {
  // do something
}

impératif = difficile à organiser et maintenir

déclaratif = plus simple à lire et moins de code

Plus maintenable

  • Dans le code HTML, on a:

    • Pour le CSS, on a les #id et .class.
    • Pour le JS, on a les directives Angular ou personnalisées.

  • Aucun/moins de risque d'erreurs de:

    • Casser des sélécteurs JS en travaillant sur le style.
    • Casser des sélécteurs CSS en travaillant sur le javascript.

Etendre le language HTML

  • Utilisation de nouvelles balises et attributs HTML.
  • Permet une compréhension de l'application plus simple.
  • Angular parse le DOM et interpréte ces éléments.
  • Pas de syntaxe de template spéciale, c'est du HTML!

C'est quoi une application AngularJS?

  • Une partie du DOM sur laquelle on a mis l'attribut ng-app:

    ..
  • Autre moyen de démarrer une application en passant par javascript:

    ..
    angular.bootstrap(document.getElementById('divApp'), ['testApp']);
  • Possibilité de mettre plusieurs applications sur la même page.

  • Parse la partie du DOM et exécute les directives qu'il trouve en ajoutant des templates, exécutant du JS,..

Un framework MVC

http://swiip.github.io

Les controllers et les scopes

  • Toujours liés entre eux et à un élément du DOM.
  • Le scope est la glue entre controlleur et vue.
  • Le scope est un simple objet JS sur lequel on ajoute des propriétés (valeurs et méthodes).
  • Nouvelles instances créées au besoin puis détruites quand inutiles (pas de persistance).
  • Aucune manipulation de DOM dans les controllers.
  • Pour chaque ngApp, un $rootScope est créé, puis chaque controlleur crée un scope descendant, ce qui crée une arborescence.
  • Chaque $scope peut accéder à son parent grâce à $scope.$parent.
Hello {{name}}!
  1. {{name}} from {{department}}
  • Dans le JS, chaque scope hérite des propriétés et méthodes de ses parents grâce au prototypage.
  • Dans le DOM, on retrouve également cet imbrication/arborescence des scopes / controllers.
https://docs.angularjs.org/guide/scope

Les events

  • S'emettent et s'interceptent à partir des scopes.
  • Permet passer des informations d'un scope à un autre.
  • Comprend un nom unique pour toute l'application et une liste d'arguments.
  • A utiliser avec modération.

2 méthodes pour émettre un évènement

  • $emit

  • $broadcast

$emit

L'event remonte la hiérarchie des scopes jusqu'au $rootScope et peut être intercepté par chacun d'entre eux.

$scope.$emit('player.events.playVideo', video);
http://www.frangular.com/

$broadcast

L'event descend chaque branche de la hiérarchie des scopes en partant du scope émetteur et peut être intercepté par chacun d'entre eux.

$scope.$broadcast('player.events.playVideo', video)
http://www.frangular.com/

Interception avec $on

$scope.$on('player.events.playVideo', function(event, video) {
  $scope.playingVideo = video;
});
http://www.frangular.com/
app.controller('MainController', function($scope, $rootScope) {
  $scope.mainData = {
    logs: ''
  };
  
  $scope.$on('eventEmitedName', function(event, data) {
    $scope.mainData.logs = $scope.mainData.logs + '\nMainController - receive EVENT "' + event.name + '" with message = "' + data.message + '"';
  });
  
  $rootScope.$on('eventEmitedName', function(event, data) {
    $scope.mainData.logs = $scope.mainData.logs + '\n$rootScope - receive EVENT "' + event.name + '" with message = "' + data.message + '"';
  });
});

app.controller('ParentController', function($scope) {
  $scope.parentData = {
    message: 'messageAAA'
  };
  
  $scope.broadcastEvent = function() {
    $scope.$broadcast('eventBroadcastedName', $scope.parentData);
  };
  
  $scope.$on('eventEmitedName', function(event, data) {
    $scope.mainData.logs = $scope.mainData.logs + '\nParentController - receive EVENT "' + event.name + '" with message = "' + data.message + '"';
  });
});

app.controller('ChildController', function($scope) {
  $scope.childData = {
    message: 'messageBBB'
  };
  
  $scope.emitEvent = function() {
    $scope.$emit('eventEmitedName', $scope.childData);
  };
  
  $scope.$on('eventBroadcastedName', function(event, data) {
    $scope.mainData.logs = $scope.mainData.logs + '\nChildController - receive EVENT "' + event.name + '" with message = "' + data.message + '"';
  });
});
http://plnkr.co/edit/am6IDw?p=preview

Angular aussi utilise des events:

app.controller('PlayerCtrl', function($scope) {
  $scope.$on('$destroy', function(event) {
    // controlleur sur le point d'être détruit, dernière volonté?
  });
});

Le data-binding

  • Synchronisation automatique entre le modèle et la vue.
  • Unidirectionnel avec ngBind / {{ }}.

    {{ user.firstname }}

  • Bidirectionnel avec ngModel.

  • Terminé les sélecteurs CSS dans le JS.
  • Evite de (trés) nombreuses lignes de code JS.
  • Et donc d'erreurs.

Les directives

  • Permet d'étendre le language HTML.
  • Permet d'encapsuler du JS et de l'HTML dans une "boite noire".
  • Va dans le sens des futurs standards du web (WebComponents, Polymer).
  • Génial pour la réutilisation, le partage, le travail en équipe, les tests,..
  • Une des parties les plus puissantes mais aussi difficiles.

Directives peuvent prendre 4 formes:

  • Elément/Tag
  • Attribut
  • Commentaire
  • Classe
  • Directives natives commencent par ng- (conventions).
  • Mettre son prefix (conventions).
  • Nommage avec des tirets dans l'HTML (ng-click) et en camelCase en JS (ngClick).
  • Possibilité de écrire sous différentes formes dans l'HTML:






                        

Directives simples:

  • ng-bind / {{ }}
  • ng-click / ngDblclick / ngMouseenter / ..
  • ng-class
  • ng-show / ng-hide
  • ng-init
  • ng-model
  • ng-focus

Directives créant un nouveau scope:

  • ng-controller
  • ng-if
  • ng-include
  • ng-switch
  • ng-repeat

Les modules

  • Permet un découpage de l'application.
  • Découpage par fonctionnalité préconisé.
  • Ne sert pas de namespace.
  • Le framework lui-même découpé en modules: ngRoute, ngAnimate, ngSanitize, ngMock,..
  • Tous instanciés au démarrage (pas de lazy loading).
  • Application AngularJS = Un module principal + modules dont le principal dépend.

Déclaration de module:

angular.module('myapp', ['myapp.feature1'
                        'myapp.feature2',
                        'ng-route',
                        'ng-animate'
                        'ui-router']);

Récupération de module:

angular.module('myapp');

Tous les controlleurs, directives, filters et services sont déclarés sur un module.

http://www.manning.com/aden/

Exemple:

angular.module('myapp', ['myapp.utils', 'myapp.gallery']);
angular.module('myapp').controller('HomeCtrl', function() {});
angular.module('myapp').service('myService', function() {});

angular.module('myapp.gallery', []);
angular.module('myapp.gallery').controller('GalleryCtrl', function() {});

angular.module('myapp.utils', []);
angular.module('myapp.utils').constant('myFilter', {});
angular.module('myapp.utils').filter('myFilter', function() {});

Au chargement de chaque module:

  • Exécution des config() puis des run().
  • On peut affecter autant de config() et de run().
  • Exécuté avant tous les controllers, directives, filtres,..
angular.module('myapp', []);
                        
angular.module('myapp').config(function($httpProvider) {
    // Injection des providers des services
    // (objets servant en interne à créer les instances des services)
    // et de les configurer avant leur instanciation
    $httpProvider.defaults.headers.common['Access-Control-Max-Age'] = '1728000';
    $httpProvider.defaults.headers.common['Accept'] = 'application/json, text/javascript';
    $httpProvider.defaults.headers.common['Content-Type'] = 'application/json; charset=utf-8';
});

angular.module('myapp').run(function($http) {
  // Injection des instances des services pour utilisation
  $http.post('/someUrl', {msg:'appStart'});
});

Les services

  • Un service = simple objet JS avec propriétés et méthodes.
  • Utilise l'injection de dépendances.
  • Créé une seule fois lors du premier appel (Singleton).
  • Réutilisé lors des appels suivants pour toute la durée de vie de la SPA.
  • Accessible de partout quelque soit le module sur lequel ils sont créés ou appelés.
  • Peut être injecté dans controllers, filtres, services,..
  • Utilisation à volonté pour alléger au maximum les controllers.

Différentes déclarations de services

Il existe 5 méthodes pour déclarer un service mais dans tous les cas, ils s'injectent et s'utilisent de la même manière et il en existe toujours une seule instance.

  • module.provider()
  • module.factory()
  • module.service()
  • module.value()
  • module.constant()
En pratique, factory() et value() sont utilisés dans la plupart du cas.

Méthode FACTORY()

  • Permet de créer une boite noire avec une API publique.
  • Possibilité d'injecter et d'utiliser d'autres services.
app.factory('MyService', function($window) {
    var scale = 1;
    
    return {
        getScreenWidth: function() {
            return $window.innerWidth * scale;
        },
        getScreenHeight: function() {
            return $window.innerHeight * scale;
        }
    };
});

app.controller('AppCtrl', function($scope, MyService) {
  var _width = MyService.getScreenWidth();
});

Méthode VALUE()

  • Stocker un objet JS de type object ou bien de type primitif (booléen, string, number).
  • Peut être modifié pendant l'éxecution de l'application.
  • Impossible d'utiliser l'injection de dépendances pour utiliser d'autres services.
app.value('GoogleMapAPI_KEY', '5VUhR4kqvVvhXRH4BIAN')

app.value('VideoData', {
    title: 'Ters Akan şelale!',
    description: 'Bu işte bir yanlışlık var diye İngiltere',
    url: 'http://www.dailymotion.com/video/x28i2n3_ters-akan-selale_news'
})

app.controller('AppCtrl', function($scope, VideoData) {
  $scope.video = VideoData;
});

Exemple de déclaration de services

playerApp.value('Params', {
  URL_API: 'https://api.dailymotion.com/videos',
  URL_EMBED: 'http://www.dailymotion.com/embed/video',
  VIDEOS_PER_PAGE: 12,
  VIDEOS_AUTOPLAY: true,
});

playerApp.factory('Dailymotion', function($http, $sce, Params) {
  var getApiUrl = function(terms, page) {
      var api_params =  '?fields=id,title,description,duration_formatted,created_time&sort=relevance';
      var api_limit =   '&limit=' + Params.VIDEOS_PER_PAGE;
      var api_page =    '&page=' + page;
      var api_search =  '&search=' + terms.split(' ').join('+');
      var api_jsonp =   '&callback=JSON_CALLBACK';
    
    return Params.URL_API + api_params + api_limit + api_page + api_search + api_jsonp;
  };
  
  var Service = {
    getResults: function(terms, page) {
      return $http({
        method: 'JSONP',
        url: getApiUrl(terms, page),
        cache: true,
        transformResponse: function (data, headers) {
          data.nbPages = Math.ceil(data.total / data.limit);
          return data;
        }
      });
    },
    
    getIframeUrl: function(videoId) {
          var url = Params.URL_EMBED + '/' + videoId + '?autoPlay=' + Params.VIDEOS_AUTOPLAY;
          return $sce.trustAsResourceUrl(url);
    }
  };
  
  return Service;
});

Exemple d'utilisation de services

playerApp.controller('VideoPlayerModalCtrl', function($scope, $modalInstance, Dailymotion, video) {
  $scope.video = video;
  $scope.iframeUrl = Dailymotion.getIframeUrl(video.id);
  
  $scope.close = function () {
    $modalInstance.close();
  };
});

Services fournis par Angular

  • Couche d'abstraction utile pour fonctionner dans l'écosystéme AngularJs et pour être mocké lors des tests.

  • Commencent tous par $ (conventions):
    $http, $location, $filter, $window, $timeout, $interval, $animate, $rootScope, $document,..

  • Providers associés pour les configurer au démarrage:
    $httpProvider, $locationProvider, $filterProvider, $animateProvider,..

Les filtres

  • Fonction qui transforme un input en output.
  • Utilisation similaire à Twig.
  • Stateless = Si la donnée à transformer ne change pas, le filtre ne sera pas réexeuté.
  • Utilisation en HTML ou en JS.

Création d'un filtre

app.filter('truncate', function() {
  return function(str, nbChar) {
    return str.substring(0, nbChar);
  };
});

Utilisation HTML d'un filtre

{{ description | truncate:10 }}


Possibilité de les cumuler:

{{ data | filterA:param1 | filterB | filterC:param1:param2 }}

Utilisation JS d'un filtre

app.controller('MainCtrl', function($scope, $filter) {
  var longText = 'Trés long texte, trés long texte, trés long texte';
  
  $scope.filteredText = $filter('truncate')(longText, 10);
});
app.controller('MainCtrl', function($scope, truncateFilter) {
  var longText = 'Trés long texte, trés long texte, trés long texte';
  
  $scope.filteredText = truncateFilter(longText, 10);
});

Filtres natifs: date, uppercase, json,..

{{ today | date:'short' }}      
{{ today | date:'yyyy' }}       

{{ 789123.4567 | number:2 }}    

{{ 123 | currency }}            

{{ 'Vincent' | uppercase }}     
{{ 'Vincent' | lowercase }}     
{{ 'Je fais un essai' | limitTo: 2}}        
{{ 'Je fais un essai' | limitTo: -5}}       

{{ [1, 2, 3, 4] | limitTo: 2}}              
{{ ['alpha', 'beta', 'gamma'] | filter:'e' }}   

{{  [{'nom': 'Nabet', 'prenom':'Vincent'},
     {'nom': 'Van Gogh', 'prenom':'Vincent'}] | filter:{'nom':'Nabet'}:true }}
       

{{ ['alpha', 'beta', 'gamma'] | filter:fourCharac }}   
$scope.fourCharac = function(elem) { return elem.length === 4;}

Les promesses

  • Promise = Objet JS correspondant au résultat différé d'une opération asynchrone.
  • Trés utilisé dans l'écosystéme AngularJS.
  • Plus souple que les callbacks.
  • Possibilité des les imbriquer.
  • Gestion des erreurs.
  • Les services $http et $timeout retournent une promesse.
app.controller('AppController', function($scope, $timeout) {
  var promise = $timeout(3000);
  
  $scope.name = 'waiting promise resolve';
  
  promise.then(function() {
    $scope.name = 'resolved!';
  });
});
http://plnkr.co/edit/sYinVP?p=preview
app.controller('GreetController', function($scope, $http) {
  var promise = $http.get('https://api.github.com/users/bertrandg');
  
  promise.then(function(response) {
    $scope.user = response.data;
  }, function(reason) {
    $scope.error = reason;
  });
  
  $scope.loading = true;
  promise.finally(function(){
    $scope.loading = false;
  });
});
http://plnkr.co/edit/7fbtsQ?p=preview
  • Création de promesses grâce au service $q.
app.factory('MaPromesse', function($q, $timeout) {
  
  var Service = {
    get: function() {
      var deferred = $q.defer();
      
      $timeout(3000).then(function() {
        //deferred.resolve("terminé !");
        deferred.reject("terminé mais mal !");
      });
      
      return deferred.promise;
    }
  };
  
  return Service;
});

app.controller('GreetController', function($scope, MaPromesse) {
  
  MaPromesse.get().then(function(data) {
    $scope.data = data;
  }, function(reason) {
    $scope.data = reason;
  });
});
http://plnkr.co/edit/wo4DNu?p=preview

Les formulaires

  • Validation de formulaire trés simple.
  • Permet de fournir des indications visuelles à l'utilisateur.
  • Permet de controler les données entrées par l'utilisateur.
  • Ne dispense pas des validations serveurs evidemment!

Comment ça se passe

  • Grâce au pouvoir des directives, tous les éléments HTML du formulaire sont 'étendus' par Angular:
    form, input, button,..
  • Ainsi que tous les attributs de validation HTML5:
    required, min="4", type="email",..
  • Et Angular ajoute des directives de validation:
    ng-maxlength="20", ng-pattern="/^[a-zA-Z]+$/",..
  • En bonus, vous pouvez créer vos propres directives de validation, y compris asynchrones!

Exemple

app.controller('MainController', function($scope) {
  $scope.newUser = {
    firstname: '',
    lastname: '',
    email: '',
    website: ''
  };
  
  $scope.createUser = function(user) {
    alert('nouveau user valide prêt!');
  };
});
http://plnkr.co/edit/0oFGvN?p=preview

Etats et erreurs du formulaire

Le formulaire et chaque champ ont des booléens assignés:

  • $dirty: Champ ou formulaire déjà modifié.
  • $pristine: Champ ou formulaire pas encore modifié.
  • $valid: Champ ou formulaire valide.
  • $invalid: Champ ou formulaire invalide.
  • $error: Erreur courante du champ ou formulaire.
$scope.registerForm.$valid; /* true si formulaire valide */
$scope.registerForm.nom.$invalid; /* true si champ nom n'est pas valide */
$scope.registerForm.courriel.$dirty; /* true si champ courriel n'a pas encore été 'utilisé' */

$scope.registerForm.prenom.$error.required; /* true si validation required du champ prenom non remplie */
$scope.registerForm.courriel.$error.email; /* true si validation email du champ courriel non remplie */

Affichage des erreurs

Prénom requis!

Prénom doit faire 4 caractéres minimum!

Nom requis!

Nom doit faire 4 caractéres minimum!

Courriel requis!

Courriel doit contenir @ et..!

Site web requis!

Site web doit être de la forme http://..!

http://plnkr.co/edit/Wix7sD?p=preview

Encore mieux, le module ngMessages

var app = angular.module('myApp', ['ngMessages']);

Prénom requis!

Prénom doit faire 4 caractéres minimum!

Nom requis!

Nom doit faire 4 caractéres minimum!

Courriel requis!

Courriel doit contenir @ et..!

Site web requis!

Site web doit être de la forme http://..!

http://plnkr.co/edit/00vSyb?p=preview

Bonus

Angular ajoute des classes CSS sur les input en fonction de leur état.

/* SASS */

form input {
  border-width: 4px;
  
  &.ng-dirty {
    &.ng-valid {
      border-color: green;
    }
    
    &.ng-invalid {
      border-color: red;
    }
  }
}

Le routing

Différentes options possibles

  • Module ngRoute: Basé sur les routes (#/posts/12), trés basique, pas de vues imbriquées, plus mis à jour.

  • Module uiRouter: Basé sur les states ('app.posts.details'), librairie remplaçante, beaucoup plus souple.

  • Module ngNewRouter Nouveau router qui arrive avec la version 1.4 et sera aussi utilisé dans Angular 2.

ngRoute

app.config(function($routeProvider) {
  
  $routeProvider.when(
    '/page1', 
    {
      templateUrl: 'page1.tpl.html'
    }
  );
  
  $routeProvider.when(
    '/page2/:name',
    {
      controller: 'Page2Ctrl',
      templateUrl: 'page2.tpl.html'
    }
  );
  
  $routeProvider.when(
    '/pages3/article/:id',
    {
      controller: function($scope, $routeParams) {
        $scope.idArticle = $routeParams.id;
      },
      template:  '
\

PAGE3

\

Details de l\'article id: {{ idArticle }}

\
' } ); $routeProvider.otherwise({redirectTo: '/page1'}); });
http://plnkr.co/edit/rFUxCD?p=preview

uiRouter

app.config(function($stateProvider, $urlRouterProvider) {
  
  $stateProvider.state(
    'app',
    {
      url: '/', 
      template: '
' } ); $stateProvider.state( 'app.page1', { url: 'page1/', templateUrl: 'page1.tpl.html' } ); $stateProvider.state( 'app.page1.detailA', { url: 'detailA', template: '
\

DETAILA

\
' } ); $stateProvider.state( 'app.page1.detailB', { url: 'detailB', template: '
\

DETAILB

\
' } ); $stateProvider.state( 'app.page2', { url: 'page2/:name', controller: 'Page2Ctrl', templateUrl: 'page2.tpl.html' } ); $stateProvider.state( 'app.page3', { url: 'page3/article/:id', controller: function($scope, $stateParams) { $scope.idArticle = $stateParams.id; }, template: '
\

PAGE3

\

Details de l\'article id: {{ idArticle }}

\
' } ); $urlRouterProvider.otherwise('/page1'); });
http://plnkr.co/edit/xSpKKD?p=preview

ngNewRouter

  • Trés récent et encore en version béta.

  • Basé sur le principe des composants trés puissant.

  • Va être un élément clé pour la migration vers Angular 2.

Les animations

  • Découplées et réutilisables.
  • Animations CSS (transitions & keyframes) et JS supportées.
  • Utilisation du module ngAnimate.
  • Fonctionne uniquement avec une class.

animations CSS

  • Possibilité de coder soi même ses animations ou bien d'utiliser une librairie externe (animate.css / Effeckt.css).
  • ngAnimate ajoute de classes CSS sur diférentes directives:
https://docs.angularjs.org/guide/animations


var app = angular.module('app', ['ngAnimate']);

                    	

Hi guys !!!

/* SASS */
.myTitle.ng-hide-remove {
  @include animation(0, 1s, rollIn); // zoomInDown bounceInLeft
}
.myTitle.ng-hide-add {
  @include animation(0, 1s, rollOut); // zoomOutDown bounceOutRight
}
http://plnkr.co/edit/IbgIcn?p=preview

animations JS

  • Utilisation de librairies externes:
    jQuery, Greensock, Anima
  • Permet des animations complexes (exemple).
  • Utile pour performances sur supports mobiles.

Hi guys !!!

app.animation('.myTitle', function () {
  return {
    enter: function(element, done) {
      element.css({
        opacity: 0,
        position: "relative",
        top: '-50px'
      })
      .animate({
        opacity: 1,
        top: '0'
        }, 500, done);
    },
    leave: function(element, done) {
      element.css({
        opacity: 1,
        position: "relative"
      })
      .animate({
        opacity: 0,
        right: '100%'
        }, 1000, done);
    }
  };
});
http://plnkr.co/edit/dN3fpG?p=preview

Les tests

pensé pour les tests

Tout est testable:
Controllers, Services, Directives, Filters,...

ainsi que l'application dans sa globalité!

Deux types de tests

  • les tests unitaires.
  • les tests d’intégration dits tests End-to-End (E2E).

Tests unitaires

L'application est composé de modules, eux-mêmes composés de controllers, services, directives, filters,..

Toutes ces briques communiquent entre elles par le biais d'une API publique et se comportent comme des "boites noires".

Les tests unitaires vérifient ces API publiques.

Les outils:

  • Karma: Application NodeJS pour faire tourner les tests.
  • Jasmine: Framework pour écrire les tests (ou Mocha).
  • ngMock: Utilitaires et services spécial tests unitaires.
  • $httpBackend: Service pour simuler $http.

Possibilité de faire du TDD.

Test d'un controller:

angular.module('monApp').controller('DashboardCtrl', function ($scope) {
  $scope.score = 456585;
});
describe('Tests du Controller: DashboardCtrl', function () {
    var DashboardCtrl,
        scope;
 
    beforeEach(module('monApp'));
 
    beforeEach(inject(function ($controller, $rootScope) {
        scope = $rootScope.$new();
        DashboardCtrl = $controller('DashboardCtrl', {
            $scope : scope
        });
    }));
 
    it('le score doit etre defini!', function () {
        expect(scope.score).toBeDefined();
        expect(scope.score).toBe(456585);
    });
});

Tests E2E avec Protrator

  • Famework dédié à AngularJS utilisant Node.js.
  • Choix des OS/navigateurs sur lesquels exécuter les tests.
  • Intéragit avec l'application comme un vrai utilisateur.
  • Possibilité de faire tourner les tests avec une tâche Grunt.
  • Fichiers à l'extension .e2e.js.
  • Combine des outils puissants: Selenium, webDriver, Jasmine..
  • Peut tourner avec PhantomJS (headless browser).
// todo-spec.js
describe('angularjs homepage todo list', function() {
  it('should add a todo', function() {
    browser.get('http://www.angularjs.org');

    element(by.model('todoText')).sendKeys('write a protractor test');
    element(by.css('[value="add"]')).click();

    var todoList = element.all(by.repeater('todo in todos'));
    expect(todoList.count()).toEqual(3);
    expect(todoList.get(2).getText()).toEqual('write a protractor test');
  });
});
// conf.js
exports.config = {
  seleniumAddress: 'http://localhost:4444/wd/hub',
  specs: ['todo-spec.js']
};
// ligne de commande
protractor conf.js

Chrome s'ouvre, interactions, puis se ferme rapidement.

1 test, 2 assertions, 0 failures